feat: switch a running session model mid-conversation via a model-alias keyword - #114
feat: switch a running session model mid-conversation via a model-alias keyword#114Koha101 wants to merge 3 commits into
Conversation
| try { | ||
| Bun.spawn(['tmux', 'send-keys', '-t', info.tmuxName, 'Escape'], spawnOpts) | ||
| Bun.spawn(['tmux', 'send-keys', '-t', info.tmuxName, '-l', `/model ${resolved}`], spawnOpts) | ||
| Bun.spawn(['tmux', 'send-keys', '-t', info.tmuxName, 'Enter'], spawnOpts) |
There was a problem hiding this comment.
Should-fix (flagged by both reviewers): Three Bun.spawn calls fire without awaiting — no sequencing guarantee. Under OS scheduling skew, Enter can arrive before the /model text, or the text before Escape clears the composer. The existing ! handler only spawns once so it doesn't have this problem.
Fix: await each process's .exited before starting the next:
await Bun.spawn([...Escape...], spawnOpts).exited
await Bun.spawn([...model text...], spawnOpts).exited
await Bun.spawn([...Enter...], spawnOpts).exited| } catch (err) { | ||
| process.stderr.write(`daemon: model switch failed for ${info.tmuxName}: ${err instanceof Error ? err.message : err}\n`) | ||
| } | ||
| void gateway.react(msg.channelId, msg.id, '🔁').catch(() => {}) |
There was a problem hiding this comment.
Should-fix (flagged by both reviewers): The 🔁 react fires unconditionally — even when all three tmux spawns silently fail (stale pane, dead session). The user gets false confirmation that the switch happened.
Move the react inside the try block after the spawns, or better yet, await .exited (per the sequencing comment) and check exit codes before reacting. On failure, reply with an error message instead of reacting.
| // approach as the ! interrupt below. | ||
| const modelMatch = msg.content.match(MODEL_SWITCH_RE) | ||
| if (modelMatch) { | ||
| const resolved = resolveModelAlias(modelMatch[1]) ?? modelMatch[1] |
There was a problem hiding this comment.
Should-fix (flagged by both reviewers): When resolveModelAlias returns undefined (typo like "sonett"), the raw string is silently injected into the tmux pane via /model sonett. Claude Code will reject it, but the user gets no feedback — just a 🔁 react suggesting success.
Other spawn/review commands in this file only proceed when resolution succeeds. Match that pattern: reply with an unknown-alias error and return early when resolveModelAlias returns undefined.
| const SPAWN_WT_MODEL_RE = new RegExp(`^(?:spawn-wt|/spawn-wt)\\s+(${MODEL_ALIAS_PATTERN}):\\s*(\\S+)\\s+([\\s\\S]+)`, 'i') | ||
| const BARE_ALIAS_RE = new RegExp(`^(${MODEL_ALIAS_PATTERN}):?$`, 'i') | ||
| // "model sonnet" / "model opus" — switch a running session's model mid-conversation | ||
| const MODEL_SWITCH_RE = new RegExp(`^model\\s+(${MODEL_ALIAS_PATTERN})\\s*$`, 'i') |
There was a problem hiding this comment.
Nit (flagged by both reviewers): 'model' is not added to COMMAND_PREFIXES (used by COMMAND_RE at line ~28). Every other named command (usage, kill, restart, etc.) has a prefix entry. Without it, a non-allowlisted sender who types model sonnet won't trigger the warning log — the message falls through silently.
| const modelMatch = msg.content.match(MODEL_SWITCH_RE) | ||
| if (modelMatch) { | ||
| const resolved = resolveModelAlias(modelMatch[1]) ?? modelMatch[1] | ||
| const spawnOpts = { stdio: ['pipe', 'pipe', 'pipe'] as const } |
There was a problem hiding this comment.
Nit: stdio: ['pipe', 'pipe', 'pipe'] creates pipes that are never read. If tmux send-keys writes to stderr on error (e.g., unknown pane), the pipe buffer fills with no reader. Since the output isn't consumed, stdio: ['ignore', 'ignore', 'ignore'] (or 'inherit' to surface errors in daemon logs) is more appropriate.
cf930c9 to
65349e0
Compare
|
Thanks for the thorough review — addressed all four should-fixes and pushed an update:
On the |
…as keyword Adds a thread-scoped keyword (model <alias>), matched alongside listen/pause, that switches the running session model without a respawn. It resolves the alias with the existing resolveModelAlias and sends Claude Code's own /model into the session tmux pane (Escape, the text literally via -l, then Enter) - the same pane-driving approach as the interrupt command - then reacts. Reuses MODEL_ALIASES; no new deps; help text updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
65349e0 to
e4bd0f6
Compare
|
Pushed one more fix (found while running this against current Claude Code): recent CC versions pop a "Switch model?" confirmation ("1. Yes / 2. No") when the switch invalidates the prompt cache. The Fix: after the switch keystrokes, wait a beat (~800ms) and send one more
|
| // "model sonnet" / "model opus" — switch a running session's model mid-conversation. | ||
| // Matches any single token so a typo'd alias gets a clear error instead of silently | ||
| // falling through; the alias is validated (resolveModelAlias) at the call site. | ||
| const MODEL_SWITCH_RE = /^model\s+(\S+)\s*$/i |
There was a problem hiding this comment.
Should-fix (bump, escalated from prior nit — flagged by sp-reviewer): 'model' is still not in COMMAND_PREFIXES. Previous review noted this means the warning log is skipped for non-allowlisted senders. Escalating because model mutates the session runtime (cost, capability, cache invalidation) — unlike the other thread-scoped commands that skip the gate (listen/pause, which are harmless visibility toggles). Any user who can post in the thread can switch the model with no ownership check.
Minimal fix: add 'model' to COMMAND_PREFIXES. Better: also gate on msg.authorId matching the session creator or allowFrom.
…193#114 review) Round-2 review fixes: - Gate `model <alias>` on allowFrom — it mutates session cost/capability, unlike the harmless listen/pause toggles it sits beside. Not a COMMAND_PREFIXES entry (those are global commands; this is thread-scoped). - Replace the blind 800ms sleep before the "Switch model?" confirm with a pane poll (capturePane, up to ~3s) that fires Enter only once the modal is seen — a slow modal no longer swallows a timed Enter. - Confirm is best-effort: a capture error leaves the submitted switch intact; only a seen-but-unconfirmed modal (pane parked) reports failure, so a transient capture hiccup can't falsely report a delivered switch as failed. - Extract capturePane into util.ts (shared with getContextPercent), single-quote-escaping the pane name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| // Claude Code shows "Switch model?" only when the switch invalidates the cache. | ||
| // Poll for it rather than blind-firing a timed Enter (a slow modal would swallow | ||
| // one); best-effort — a capture error leaves the submitted switch intact, only a | ||
| // seen-but-unconfirmed modal (pane parked) downgrades success to failure. |
There was a problem hiding this comment.
Nit (flagged by both reviewers): The poll constants 12 and 250 (3 s ceiling) are magic numbers. A named pair would make the timeout tunable and self-documenting:
const CONFIRM_POLL_MS = 250
const CONFIRM_POLL_MAX = 12 // 3 s ceilingMinor — the inline comment already explains the mechanism, so this is cosmetic.
| const modelMatch = msg.content.match(MODEL_SWITCH_RE) | ||
| if (modelMatch) { | ||
| if (!isAllowed) { | ||
| process.stderr.write(`daemon: model switch from non-allowlisted sender ${senderId} on ${info.tmuxName} ignored\n`) | ||
| return | ||
| } | ||
| const resolved = resolveModelAlias(modelMatch[1]) | ||
| if (!resolved) { | ||
| void reportError(msg.channelId, msg.id, 'model', `unknown model alias "${modelMatch[1]}"`, `Known aliases: ${Object.keys(MODEL_ALIASES).join(', ')}`) | ||
| return | ||
| } | ||
| const spawnOpts = { stdio: ['ignore', 'ignore', 'ignore'] as const } | ||
| let switched = true | ||
| try { | ||
| for (const keys of [['Escape'], ['-l', `/model ${resolved}`], ['Enter']]) { | ||
| if ((await Bun.spawn(['tmux', 'send-keys', '-t', info.tmuxName, ...keys], spawnOpts).exited) !== 0) { | ||
| switched = false | ||
| break | ||
| } | ||
| } | ||
| } catch (err) { | ||
| switched = false | ||
| process.stderr.write(`daemon: model switch failed for ${info.tmuxName}: ${err instanceof Error ? err.message : err}\n`) | ||
| } | ||
| if (switched) { | ||
| // Claude Code shows "Switch model?" only when the switch invalidates the cache. | ||
| // Poll for it rather than blind-firing a timed Enter (a slow modal would swallow | ||
| // one); best-effort — a capture error leaves the submitted switch intact, only a | ||
| // seen-but-unconfirmed modal (pane parked) downgrades success to failure. | ||
| try { | ||
| for (let i = 0; i < 12; i++) { | ||
| await new Promise(r => setTimeout(r, 250)) | ||
| if (/Switch model\?/i.test(capturePane(info.tmuxName))) { | ||
| if ((await Bun.spawn(['tmux', 'send-keys', '-t', info.tmuxName, 'Enter'], spawnOpts).exited) !== 0) switched = false | ||
| break | ||
| } | ||
| } | ||
| } catch (err) { | ||
| process.stderr.write(`daemon: model switch confirm-check failed for ${info.tmuxName}: ${err instanceof Error ? err.message : err}\n`) | ||
| } | ||
| } | ||
| if (switched) { | ||
| process.stderr.write(`daemon: model switch -> ${resolved} for ${info.tmuxName}\n`) | ||
| void gateway.react(msg.channelId, msg.id, '🔁').catch(() => {}) | ||
| } else { | ||
| void reportError(msg.channelId, msg.id, 'model', `couldn't switch model on ${info.tmuxName}`, 'The pane may be gone or stuck on the confirmation.') | ||
| } | ||
| return | ||
| } | ||
|
|
There was a problem hiding this comment.
can you put all of this in helper function? similar to above with like
if (cancelDesignMatch) {
void handleCancelDesignIntercept(msg)
return
}
… poll constants (sf8193#114 review) Author review follow-ups (PR approved): - Move the inline `model <alias>` block out of router.ts into handleModelSwitchIntercept in commands/thread.ts, matching the sibling intercept handlers (fork/resume/respawn) — router call site is now the same `void handle...Intercept(msg, ...); return` shape as its neighbors. - Name the confirm-poll magic numbers: CONFIRM_POLL_MS / CONFIRM_POLL_MAX (~3s ceiling). Behavior-preserving: the helper self-resolves the session via resolveThreadSessionFromMsg (same thread-id resolution as the router's getByThread), and the allowFrom gate still fires before any pane action. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| break | ||
| } | ||
| } | ||
| } catch (err) { |
There was a problem hiding this comment.
Should-fix (flagged by both reviewers): The catch block logs the error but doesn't set switched = false. If the pane dies during polling (capturePane throws), switched stays true and the user gets a false 🔁 success reaction.
} catch (err) {
switched = false // ← add this
process.stderr.write(`daemon: model switch confirm-check failed for ${info.tmuxName}: ${err instanceof Error ? err.message : err}\n`)
}The comment says "a capture error leaves the submitted switch intact" — but a dead pane means the switch didn't land, so the react should not fire.
| export function capturePane(tmuxName: string): string { | ||
| const name = tmuxName.replace(/'/g, "'\\''") | ||
| return execSync(`tmux capture-pane -t '${name}' -p 2>/dev/null`, { stdio: ['pipe', 'pipe', 'pipe'], timeout: 2000 }).toString() | ||
| } |
There was a problem hiding this comment.
Nit (ts-reviewer): capturePane uses execSync with shell string interpolation. The single-quote escaping is adequate, but execFileSync('tmux', ['capture-pane', '-t', tmuxName, '-p'], ...) would avoid the shell entirely and match the tmuxHasSession pattern directly above. The 2>/dev/null redirect is also redundant when stderr is already piped via stdio.
What
A thread-scoped keyword to switch a running session's model mid-conversation, without a respawn:
It sits alongside
listen/pause/!as a thread-scoped session command.How
MODEL_SWITCH_RE(^model\s+(<alias>)$), built from the existingMODEL_ALIAS_PATTERN.resolveModelAlias, then sends Claude Code's own/model <id>into the session's tmux pane —Escape(clear the composer) → the command text literally viasend-keys -l→Enter— the same pane-driving approach the!interrupt already uses. Then it reacts 🔁./commandshelp text updated.Reuses existing machinery (
MODEL_ALIASES, the tmuxsend-keyspattern); no new dependencies.Scope / notes
listen/pause/!(acts on the session mapped to the thread)./modelbehaves in Claude Code).sonnet,opus,haiku,fable, …), consistent withspawn sonnet: ….Test
bun test daemon/__tests__/router-commands.test.ts→ 40/40;bun build daemon.tsclean.🤖 Generated with Claude Code