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.
Files changed (156) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +3 -1
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +386 -128
  6. package/dist/host-control/main.js +4 -2
  7. package/dist/vault/approvals/kernel-server.js +3 -1
  8. package/dist/vault/broker/server.js +38 -8
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +35 -16
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-release/SKILL.md +78 -0
  14. package/telegram-plugin/auth-snapshot-format.ts +15 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
  17. package/telegram-plugin/dist/server.js +24 -0
  18. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  19. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  20. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  21. package/telegram-plugin/gateway/gateway.ts +1331 -151
  22. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  23. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  24. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  25. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  27. package/telegram-plugin/gateway/model-command.ts +212 -51
  28. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  29. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  30. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  31. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  32. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  33. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  34. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  36. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  37. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  38. package/telegram-plugin/model-label.ts +69 -0
  39. package/telegram-plugin/operator-events.ts +45 -0
  40. package/telegram-plugin/pending-work-progress.ts +42 -7
  41. package/telegram-plugin/permission-diff.ts +128 -0
  42. package/telegram-plugin/quota-bar-format.ts +360 -0
  43. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  44. package/telegram-plugin/registry/subagents.test.ts +90 -0
  45. package/telegram-plugin/session-tail.ts +28 -0
  46. package/telegram-plugin/silent-end.ts +49 -4
  47. package/telegram-plugin/subagent-watcher.ts +249 -46
  48. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  49. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
  51. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  52. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  53. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  54. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
  55. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  56. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  57. package/telegram-plugin/tests/model-command.test.ts +202 -42
  58. package/telegram-plugin/tests/model-label.test.ts +64 -0
  59. package/telegram-plugin/tests/operator-events.test.ts +17 -0
  60. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  61. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  62. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  63. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  64. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  65. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  66. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  67. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  68. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  69. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  70. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  71. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  72. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  73. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  74. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  75. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  76. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  77. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  78. package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
  79. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  80. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  81. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  82. package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
  83. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  84. package/telegram-plugin/tool-activity-summary.ts +22 -2
  85. package/telegram-plugin/typing-wrap.ts +72 -25
  86. package/telegram-plugin/worker-activity-feed.ts +229 -15
  87. package/profiles/default/CLAUDE.md +0 -116
  88. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  89. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  97. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  99. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  156. 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. This matches the
212
- // behaviour of the `/restart` command (same mechanism, same marker logic).
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.scheduleRestart(`user: /model ${model} (sr-to-claude restart)`)
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>` switches the session SILENTLY it does not
271
- // print a confirmation line. So `result.output` on this path is almost
272
- // always just whatever pane scrollback sat below the command echo (the
273
- // agent's previous prose answer). `isTuiChromeLine` strips borders/glyphs
274
- // but NOT ordinary prose, so blindly `preBlock`-ing `result.output` here
275
- // dumped that unrelated scrollback back to the user as a code block
276
- // (screenshot-confirmed on klanker, v0.16.47). Only relay output when it
277
- // actually looks like a model-switch acknowledgement; otherwise suppress
278
- // it and send a clean confirmation.
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} — switched (session).`,
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
- .split('\n')
738
- .map((l) => l.trim())
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
- selectedModel: sessionModelFromConfirmation(confirmation) ?? alias,
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: text-inject `/model sr-<name>` rather than cursor-nav.
763
- // Text-inject is more reliable when the picker has many models; sr-* names
764
- // are safe (no entry in model_group_settings no OAuth forwarding). See I6.
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
- srResult = await deps.inject(deps.getAgentName(), `/model ${srName}`)
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 menuWithBanner(deps, `❌ Switch to **${deps.escapeHtml(srName)}** failed: ${deps.escapeHtml(msg)}`),
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: 'Switch failed',
806
- reply: await menuWithBanner(
872
+ answer: `Switching to ${friendlyName} — restarting (~30s)`,
873
+ reply: await menuWithBannerStatic(
807
874
  deps,
808
- `❌ Switch to **${deps.escapeHtml(srFriendlyLabel(srName))}** failed agent may be mid-turn`,
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: sessionModelFromConfirmation(result.confirmation) ?? target.label,
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*[⏺●•>-]?\s*(?:Set model to|Switched to|Kept model as)\b/i
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" (or "Switched to <name>").
923
- * Returns null when the confirmation doesn't carry a recognizable name (the
924
- * caller falls back to the tapped picker label).
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()