---
phase: 06-telegram-bot-commands
plan: 02
type: execute
wave: 2
depends_on: [06-01]
files_modified:
  - plugins/claude-tts-companion/Sources/claude-tts-companion/PromptExecutor.swift
  - plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift
  - plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift
autonomous: true
requirements: [BOT-05, BOT-06]

must_haves:
  truths:
    - "/prompt command with --haiku, --sonnet, --opus flags spawns Claude CLI with correct model"
    - "Streaming response is forwarded to Telegram as edit-in-place message updates"
    - "Bot resumes existing sessions when session ID is provided"
    - "Mutex prevents concurrent prompt executions"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/PromptExecutor.swift"
      provides: "/prompt command handling with model selection, streaming, edit-in-place"
      exports: ["PromptExecutor", "parsePromptFlags"]
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift"
      provides: "Updated bot with /prompt command handler and /done detach"
      contains: "handlePrompt"
  key_links:
    - from: "PromptExecutor.swift"
      to: "ClaudeProcess.swift"
      via: "spawns subprocess and consumes stream events"
      pattern: "ClaudeProcess"
    - from: "PromptExecutor.swift"
      to: "TelegramBot.swift"
      via: "editMessage for streaming updates"
      pattern: "editMessage"
    - from: "TelegramBot.swift"
      to: "PromptExecutor.swift"
      via: "/prompt command dispatches to executor"
      pattern: "handlePrompt"
---

<objective>
Wire the /prompt command into the Telegram bot with model selection flags, streaming edit-in-place, and session resume support.

Purpose: Users can send /prompt from Telegram to interact with Claude CLI. Model selection (--haiku, --sonnet, --opus) controls which model responds. Streaming NDJSON from the subprocess is forwarded as progressive Telegram message edits. Session resume continues existing conversations.

Output: PromptExecutor.swift, updated TelegramBot.swift with /prompt and /done handlers, updated main.swift
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-telegram-bot-commands/06-CONTEXT.md
@.planning/phases/06-telegram-bot-commands/06-01-SUMMARY.md

<interfaces>
<!-- Created in Plan 01 — executor depends on these -->
From ClaudeProcess.swift (Plan 01):
```swift
enum ClaudeStreamEvent {
    case text(String)
    case toolUse(name: String)
    case error(String)
    case done
}

final class ClaudeProcess {
func run(prompt: String, model: String, cwd: String, resume sessionId: String?, maxTurns: Int) -> AsyncThrowingStream<ClaudeStreamEvent, Error>
func cancel()
}

````

From Config.swift (Plan 01 additions):
```swift
static let claudeCliPath: String   // "/usr/local/bin/claude"
static let haikuModel: String      // "claude-haiku-4-5"
static let sonnetModel: String     // "claude-sonnet-4-6"
static let opusModel: String       // "claude-opus-4-6"
static let promptDefaultCwd: String
static let promptTimeoutSeconds: Int
````

From TelegramBot.swift (existing):

```swift
final class TelegramBot: @unchecked Sendable {
    private var bot: TGBot?
    private let chatId: Int64
    func sendMessage(_ text: String, parseMode: TGParseMode = .html) async
    // replyToChat is private — will need to add editMessage or expose it
}
```

From TelegramFormatter.swift (existing):

```swift
static func escapeHtml(_ text: String) -> String
static func markdownToTelegramHtml(_ markdown: String) -> String
static func chunkTelegramHtml(_ text: String, limit: Int) -> [String]
```

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: PromptExecutor — /prompt handler with model flags, streaming edit-in-place, resume (BOT-05, BOT-06)</name>
  <files>plugins/claude-tts-companion/Sources/claude-tts-companion/PromptExecutor.swift</files>
  <read_first>
    - plugins/claude-tts-companion/Sources/claude-tts-companion/ClaudeProcess.swift (Plan 01 output)
    - plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift
    - plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramFormatter.swift
    - ~/.claude/automation/claude-telegram-sync/src/telegram/prompt-executor.ts (reference for flag parsing, streaming, edit throttle)
  </read_first>
  <action>
Create PromptExecutor.swift — a final class managing prompt execution lifecycle.

**Flag parsing (BOT-05):**

