Skip to content

feat: switch a running session model mid-conversation via a model-alias keyword - #114

Open
Koha101 wants to merge 3 commits into
sf8193:mainfrom
Koha101:feat/model-switch
Open

feat: switch a running session model mid-conversation via a model-alias keyword#114
Koha101 wants to merge 3 commits into
sf8193:mainfrom
Koha101:feat/model-switch

Conversation

@Koha101

@Koha101 Koha101 commented Jul 10, 2026

Copy link
Copy Markdown

What

A thread-scoped keyword to switch a running session's model mid-conversation, without a respawn:

model sonnet
model opus

It sits alongside listen / pause / ! as a thread-scoped session command.

How

  • New MODEL_SWITCH_RE (^model\s+(<alias>)$), built from the existing MODEL_ALIAS_PATTERN.
  • In the thread-command block it resolves the alias with the existing resolveModelAlias, then sends Claude Code's own /model <id> into the session's tmux pane — Escape (clear the composer) → the command text literally via send-keys -lEnter — the same pane-driving approach the ! interrupt already uses. Then it reacts 🔁.
  • /commands help text updated.

Reuses existing machinery (MODEL_ALIASES, the tmux send-keys pattern); no new dependencies.

Scope / notes

  • Thread-scoped, mirroring listen / pause / ! (acts on the session mapped to the thread).
  • Switches the live model; a restart/respawn reverts to the session's spawn model (consistent with how /model behaves in Claude Code).
  • Aliases are the existing set (sonnet, opus, haiku, fable, …), consistent with spawn sonnet: ….

Test

bun test daemon/__tests__/router-commands.test.ts → 40/40; bun build daemon.ts clean.

🤖 Generated with Claude Code

Comment thread daemon/router.ts Outdated
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)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread daemon/router.ts Outdated
} 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(() => {})

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread daemon/router.ts Outdated
// approach as the ! interrupt below.
const modelMatch = msg.content.match(MODEL_SWITCH_RE)
if (modelMatch) {
const resolved = resolveModelAlias(modelMatch[1]) ?? modelMatch[1]

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread daemon/router.ts Outdated
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')

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread daemon/router.ts Outdated
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 }

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Koha101
Koha101 force-pushed the feat/model-switch branch from cf930c9 to 65349e0 Compare July 10, 2026 09:20
@Koha101

Koha101 commented Jul 10, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review — addressed all four should-fixes and pushed an update:

On the COMMAND_PREFIXES nit (#4) — I left it out deliberately: model is a thread-scoped session command like listen / pause / !, and none of those are in COMMAND_PREFIXES either. Per the comment at the top of the file, thread commands are gated on session ownership (not allowFrom), so non-allowlisted senders can't reach them; COMMAND_PREFIXES is the main-channel command surface. Adding model alone would make it the odd one out. Happy to add it if you'd rather have it there for the unauthorized-command log — just say the word.

@Koha101
Koha101 marked this pull request as ready for review July 10, 2026 09:24
…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>
@Koha101
Koha101 force-pushed the feat/model-switch branch from 65349e0 to e4bd0f6 Compare July 10, 2026 10:30
@Koha101

Koha101 commented Jul 10, 2026

Copy link
Copy Markdown
Author

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 Escape → /model <id> → Enter sequence submits the command but leaves the pane parked on that modal, so the session stops responding until someone answers it.

Fix: after the switch keystrokes, wait a beat (~800ms) and send one more Enter to confirm the highlighted default (Yes). If no confirmation appears, the extra Enter lands on an empty composer and is a harmless no-op.

bun build daemon.ts clean; router-commands 40/40.

Comment thread daemon/router.ts
// "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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread daemon/router.ts Outdated
Comment thread daemon/router.ts Outdated
…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>
Comment thread daemon/router.ts Outdated
// 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.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ceiling

Minor — the inline comment already explains the mechanism, so this is cosmetic.

Comment thread daemon/router.ts
Comment on lines +547 to +596
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
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Comment thread daemon/commands/thread.ts
break
}
}
} catch (err) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread daemon/util.ts
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()
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants