switchroom 0.18.3 → 0.18.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +3 -1
- package/dist/auth-broker/index.js +3 -1
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +386 -128
- package/dist/host-control/main.js +4 -2
- package/dist/vault/approvals/kernel-server.js +3 -1
- package/dist/vault/broker/server.js +38 -8
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +35 -16
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +15 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1331 -151
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/model-command.ts +212 -51
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +45 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/subagent-watcher.ts +249 -46
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +202 -42
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/operator-events.test.ts +17 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/worker-activity-feed.ts +229 -15
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
|
@@ -88,7 +88,8 @@ export function spoolId(msg: InboundMessage): string {
|
|
|
88
88
|
// given turn can only be one or the other.
|
|
89
89
|
if (
|
|
90
90
|
(msg.meta?.source === 'resume_interrupted' ||
|
|
91
|
-
msg.meta?.source === 'resume_watchdog_timeout'
|
|
91
|
+
msg.meta?.source === 'resume_watchdog_timeout' ||
|
|
92
|
+
msg.meta?.source === 'resume_deferred') &&
|
|
92
93
|
typeof msg.meta?.resume_turn_key === 'string' &&
|
|
93
94
|
msg.meta.resume_turn_key.length > 0
|
|
94
95
|
) {
|
|
@@ -104,6 +104,25 @@ describe('handleInjectCommand — guards', () => {
|
|
|
104
104
|
expect(replies[0].text).toContain('Usage')
|
|
105
105
|
expect(replies[0].text).toContain('/cost')
|
|
106
106
|
})
|
|
107
|
+
|
|
108
|
+
it('refuses `/inject /model <name>` and points at the real /model driver', async () => {
|
|
109
|
+
// Bug 7c: /inject /model <arg> bypasses the whole /model driver (no busy
|
|
110
|
+
// gate, no sr-* carrier/base-URL repoint, no session-override recording).
|
|
111
|
+
const inject = vi.fn()
|
|
112
|
+
const { deps, replies } = makeDeps({ getArgs: () => '/model sonnet', inject })
|
|
113
|
+
await handleInjectCommand(fakeCtx(), deps)
|
|
114
|
+
expect(inject).not.toHaveBeenCalled()
|
|
115
|
+
expect(replies).toHaveLength(1)
|
|
116
|
+
expect(replies[0].text).toContain('/model sonnet')
|
|
117
|
+
expect(replies[0].text).toContain('own driver')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('still allows bare `/inject /model` (read-only picker) through', async () => {
|
|
121
|
+
const inject = vi.fn().mockResolvedValue(okResult('/model', 'picker output'))
|
|
122
|
+
const { deps } = makeDeps({ getArgs: () => '/model', inject })
|
|
123
|
+
await handleInjectCommand(fakeCtx(), deps)
|
|
124
|
+
expect(inject).toHaveBeenCalledWith('gymbro', '/model')
|
|
125
|
+
})
|
|
107
126
|
})
|
|
108
127
|
|
|
109
128
|
describe('handleInjectCommand — outcome=ok', () => {
|
|
@@ -146,6 +146,23 @@ export async function handleInjectCommand(ctx: Context, deps: InjectDeps): Promi
|
|
|
146
146
|
return
|
|
147
147
|
}
|
|
148
148
|
const slashCommand = arg.startsWith('/') ? arg : `/${arg}`
|
|
149
|
+
|
|
150
|
+
// `/inject /model <name>` would type `/model <name>` straight into the pane,
|
|
151
|
+
// bypassing the entire /model driver — no busy gate, no sr-* carrier/base-URL
|
|
152
|
+
// repoint, no session-override recording. That path silently fails for sr-*
|
|
153
|
+
// ids and leaves /status stale for Claude ones. Route the operator to the real
|
|
154
|
+
// command. Bare `/inject /model` (no arg) still opens claude's read-only picker.
|
|
155
|
+
const injectTokens = slashCommand.split(/\s+/)
|
|
156
|
+
if (injectTokens[0]?.toLowerCase() === '/model' && injectTokens.length > 1) {
|
|
157
|
+
const modelArg = injectTokens.slice(1).join(' ')
|
|
158
|
+
await deps.reply(
|
|
159
|
+
ctx,
|
|
160
|
+
`\`/model\` has its own driver — use \`/model ${deps.escapeHtml(modelArg)}\` directly (not via \`/inject\`) so the switch is gated, recorded, and \`/status\` stays honest.`,
|
|
161
|
+
{ html: true },
|
|
162
|
+
)
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
149
166
|
const agentName = deps.getAgentName()
|
|
150
167
|
|
|
151
168
|
let result: InjectResult
|
|
@@ -134,6 +134,25 @@ export interface ConfigApprovalResolvedEvent {
|
|
|
134
134
|
denySource?: "operator" | "dispatch_failure";
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Issue #2971 — the wedge-watchdog's permission branch answer to
|
|
139
|
+
* `query_pending_permission`. Read-only reply sourced from the gateway's
|
|
140
|
+
* `pendingPermissions` map: `pending: true` means a live Telegram approval
|
|
141
|
+
* card (and the #2724 TTL reaper) is already racing the TUI prompt for this
|
|
142
|
+
* agent, so the watchdog must NOT Esc — the reaper's channel-delivered deny
|
|
143
|
+
* dismisses the TUI cleanly at TTL. `pending: false` means no card exists
|
|
144
|
+
* (bridge down, a `requiresUserInteraction` tool, or genuinely no live
|
|
145
|
+
* request) — the watchdog's Esc fallback is safe and expected.
|
|
146
|
+
*/
|
|
147
|
+
export interface PendingPermissionStatusEvent {
|
|
148
|
+
type: "pending_permission_status";
|
|
149
|
+
/** Echoes the correlationId from the originating query. */
|
|
150
|
+
correlationId: string;
|
|
151
|
+
pending: boolean;
|
|
152
|
+
/** The live `pendingPermissions` key, when `pending: true`. */
|
|
153
|
+
requestId?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
137
156
|
export type GatewayToClient =
|
|
138
157
|
| InboundMessage
|
|
139
158
|
| PermissionEvent
|
|
@@ -143,7 +162,8 @@ export type GatewayToClient =
|
|
|
143
162
|
| DriveApprovalPostedEvent
|
|
144
163
|
| Ms365ApprovalPostedEvent
|
|
145
164
|
| ConfigApprovalResolvedEvent
|
|
146
|
-
| RolloutStatusPostedEvent
|
|
165
|
+
| RolloutStatusPostedEvent
|
|
166
|
+
| PendingPermissionStatusEvent;
|
|
147
167
|
|
|
148
168
|
// === Bridge (Client) -> Gateway messages ===
|
|
149
169
|
|
|
@@ -555,6 +575,27 @@ export interface RolloutStatusPostedEvent {
|
|
|
555
575
|
reason?: string;
|
|
556
576
|
}
|
|
557
577
|
|
|
578
|
+
/**
|
|
579
|
+
* Issue #2971 — sent by the wedge-watchdog (autoaccept-poll sidecar) over
|
|
580
|
+
* the gateway UDS, BEFORE it would otherwise Esc a shape-persistent
|
|
581
|
+
* per-tool permission prompt. Read-only: asks whether a live Telegram
|
|
582
|
+
* approval card (`pendingPermissions`) already exists for this agent's
|
|
583
|
+
* in-flight permission request, so the watchdog can defer to the card /
|
|
584
|
+
* the #2724 TTL reaper instead of racing it with a keystroke.
|
|
585
|
+
*
|
|
586
|
+
* Trust model: same as quota_wall_detected — the socket is per-agent
|
|
587
|
+
* inside the container; `agentName` is validated server-side (and, in the
|
|
588
|
+
* gateway handler, checked against the gateway's own SWITCHROOM_AGENT_NAME)
|
|
589
|
+
* but this message can never mutate state — it only reads
|
|
590
|
+
* `pendingPermissions.size`/entries.
|
|
591
|
+
*/
|
|
592
|
+
export interface QueryPendingPermissionMessage {
|
|
593
|
+
type: "query_pending_permission";
|
|
594
|
+
agentName: string;
|
|
595
|
+
/** Caller-generated correlation id, echoed in the reply. */
|
|
596
|
+
correlationId: string;
|
|
597
|
+
}
|
|
598
|
+
|
|
558
599
|
export type ClientToGateway =
|
|
559
600
|
| RegisterMessage
|
|
560
601
|
| ToolCallMessage
|
|
@@ -574,4 +615,5 @@ export type ClientToGateway =
|
|
|
574
615
|
| SendOutboundMessage
|
|
575
616
|
| PostSkillProposalMessage
|
|
576
617
|
| RolloutStatusPostMessage
|
|
577
|
-
| RolloutStatusEditMessage
|
|
618
|
+
| RolloutStatusEditMessage
|
|
619
|
+
| QueryPendingPermissionMessage;
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
InjectInboundMessage,
|
|
7
7
|
SendOutboundMessage,
|
|
8
8
|
QuotaWallDetectedMessage,
|
|
9
|
+
QueryPendingPermissionMessage,
|
|
9
10
|
PostSkillProposalMessage,
|
|
10
11
|
OperatorEventForward,
|
|
11
12
|
PermissionRequestForward,
|
|
@@ -67,6 +68,18 @@ export interface IpcServerOptions {
|
|
|
67
68
|
* don't run failover simply ignore it.
|
|
68
69
|
*/
|
|
69
70
|
onQuotaWallDetected?: (client: IpcClient, msg: QuotaWallDetectedMessage) => void;
|
|
71
|
+
/**
|
|
72
|
+
* Issue #2971 — read-only wedge-watchdog query: is there a live pending
|
|
73
|
+
* permission request (Telegram approval card) for this agent right now?
|
|
74
|
+
* The handler MUST reply on the same connection with a
|
|
75
|
+
* `pending_permission_status` event (matching `correlationId`) sourced
|
|
76
|
+
* from the gateway's `pendingPermissions` map. Optional: when no handler
|
|
77
|
+
* is wired (older gateway build, or a test fixture that doesn't need it),
|
|
78
|
+
* the server replies `pending: false` immediately — the watchdog's
|
|
79
|
+
* existing Esc fallback then behaves exactly as it did before this
|
|
80
|
+
* feature (mixed-version safety).
|
|
81
|
+
*/
|
|
82
|
+
onQueryPendingPermission?: (client: IpcClient, msg: QueryPendingPermissionMessage) => void;
|
|
70
83
|
/**
|
|
71
84
|
* #2670 one-tap self-improvement — persist a skill-improvement proposal
|
|
72
85
|
* and post its Approve/Dismiss card. Handler persists the draft bundle to
|
|
@@ -330,6 +343,15 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
|
|
|
330
343
|
&& (typeof m.resetAt !== "number" || !Number.isFinite(m.resetAt as number))) return false;
|
|
331
344
|
return true;
|
|
332
345
|
}
|
|
346
|
+
case "query_pending_permission": {
|
|
347
|
+
// Issue #2971 — read-only wedge-watchdog probe. Wire shape only.
|
|
348
|
+
if (typeof m.agentName !== "string"
|
|
349
|
+
|| !AGENT_NAME_RE.test(m.agentName as string)) return false;
|
|
350
|
+
if (typeof m.correlationId !== "string"
|
|
351
|
+
|| (m.correlationId as string).length === 0
|
|
352
|
+
|| (m.correlationId as string).length > 64) return false;
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
333
355
|
case "request_config_approval": {
|
|
334
356
|
// #1623 — hostd-initiated config-edit approval card. Wire shape
|
|
335
357
|
// only; the handler module validates the diff content.
|
|
@@ -449,6 +471,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
449
471
|
onInjectInbound,
|
|
450
472
|
onSendOutbound,
|
|
451
473
|
onQuotaWallDetected,
|
|
474
|
+
onQueryPendingPermission,
|
|
452
475
|
onPostSkillProposal,
|
|
453
476
|
onRequestDriveApproval,
|
|
454
477
|
onRequestMs365Approval,
|
|
@@ -569,6 +592,23 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
569
592
|
case "quota_wall_detected":
|
|
570
593
|
if (onQuotaWallDetected) onQuotaWallDetected(client, msg as QuotaWallDetectedMessage);
|
|
571
594
|
break;
|
|
595
|
+
case "query_pending_permission":
|
|
596
|
+
if (onQueryPendingPermission) {
|
|
597
|
+
onQueryPendingPermission(client, msg as QueryPendingPermissionMessage);
|
|
598
|
+
} else {
|
|
599
|
+
// No handler wired — fail closed to "not pending" so the caller's
|
|
600
|
+
// Esc fallback fires (byte-identical to pre-#2971 behaviour).
|
|
601
|
+
try {
|
|
602
|
+
client.send({
|
|
603
|
+
type: "pending_permission_status",
|
|
604
|
+
correlationId: (msg as QueryPendingPermissionMessage).correlationId,
|
|
605
|
+
pending: false,
|
|
606
|
+
});
|
|
607
|
+
} catch {
|
|
608
|
+
/* best effort */
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
break;
|
|
572
612
|
case "request_drive_approval":
|
|
573
613
|
if (onRequestDriveApproval) {
|
|
574
614
|
// Handler is async — fire-and-forget here; the handler
|
|
@@ -111,6 +111,15 @@ export function parseModelCommand(text: string): ParsedModelCommand | null {
|
|
|
111
111
|
export interface ModelCommandDeps {
|
|
112
112
|
/** Inject primitive — wired to injectSlashCommand in the gateway. */
|
|
113
113
|
inject: (agent: string, command: string) => Promise<InjectResult>
|
|
114
|
+
/**
|
|
115
|
+
* True while the agent is mid-turn. A typed `/model <name>` switch drives
|
|
116
|
+
* claude's session (either an inject into the input box, or a carrier-backed
|
|
117
|
+
* restart) — doing either mid-turn is unsafe: an inject queues `/model` as
|
|
118
|
+
* user text instead of switching, and a restart would tear down the live
|
|
119
|
+
* turn. So the set path refuses when this is true and asks the operator to
|
|
120
|
+
* retry, exactly like the menu callback path (`ModelMenuDeps.isBusy`).
|
|
121
|
+
*/
|
|
122
|
+
isBusy: () => boolean
|
|
114
123
|
getAgentName: () => string
|
|
115
124
|
/**
|
|
116
125
|
* The agent's configured model from `switchroom agent list` (the
|
|
@@ -153,6 +162,16 @@ export interface ModelCommandDeps {
|
|
|
153
162
|
export interface ModelCommandReply {
|
|
154
163
|
text: string
|
|
155
164
|
html: true
|
|
165
|
+
/**
|
|
166
|
+
* On a POSITIVELY-CONFIRMED typed switch, the model now running this session
|
|
167
|
+
* (parsed from claude's confirmation, falling back to the requested token).
|
|
168
|
+
* The gateway records this as the session-model override so `/status` reflects
|
|
169
|
+
* what's actually running — the SAME code path the menu callback uses via
|
|
170
|
+
* `ModelCallbackOutcome.selectedModel`. Absent on every unverified / non-switch
|
|
171
|
+
* outcome (silent capture, error, busy refusal) so an unconfirmed switch never
|
|
172
|
+
* lies to `/status`.
|
|
173
|
+
*/
|
|
174
|
+
selectedModel?: string
|
|
156
175
|
}
|
|
157
176
|
|
|
158
177
|
const PERSIST_NOTE =
|
|
@@ -205,16 +224,37 @@ export async function handleModelCommand(
|
|
|
205
224
|
// Expand short aliases: `flash` → `sr-gemini-2.5-flash`, `codex` → `sr-codex-5.5`, etc.
|
|
206
225
|
const model = expandSrAlias(parsed.model)
|
|
207
226
|
|
|
227
|
+
// Busy gate: a typed switch either injects into claude's input box or triggers
|
|
228
|
+
// a carrier-backed restart. Both are unsafe mid-turn — an inject queues the
|
|
229
|
+
// `/model` text instead of switching, and a restart kills the live turn. Refuse
|
|
230
|
+
// and ask the operator to retry (parity with the menu callback's isBusy check).
|
|
231
|
+
if (deps.isBusy()) {
|
|
232
|
+
return {
|
|
233
|
+
text: '⏳ The agent is mid-turn — a model switch needs an idle session. Try again in a moment.',
|
|
234
|
+
html: true,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
208
238
|
// sr-* → Claude: an in-place `/model` inject would leave LiteLLM routing
|
|
209
239
|
// active in the live session because the sr-* model context was set by
|
|
210
240
|
// the proxy at session start, not by claude's own REPL. A graceful restart
|
|
211
|
-
// is the only clean path back to the native OAuth route.
|
|
212
|
-
//
|
|
241
|
+
// is the only clean path back to the native OAuth route. Route it through the
|
|
242
|
+
// SAME carrier mechanism as a Claude → sr-* switch (scheduleModelRelaunch)
|
|
243
|
+
// so the requested Claude model is written to `.session-model-override` and
|
|
244
|
+
// survives the restart — otherwise boot launches the configured default and
|
|
245
|
+
// the operator's choice is silently dropped. start.sh's LiteLLM-down guard
|
|
246
|
+
// only special-cases `sr-*` overrides, so a Claude token is never dropped.
|
|
213
247
|
const currentSession = deps.getActiveSessionModel()
|
|
214
248
|
if (currentSession !== null && isSrModel(currentSession) && isClaudeModel(model)) {
|
|
215
249
|
try {
|
|
216
|
-
await deps.
|
|
250
|
+
await deps.scheduleModelRelaunch(model, `user: /model ${model} (sr-to-claude restart)`)
|
|
217
251
|
} catch (err) {
|
|
252
|
+
if (isRestartInFlight(err)) {
|
|
253
|
+
return {
|
|
254
|
+
text: `⏳ A restart is already in flight — your switch to \`${deps.escapeHtml(model)}\` will apply as it completes (~15s).`,
|
|
255
|
+
html: true,
|
|
256
|
+
}
|
|
257
|
+
}
|
|
218
258
|
const msg = err instanceof Error ? err.message : String(err)
|
|
219
259
|
return {
|
|
220
260
|
text: `❌ Could not schedule restart: ${deps.escapeHtml(msg)}`,
|
|
@@ -239,6 +279,12 @@ export async function handleModelCommand(
|
|
|
239
279
|
try {
|
|
240
280
|
await deps.scheduleModelRelaunch(model, `user: /model ${model} (session-only relaunch)`)
|
|
241
281
|
} catch (err) {
|
|
282
|
+
if (isRestartInFlight(err)) {
|
|
283
|
+
return {
|
|
284
|
+
text: `⏳ A restart is already in flight — your switch to \`${deps.escapeHtml(model)}\` will apply as it completes (~15s).`,
|
|
285
|
+
html: true,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
242
288
|
const msg = err instanceof Error ? err.message : String(err)
|
|
243
289
|
return {
|
|
244
290
|
text: `❌ Could not schedule model switch: ${deps.escapeHtml(msg)}`,
|
|
@@ -267,17 +313,33 @@ export async function handleModelCommand(
|
|
|
267
313
|
}
|
|
268
314
|
|
|
269
315
|
if (result.outcome === 'ok') {
|
|
270
|
-
// claude's `/model <name>`
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
316
|
+
// claude's `/model <name>` either prints a "Set model to X" acknowledgement
|
|
317
|
+
// or switches SILENTLY (no line). `result.output` on the silent path is just
|
|
318
|
+
// whatever pane scrollback sat below the command echo (the agent's previous
|
|
319
|
+
// prose answer) — NOT a confirmation. We MUST NOT claim success on that
|
|
320
|
+
// scrollback, and we must never dump it back as a code block (screenshot-
|
|
321
|
+
// confirmed leak on klanker, v0.16.47).
|
|
322
|
+
//
|
|
323
|
+
// Honest reporting: (1) if claude printed an error ("Model not found" /
|
|
324
|
+
// "Invalid model"), the switch FAILED — say so. (2) if an anchored
|
|
325
|
+
// confirmation line is present, the switch is verified — relay it and record
|
|
326
|
+
// the live model for /status. (3) otherwise we cannot positively verify the
|
|
327
|
+
// switch (silent success or nothing) — say "sent, but couldn't confirm" and
|
|
328
|
+
// record NOTHING, so /status is never lied to.
|
|
329
|
+
const errLine = modelSwitchErrorLine(result.output)
|
|
330
|
+
if (errLine) {
|
|
331
|
+
return {
|
|
332
|
+
text: [
|
|
333
|
+
`❌ ${verbHtml} — the switch did not take:`,
|
|
334
|
+
deps.preBlock(errLine),
|
|
335
|
+
'Check \`/model\` for valid model names.',
|
|
336
|
+
].join('\n'),
|
|
337
|
+
html: true,
|
|
338
|
+
}
|
|
339
|
+
}
|
|
279
340
|
const confirmation = modelSwitchConfirmationLine(result.output)
|
|
280
341
|
if (confirmation) {
|
|
342
|
+
const confirmed = sessionModelFromConfirmation(confirmation) ?? model
|
|
281
343
|
return {
|
|
282
344
|
text: [
|
|
283
345
|
`${verbHtml}`,
|
|
@@ -286,11 +348,14 @@ export async function handleModelCommand(
|
|
|
286
348
|
PERSIST_NOTE,
|
|
287
349
|
].join('\n'),
|
|
288
350
|
html: true,
|
|
351
|
+
// Only record when the confirmation carries a real switch (Set/Switched);
|
|
352
|
+
// a "Kept model as" line means no change — don't overwrite the override.
|
|
353
|
+
...(isKeptModelConfirmation(confirmation) ? {} : { selectedModel: confirmed }),
|
|
289
354
|
}
|
|
290
355
|
}
|
|
291
356
|
return {
|
|
292
357
|
text: [
|
|
293
|
-
`${verbHtml} —
|
|
358
|
+
`${verbHtml} — sent, but couldn't confirm the switch — check \`/status\`.`,
|
|
294
359
|
PERSIST_NOTE,
|
|
295
360
|
].join('\n'),
|
|
296
361
|
html: true,
|
|
@@ -676,6 +741,16 @@ export interface ModelCallbackOutcome {
|
|
|
676
741
|
* Absent on every non-switch outcome.
|
|
677
742
|
*/
|
|
678
743
|
selectedModel?: string
|
|
744
|
+
/**
|
|
745
|
+
* The canonical `claude --model` token (alias or full `claude-*` id) for a
|
|
746
|
+
* Claude selection, when derivable — distinct from `selectedModel` (a display
|
|
747
|
+
* name for /status). The gateway writes this to the `.session-model-override`
|
|
748
|
+
* carrier on an sr-* → Claude transition so the requested Claude model survives
|
|
749
|
+
* the restart (otherwise boot launches the configured default). Absent when the
|
|
750
|
+
* target has no derivable token (e.g. the "Default" row → boot the configured
|
|
751
|
+
* default).
|
|
752
|
+
*/
|
|
753
|
+
selectedModelToken?: string
|
|
679
754
|
/** Short toast for answerCallbackQuery. */
|
|
680
755
|
answer: string
|
|
681
756
|
/** Replacement dashboard (message edit). */
|
|
@@ -732,15 +807,19 @@ export async function handleModelMenuCallback(
|
|
|
732
807
|
}
|
|
733
808
|
}
|
|
734
809
|
if (aliasResult.outcome === 'ok') {
|
|
810
|
+
// Anchored confirmation only — a loose /set model|switched/ match false-
|
|
811
|
+
// positives on ordinary scrollback prose ("I switched the deploy…").
|
|
735
812
|
const confirmation =
|
|
736
|
-
aliasResult.output
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
.find((l) => /set model|switched/i.test(l)) ?? `Switched to ${alias} (session)`
|
|
813
|
+
modelSwitchConfirmationLine(aliasResult.output) ?? `Switched to ${alias} (session)`
|
|
814
|
+
// "Kept model as X" means no change — don't overwrite the override.
|
|
815
|
+
const kept = isKeptModelConfirmation(confirmation)
|
|
740
816
|
return {
|
|
741
817
|
answer: confirmation,
|
|
742
818
|
reply: await menuWithBannerStatic(deps, `✅ ${deps.escapeHtml(confirmation)}`),
|
|
743
|
-
|
|
819
|
+
...(kept ? {} : {
|
|
820
|
+
selectedModel: sessionModelFromConfirmation(confirmation) ?? alias,
|
|
821
|
+
selectedModelToken: alias,
|
|
822
|
+
}),
|
|
744
823
|
}
|
|
745
824
|
}
|
|
746
825
|
return {
|
|
@@ -759,11 +838,17 @@ export async function handleModelMenuCallback(
|
|
|
759
838
|
return { answer: 'Tap a model in this section to switch', reply: { text: '', html: true }, toastOnly: true }
|
|
760
839
|
}
|
|
761
840
|
|
|
762
|
-
// sr-* model tap
|
|
763
|
-
//
|
|
764
|
-
//
|
|
841
|
+
// sr-* model tap. In the live gateway this branch is DEAD — the gateway
|
|
842
|
+
// intercepts `mdl:sr:` at its callback dispatcher (before calling this
|
|
843
|
+
// function) and routes it straight to scheduleModelRelaunch (carrier + restart).
|
|
844
|
+
// The old body here text-injected `/model sr-<name>`, which is doubly broken if
|
|
845
|
+
// ever reached: claude's picker rejects unknown sr-* ids AND the ANTHROPIC_BASE_URL
|
|
846
|
+
// is never repointed at the LiteLLM router, so the request 4xxs against Anthropic.
|
|
847
|
+
// Delegate to the SAME carrier mechanism so a direct caller (tests, a future
|
|
848
|
+
// refactor that drops the gateway intercept) still does the safe thing.
|
|
765
849
|
if (data.startsWith(MODEL_CALLBACK_SR)) {
|
|
766
850
|
const srName = data.slice(MODEL_CALLBACK_SR.length)
|
|
851
|
+
const friendlyName = srFriendlyLabel(srName)
|
|
767
852
|
if (!isValidModelArg(srName)) {
|
|
768
853
|
return { answer: 'Invalid model name', reply: await buildModelMenu(deps) }
|
|
769
854
|
}
|
|
@@ -774,39 +859,22 @@ export async function handleModelMenuCallback(
|
|
|
774
859
|
toastOnly: true,
|
|
775
860
|
}
|
|
776
861
|
}
|
|
777
|
-
let srResult: InjectResult
|
|
778
862
|
try {
|
|
779
|
-
|
|
863
|
+
await deps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`)
|
|
780
864
|
} catch (err) {
|
|
781
865
|
const msg = err instanceof Error ? err.message : String(err)
|
|
782
866
|
return {
|
|
783
867
|
answer: 'Switch failed',
|
|
784
|
-
reply: await
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
if (srResult.outcome === 'ok') {
|
|
788
|
-
const friendlyName = srFriendlyLabel(srName)
|
|
789
|
-
const confirmation =
|
|
790
|
-
srResult.output
|
|
791
|
-
.split('\n')
|
|
792
|
-
.map((l) => l.trim())
|
|
793
|
-
.find((l) => /set model|switched/i.test(l)) ?? `Switched to ${friendlyName} (session)`
|
|
794
|
-
return {
|
|
795
|
-
answer: confirmation,
|
|
796
|
-
// Use the static (no-discover) path — after a text-inject the picker
|
|
797
|
-
// is in flux and discover() reliably fails, producing a spurious
|
|
798
|
-
// "(picker unavailable)" line that reads as an error when the switch
|
|
799
|
-
// actually succeeded.
|
|
800
|
-
reply: await menuWithBannerStatic(deps, `✅ ${deps.escapeHtml(confirmation)}`),
|
|
801
|
-
selectedModel: srName,
|
|
868
|
+
reply: await menuWithBannerStatic(deps, `❌ Switch to **${deps.escapeHtml(friendlyName)}** failed: ${deps.escapeHtml(msg)}`),
|
|
802
869
|
}
|
|
803
870
|
}
|
|
804
871
|
return {
|
|
805
|
-
answer:
|
|
806
|
-
reply: await
|
|
872
|
+
answer: `Switching to ${friendlyName} — restarting (~30s)`,
|
|
873
|
+
reply: await menuWithBannerStatic(
|
|
807
874
|
deps,
|
|
808
|
-
|
|
875
|
+
`🔄 Switching session to **${deps.escapeHtml(friendlyName)}** — restarting (~30s). _Session-only; reverts to the configured default on the next restart._`,
|
|
809
876
|
),
|
|
877
|
+
selectedModel: srName,
|
|
810
878
|
}
|
|
811
879
|
}
|
|
812
880
|
|
|
@@ -866,10 +934,26 @@ export async function handleModelMenuCallback(
|
|
|
866
934
|
}
|
|
867
935
|
}
|
|
868
936
|
|
|
937
|
+
// "Kept model as X" means the tapped model was ALREADY the session model —
|
|
938
|
+
// nothing changed. Do NOT overwrite the override (and never store the display
|
|
939
|
+
// label). Tapping the "Default (recommended)" row on the already-default model
|
|
940
|
+
// previously stored "Default (recommended)" verbatim into /status.
|
|
941
|
+
if (isKeptModelConfirmation(result.confirmation)) {
|
|
942
|
+
return {
|
|
943
|
+
answer: deps.escapeHtml(result.confirmation),
|
|
944
|
+
reply: await menuWithBanner(deps, `✅ ${deps.escapeHtml(result.confirmation)}`),
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
// Normalize what we store: prefer the model name claude confirmed, else a
|
|
948
|
+
// canonical token derived from the row label — never a pure display label like
|
|
949
|
+
// "Default (recommended)". If neither resolves, record nothing rather than lie.
|
|
950
|
+
const token = canonicalClaudeToken(target.label)
|
|
951
|
+
const selectedModel = sessionModelFromConfirmation(result.confirmation) ?? token ?? undefined
|
|
869
952
|
return {
|
|
870
953
|
answer: deps.escapeHtml(result.confirmation),
|
|
871
954
|
reply: await menuWithBanner(deps, `✅ ${deps.escapeHtml(result.confirmation)}`),
|
|
872
|
-
selectedModel:
|
|
955
|
+
...(selectedModel ? { selectedModel } : {}),
|
|
956
|
+
...(token ? { selectedModelToken: token } : {}),
|
|
873
957
|
}
|
|
874
958
|
}
|
|
875
959
|
|
|
@@ -904,27 +988,104 @@ export function modelSwitchConfirmationLine(output: string): string | null {
|
|
|
904
988
|
return line && line.length > 0 ? line : null
|
|
905
989
|
}
|
|
906
990
|
|
|
991
|
+
/**
|
|
992
|
+
* claude's failure output for a bad `/model <name>` — the CLI rejects an
|
|
993
|
+
* unknown id with "Model not found" / "Invalid model" / "Unknown model".
|
|
994
|
+
* Detecting it lets the typed set path report an HONEST failure instead of
|
|
995
|
+
* falsely claiming "switched (session)". Anchored to LINE START (behind the
|
|
996
|
+
* same optional status glyph + optional "Error:" prefix as
|
|
997
|
+
* MODEL_SWITCH_CONFIRMATION_PREFIX) so ordinary scrollback prose that merely
|
|
998
|
+
* CONTAINS the phrase mid-sentence (e.g. "deploy failed: model not found in
|
|
999
|
+
* registry") can never false-positive a successful switch into a reported
|
|
1000
|
+
* failure — the false-FAILURE variant of the scrollback-leak class.
|
|
1001
|
+
*
|
|
1002
|
+
* Empirically verified against claude v2.1.205 (disposable TUI probe,
|
|
1003
|
+
* 2026-07-10): `/model claude-bogus-99` prints
|
|
1004
|
+
* `⎿ Model 'claude-bogus-99' not found` — glyph prefix `⎿`, quoted model
|
|
1005
|
+
* name between "Model" and "not found". Both shapes are covered.
|
|
1006
|
+
*/
|
|
1007
|
+
const MODEL_SWITCH_ERROR_RE =
|
|
1008
|
+
/^\s*[⏺●•>⎿-]?\s*(?:Error:\s*)?(?:Model(?:\s+'[^']+')?\s+not found|Invalid model|Unknown model|No such model)\b/i
|
|
1009
|
+
|
|
1010
|
+
/** The single capture line that reads as a claude model-switch error, or null. */
|
|
1011
|
+
export function modelSwitchErrorLine(output: string): string | null {
|
|
1012
|
+
const line = output
|
|
1013
|
+
.split('\n')
|
|
1014
|
+
.map((l) => l.trim())
|
|
1015
|
+
.find((l) => MODEL_SWITCH_ERROR_RE.test(l))
|
|
1016
|
+
return line && line.length > 0 ? line : null
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* True when a confirmation line is claude's "Kept model as X" — i.e. the model
|
|
1021
|
+
* was ALREADY the session model and nothing changed. The caller must NOT record
|
|
1022
|
+
* this as a session-override (there is nothing to override), and must not store
|
|
1023
|
+
* a display label in its place. See the menu-select bug where tapping the
|
|
1024
|
+
* "Default (recommended)" row on the already-default model stored the display
|
|
1025
|
+
* label verbatim into /status.
|
|
1026
|
+
*/
|
|
1027
|
+
export function isKeptModelConfirmation(confirmation: string): boolean {
|
|
1028
|
+
return /^\s*[⏺●•>⎿-]?\s*Kept model as\b/i.test(confirmation.trim())
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Normalize a picker ROW LABEL to a canonical `claude --model` token suitable
|
|
1033
|
+
* for the `.session-model-override` carrier (aliases and full `claude-*` ids —
|
|
1034
|
+
* NOT display strings like "Default (recommended)" or "Opus 4.8", which the CLI
|
|
1035
|
+
* flag rejects). Returns null when the label is a pure display label with no
|
|
1036
|
+
* derivable token: for the "Default" row that correctly means "boot the
|
|
1037
|
+
* configured default" (write no carrier).
|
|
1038
|
+
*/
|
|
1039
|
+
export function canonicalClaudeToken(label: string): string | null {
|
|
1040
|
+
const l = label.trim()
|
|
1041
|
+
if (l.toLowerCase().startsWith('claude-')) return l
|
|
1042
|
+
const first = l.toLowerCase().split(/\s+/)[0]
|
|
1043
|
+
if (first === 'default') return null
|
|
1044
|
+
if ((MODEL_ALIASES as readonly string[]).includes(first)) return first
|
|
1045
|
+
return null
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* True when an error thrown by scheduleRestart / scheduleModelRelaunch signals
|
|
1050
|
+
* "a restart is already in flight" (the gateway's 15s debounce) rather than a
|
|
1051
|
+
* genuine dispatch failure. Carried as a `code` property so model-command.ts
|
|
1052
|
+
* need not import the gateway's error type.
|
|
1053
|
+
*/
|
|
1054
|
+
export function isRestartInFlight(err: unknown): boolean {
|
|
1055
|
+
return !!err && typeof err === 'object' && (err as { code?: unknown }).code === 'restart_in_flight'
|
|
1056
|
+
}
|
|
1057
|
+
|
|
907
1058
|
/**
|
|
908
1059
|
* claude's real model-switch confirmation always begins the line (optionally
|
|
909
|
-
* behind a status glyph like `⏺` + whitespace) with one of these exact
|
|
1060
|
+
* behind a status glyph like `⏺` or `⎿` + whitespace) with one of these exact
|
|
910
1061
|
* phrasings. Anchoring to the line start keeps ordinary scrollback prose that
|
|
911
1062
|
* merely *contains* words like "switched" or "set model" (e.g. "I switched the
|
|
912
1063
|
* deploy to blue-green") from false-positiving as a confirmation worth
|
|
913
1064
|
* relaying. Shared by `modelSwitchConfirmationLine` (does this line qualify?)
|
|
914
1065
|
* and `sessionModelFromConfirmation` (pull the name out).
|
|
1066
|
+
*
|
|
1067
|
+
* Empirically verified against claude v2.1.205 (disposable TUI probe,
|
|
1068
|
+
* 2026-07-10): the arg form `/model opus` is NOT silent — it prints
|
|
1069
|
+
* `⎿ Set model to Opus 4.8 and saved as your default for new sessions`.
|
|
1070
|
+
* The `⎿` glyph survives the inject capture (isTuiChromeLine doesn't strip
|
|
1071
|
+
* it), so it must be in the glyph class or every typed switch would fall
|
|
1072
|
+
* through to the "couldn't confirm" branch and never record the override.
|
|
915
1073
|
*/
|
|
916
1074
|
const MODEL_SWITCH_CONFIRMATION_PREFIX =
|
|
917
|
-
/^\s*[
|
|
1075
|
+
/^\s*[⏺●•>⎿-]?\s*(?:Set model to|Switched to|Kept model as)\b/i
|
|
918
1076
|
|
|
919
1077
|
/**
|
|
920
1078
|
* Pull the model NAME out of claude's session-switch confirmation so it can
|
|
921
1079
|
* be shown in `/status` as the live session model. claude phrases it as
|
|
922
|
-
* "Set model to <name> for this session only"
|
|
923
|
-
*
|
|
924
|
-
*
|
|
1080
|
+
* "Set model to <name> for this session only" / "Switched to <name>" /
|
|
1081
|
+
* (v2.1.205 arg form) "Set model to <name> and saved as your default for new
|
|
1082
|
+
* sessions" — the "and saved" tail must terminate the name capture or the
|
|
1083
|
+
* whole sentence would be stored as the model. Returns null when the
|
|
1084
|
+
* confirmation doesn't carry a recognizable name (the caller falls back to
|
|
1085
|
+
* the tapped picker label).
|
|
925
1086
|
*/
|
|
926
1087
|
export function sessionModelFromConfirmation(confirmation: string): string | null {
|
|
927
|
-
const m = /(?:Set model to|Switched to)\s+(.+?)(?:\s+for (?:this|the) session|\s*\(|\s*$)/i.exec(
|
|
1088
|
+
const m = /(?:Set model to|Switched to)\s+(.+?)(?:\s+for (?:this|the) session|\s+and saved\b|\s*\(|\s*$)/i.exec(
|
|
928
1089
|
confirmation.trim(),
|
|
929
1090
|
)
|
|
930
1091
|
const name = m?.[1]?.trim()
|