Create a free function `parsePromptFlags(_ raw: String) -> (model: String, text: String)`:

- `--haiku` → Config.haikuModel
- `--sonnet` → Config.sonnetModel
- `--opus` → Config.opusModel
- `--model <id>` → literal model ID
- Default (no flag) → Config.sonnetModel
- Flags are case-insensitive, must appear at start of text
- Return cleaned text with flag stripped

Create `modelLabel(_ modelId: String) -> String`:

- Contains "haiku" → "haiku"
- Contains "sonnet" → "sonnet"
- Contains "opus" → "opus"
- Otherwise → first two segments of model ID

**PromptExecutor class:**

```swift
final class PromptExecutor: @unchecked Sendable {
    private let logger = Logger(label: "prompt-executor")
    private var isExecuting = false
    private var lastExecutionTime: Date = .distantPast
    private var currentProcess: ClaudeProcess?

    // Safety: mutex, rate limiting, circuit breaker (matching TS patterns)
    private let minIntervalSeconds: TimeInterval = 30
    private var consecutiveFailures = 0
    private var disabledUntil: Date = .distantPast
    private let maxFailures = 3
    private let disableDurationSeconds: TimeInterval = 600  // 10 min

    // Edit throttle for Telegram rate limiting
    private let editThrottleSeconds: TimeInterval = 1.5
    private let minInitialChars = 200
}
```

**Main method: `func execute(prompt: String, model: String, cwd: String, resumeSessionId: String? = nil, sendMessage: @escaping (String) async -> Int?, editMessage: @escaping (Int, String) async -> Void) async`**

Parameters:

- `sendMessage` closure: sends a Telegram message, returns message_id (Int?) for edit-in-place
- `editMessage` closure: edits message by ID with new text
- These closures are injected from TelegramBot to avoid coupling

Flow:

1. Check circuit breaker → if open, sendMessage with warning, return
2. Check mutex → if executing, sendMessage with "please wait", return
3. Check rate limit → if too soon, sendMessage with "rate limited, try in Xs", return
4. Set isExecuting = true, record lastExecutionTime
5. Send initial status message: "Thinking... (model_label)" or "Resuming session_id..." via sendMessage, capture message_id
6. Create ClaudeProcess, store in currentProcess
7. Call process.run(prompt:model:cwd:resume:maxTurns:)
8. Iterate over AsyncThrowingStream:
   - On .text(accumulated): throttled editMessage with markdown-to-HTML preview (last 2000 chars). Apply regressive suppression: skip edit if new preview starts with what was already sent.
   - On .toolUse(name): append to tool list (for final display)
   - On .error(msg): log
   - On .done: break
9. On completion: final editMessage with full formatted response (model label + duration + body). Use chunkTelegramHtml for overflow.
10. On timeout (Config.promptTimeoutSeconds): cancel process, editMessage with timeout notice
11. In defer: isExecuting = false, currentProcess = nil
12. Record success/failure for circuit breaker

**Resume mode (BOT-06):**
When resumeSessionId is provided, pass it to ClaudeProcess.run(resume:). The initial status message says "Resuming {sessionId.prefix(8)}..." instead of "Thinking...". Max turns reduced to 5 for resume (matching TS).

**Timeout implementation:** Use `withThrowingTaskGroup` or `Task.sleep` racing against the stream iteration. When timeout fires, call currentProcess?.cancel().
</action>
<verify>
<automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>PromptExecutor compiles, parsePromptFlags correctly maps --haiku/--sonnet/--opus to model IDs, execute method handles streaming with edit-in-place throttling, circuit breaker and mutex prevent concurrent/rapid execution</acceptance_criteria>
<done>/prompt command handler parses model flags, spawns ClaudeProcess, streams NDJSON to Telegram via edit-in-place with throttling, supports resume mode</done>
</task>

<task type="auto">
  <name>Task 2: Wire PromptExecutor into TelegramBot + update /commands and /done</name>
  <files>plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift, plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift</files>
  <read_first>
    - plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift
    - plugins/claude-tts-companion/Sources/claude-tts-companion/PromptExecutor.swift (Task 1 output)
    - plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift
  </read_first>
  <action>
**TelegramBot.swift changes:**

1. Add `private let promptExecutor = PromptExecutor()` property

2. Add `editMessage` method (new, needed for edit-in-place):

```swift
/// Edit an existing message by ID. Returns silently on failure.
func editMessage(messageId: Int, text: String) async {
    guard let bot = bot else { return }
    do {
        let params = TGEditMessageTextParams(
            chatId: .chat(chatId),
            messageId: messageId,
            text: text,
            parseMode: .html,
            linkPreviewOptions: TGLinkPreviewOptions(isDisabled: true)
        )
        try await bot.editMessageText(params: params)
    } catch {
        // Retry with plain text on HTML parse error (matching TS safeEditMessage pattern)
        do {
            let plainParams = TGEditMessageTextParams(
                chatId: .chat(chatId),
                messageId: messageId,
                text: TelegramFormatter.stripHtmlTags(text)
            )
            try await bot.editMessageText(params: plainParams)
        } catch {
            logger.warning("Failed to edit message \(messageId): \(error)")
        }
    }
}
```

1. Add `sendMessageReturningId` method (returns message_id for edit-in-place):

```swift
/// Send a message and return its message_id for later editing.
func sendMessageReturningId(_ text: String) async -> Int? {
    guard let bot = bot else { return nil }
    do {
        let params = TGSendMessageParams(
            chatId: .chat(chatId),
            text: text,
            parseMode: .html,
            linkPreviewOptions: TGLinkPreviewOptions(isDisabled: true)
        )
        let msg = try await bot.sendMessage(params: params)
        return msg.messageId
    } catch {
        logger.error("Failed to send message: \(error)")
        return nil
    }
}
```

1. Add `handlePrompt(update:)` command handler:
   - Extract text after "/prompt " from update.message?.text
   - If empty text, reply with usage: "/prompt [--haiku|--sonnet|--opus] your question"
   - Parse flags via parsePromptFlags
   - Call promptExecutor.execute(prompt:model:cwd:resumeSessionId:sendMessage:editMessage:) passing closures that call self.sendMessageReturningId and self.editMessage
   - For now, cwd = Config.promptDefaultCwd, resumeSessionId = nil (session resume wiring needs /sessions to provide session context — that's Phase 7)

2. Update `handleDone(update:)`:
   - Replace stub text with: "Session detach: no active prompt session to detach." (placeholder until Phase 7 wires session tracking)

3. Update `handleCommands(update:)`:
   - Add `/prompt` line: "/prompt - Send prompt to Claude (--haiku, --sonnet, --opus)"

4. Register `/prompt` in BotDispatcher.handle():

```swift
await add(TGCommandHandler(commands: ["prompt"]) { [weak self] update in
    guard let self = self else { return }
    await self.telegramBot.handlePrompt(update: update)
})
```

1. Add "prompt" to the TGBotCommand menu registration array in start():

```swift
TGBotCommand(command: "prompt", description: "Send prompt to Claude CLI"),
```

**main.swift changes:**

- No changes needed — PromptExecutor is created inside TelegramBot, no new subsystem injection required
- Verify the build compiles cleanly
  </action>
  <verify>
  <automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
  </verify>
  <acceptance_criteria>/prompt registered in dispatcher and command menu, handlePrompt extracts text and delegates to PromptExecutor, editMessage enables edit-in-place updates, /commands list updated, swift build succeeds</acceptance_criteria>
  <done>/prompt command works end-to-end: parses flags, spawns Claude CLI, streams response to Telegram as edit-in-place, /commands updated, /done placeholder improved</done>
  </task>

</tasks>

<verification>
```bash
cd plugins/claude-tts-companion && swift build 2>&1 | tail -10
# Must show "Build complete!" with no errors
```
</verification>

<success_criteria>

1. /prompt --haiku, --sonnet, --opus flags correctly select model (BOT-05)
2. PromptExecutor spawns ClaudeProcess and streams response to Telegram with edit-in-place (CLI-02)
3. Resume mode passes session ID to ClaudeProcess (BOT-06)
4. Mutex, rate limiting, and circuit breaker prevent abuse
5. `swift build` succeeds with zero errors
   </success_criteria>

<output>
After completion, create `.planning/phases/06-telegram-bot-commands/06-02-SUMMARY.md`
</output>
