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
@@ -41,16 +41,19 @@ describe('handleMentalModelProposeCallback — authorization gate', () => {
41
41
  })
42
42
 
43
43
  describe('handleMentalModelProposeCallback — TTL enforced at tap time', () => {
44
- it('checks staged_at against MENTAL_MODEL_PROPOSE_TTL_MS at tap and edits the card away', () => {
44
+ it('checks staged_at against MENTAL_MODEL_PROPOSE_TTL_MS at tap and routes through the shared expiry path', () => {
45
45
  const block = proposeCallbackBlock()
46
46
  expect(block).toMatch(/Date\.now\(\) - pending\.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS/)
47
- // The expired branch deletes the pending entry and clears the keyboard so a
48
- // stale card can't be tapped into an approval.
47
+ // The expired branch routes through expireMentalModelProposeCard, which
48
+ // deletes the pending entry, clears the durable store, edits the card away
49
+ // (keyboard stripped), AND wakes the parked agent with a timeout synthetic —
50
+ // so a stale card can't be tapped into an approval and the agent isn't left
51
+ // parked. (The helper's delete/clear/wake behavior is pinned in
52
+ // pending-card-durability-wiring.test.ts.)
49
53
  const ttlBranch =
50
54
  block.split('Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS')[1]?.split('Single-shot')[0] ?? ''
51
- expect(ttlBranch).toMatch(/pendingMentalModelProposes\.delete\(stageId\)/)
55
+ expect(ttlBranch).toMatch(/expireMentalModelProposeCard\(stageId, pending, Date\.now\(\)\)/)
52
56
  expect(ttlBranch).toMatch(/expired/)
53
- expect(ttlBranch).toMatch(/inline_keyboard: \[\]/)
54
57
  })
55
58
  })
56
59
 
@@ -50,6 +50,7 @@ function makeDeps(overrides: Partial<ModelCommandDeps> = {}) {
50
50
  s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"),
51
51
  preBlock: (s) => `<pre>${s}</pre>`,
52
52
  getActiveSessionModel: () => null,
53
+ isBusy: () => false,
53
54
  scheduleRestart: async (reason) => { restartCalls.push(reason); },
54
55
  scheduleModelRelaunch: async (model, reason) => { relaunchCalls.push({ model, reason }); },
55
56
  ...overrides,
@@ -202,6 +203,9 @@ describe("handleModelCommand — set", () => {
202
203
  expect(reply.text).toContain("<pre>⏺ Set model to sonnet</pre>");
203
204
  expect(reply.text).toContain("Session-only");
204
205
  expect(reply.html).toBe(true);
206
+ // A verified confirmation records the live model so /status stays honest
207
+ // (bug 1: the typed path never recorded the switch before).
208
+ expect(reply.selectedModel).toBe("sonnet");
205
209
  });
206
210
 
207
211
  it("SILENT switch: suppresses raw pane scrollback instead of dumping it as a code block", async () => {
@@ -219,10 +223,13 @@ describe("handleModelCommand — set", () => {
219
223
  expect(reply.text).not.toContain("summary you asked for");
220
224
  expect(reply.text).not.toContain("rollback plan");
221
225
  expect(reply.text).not.toContain("<pre>");
222
- // A clean, session-scoped confirmation is sent instead.
226
+ // No confirmation line was captured, so the switch is UNVERIFIED — report it
227
+ // honestly (never a false "switched") and record NO session override.
223
228
  expect(reply.text).toContain("/model fable");
224
- expect(reply.text).toContain("switched (session)");
225
- expect(reply.text).toContain("Session-only");
229
+ expect(reply.text).toContain("couldn't confirm the switch");
230
+ expect(reply.text).toContain("/status");
231
+ expect(reply.text).not.toContain("switched (session)");
232
+ expect(reply.selectedModel).toBeUndefined();
226
233
  expect(reply.html).toBe(true);
227
234
  });
228
235
 
@@ -248,12 +255,15 @@ describe("handleModelCommand — set", () => {
248
255
  const { deps } = makeDeps({ inject: async () => okResult(prose) });
249
256
  const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
250
257
  // The anchored regex rejects all three lines, so nothing leaks and there is
251
- // no <pre> block. A clean session confirmation is sent instead.
258
+ // no <pre> block. With no confirmation captured, the switch is UNVERIFIED
259
+ // report it honestly, never a false "switched".
252
260
  expect(reply.text).not.toContain("<pre>");
253
261
  expect(reply.text).not.toContain("switched the deploy");
254
262
  expect(reply.text).not.toContain("set model behaviour");
255
263
  expect(reply.text).not.toContain("kept model changes");
256
- expect(reply.text).toContain("switched (session)");
264
+ expect(reply.text).toContain("couldn't confirm the switch");
265
+ expect(reply.text).not.toContain("switched (session)");
266
+ expect(reply.selectedModel).toBeUndefined();
257
267
  expect(reply.html).toBe(true);
258
268
  });
259
269
 
@@ -327,49 +337,55 @@ describe("isSrModel / isClaudeModel helpers", () => {
327
337
  });
328
338
 
329
339
  describe("handleModelCommand — sr-* → Claude graceful restart", () => {
330
- it("schedules restart instead of injecting when session is on sr-* and target is Claude alias", async () => {
331
- const { deps, calls, restartCalls } = makeDeps({
340
+ it("carries the requested Claude model across the restart (relaunch carrier), never injects", async () => {
341
+ const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
332
342
  getActiveSessionModel: () => "sr-gemini-2.5-pro",
333
343
  });
334
344
  const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
335
345
  // Must NOT inject
336
346
  expect(calls).toHaveLength(0);
337
- // Must schedule a restart
338
- expect(restartCalls).toHaveLength(1);
339
- expect(restartCalls[0]).toContain("opus");
340
- expect(restartCalls[0]).toContain("sr-to-claude");
347
+ // sr→Claude now rides the SAME carrier mechanism as Claude→sr so the
348
+ // requested Claude model survives the restart (bug 2). The bespoke
349
+ // scheduleRestart-without-carrier path is gone.
350
+ expect(restartCalls).toHaveLength(0);
351
+ expect(relaunchCalls).toHaveLength(1);
352
+ expect(relaunchCalls[0].model).toBe("opus");
353
+ expect(relaunchCalls[0].reason).toContain("sr-to-claude");
341
354
  // Reply mentions the sr-* model and ~30s
342
355
  expect(reply.text).toContain("sr-gemini-2.5-pro");
343
356
  expect(reply.text).toContain("30s");
344
357
  expect(reply.html).toBe(true);
345
358
  });
346
359
 
347
- it("schedules restart when session is on sr-* and target is a full claude-* id", async () => {
348
- const { deps, calls, restartCalls } = makeDeps({
360
+ it("carries a full claude-* id across the restart when session is on sr-*", async () => {
361
+ const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
349
362
  getActiveSessionModel: () => "sr-deepseek-r1",
350
363
  });
351
364
  const reply = await handleModelCommand({ kind: "set", model: "claude-opus-4-8" }, deps);
352
365
  expect(calls).toHaveLength(0);
353
- expect(restartCalls).toHaveLength(1);
366
+ expect(restartCalls).toHaveLength(0);
367
+ expect(relaunchCalls).toHaveLength(1);
368
+ expect(relaunchCalls[0].model).toBe("claude-opus-4-8");
354
369
  expect(reply.text).toContain("sr-deepseek-r1");
355
370
  expect(reply.text).toContain("30s");
356
371
  });
357
372
 
358
373
  it("does NOT restart when switching between Claude models (no sr-* session)", async () => {
359
- const { deps, calls, restartCalls } = makeDeps({
374
+ const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
360
375
  getActiveSessionModel: () => "Opus 4.8",
361
376
  });
362
377
  const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
363
- // Normal inject path: still injects, no restart
378
+ // Normal inject path: still injects, no restart, no relaunch
364
379
  expect(calls).toHaveLength(1);
365
380
  expect(restartCalls).toHaveLength(0);
381
+ expect(relaunchCalls).toHaveLength(0);
366
382
  expect(reply.text).toContain("Set model to sonnet");
367
383
  });
368
384
 
369
- it("surfaces scheduleRestart failures without propagating the error", async () => {
385
+ it("surfaces relaunch dispatch failures without propagating the error", async () => {
370
386
  const { deps, calls } = makeDeps({
371
387
  getActiveSessionModel: () => "sr-deepseek-r1",
372
- scheduleRestart: async () => { throw new Error("hostd unreachable"); },
388
+ scheduleModelRelaunch: async () => { throw new Error("hostd unreachable"); },
373
389
  });
374
390
  const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
375
391
  expect(calls).toHaveLength(0);
@@ -377,13 +393,109 @@ describe("handleModelCommand — sr-* → Claude graceful restart", () => {
377
393
  expect(reply.text).toContain("hostd unreachable");
378
394
  });
379
395
 
396
+ it("reports 'restart already in flight' honestly on a debounced dispatch (no silent no-op)", async () => {
397
+ const { deps } = makeDeps({
398
+ getActiveSessionModel: () => "sr-deepseek-r1",
399
+ scheduleModelRelaunch: async () => {
400
+ const e = new Error("a restart is already in flight — try again in ~15s");
401
+ (e as { code?: string }).code = "restart_in_flight";
402
+ throw e;
403
+ },
404
+ });
405
+ const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
406
+ expect(reply.text).toContain("restart is already in flight");
407
+ expect(reply.text).toContain("15s");
408
+ // Never a false success.
409
+ expect(reply.text).not.toContain("Set model to");
410
+ });
411
+
380
412
  it("null session model (no prior override) still uses normal inject path for Claude target", async () => {
381
- const { deps, calls, restartCalls } = makeDeps({
413
+ const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
382
414
  getActiveSessionModel: () => null,
383
415
  });
384
416
  await handleModelCommand({ kind: "set", model: "opus" }, deps);
385
417
  expect(calls).toHaveLength(1);
386
418
  expect(restartCalls).toHaveLength(0);
419
+ expect(relaunchCalls).toHaveLength(0);
420
+ });
421
+ });
422
+
423
+ describe("handleModelCommand — busy gate + honest unverified reporting", () => {
424
+ it("refuses a typed switch while the agent is mid-turn (no inject, no relaunch)", async () => {
425
+ const { deps, calls, relaunchCalls } = makeDeps({ isBusy: () => true });
426
+ const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
427
+ expect(calls).toHaveLength(0);
428
+ expect(relaunchCalls).toHaveLength(0);
429
+ expect(reply.text).toContain("mid-turn");
430
+ expect(reply.selectedModel).toBeUndefined();
431
+ });
432
+
433
+ it("refuses an sr-* switch too while mid-turn", async () => {
434
+ const { deps, relaunchCalls } = makeDeps({ isBusy: () => true });
435
+ const reply = await handleModelCommand({ kind: "set", model: "sr-glm-5" }, deps);
436
+ expect(relaunchCalls).toHaveLength(0);
437
+ expect(reply.text).toContain("mid-turn");
438
+ });
439
+
440
+ it("reports a claude error output as an HONEST failure, not a switch", async () => {
441
+ const { deps } = makeDeps({
442
+ inject: async () => okResult("Error: Model not found"),
443
+ });
444
+ const reply = await handleModelCommand({ kind: "set", model: "claude-bogus" }, deps);
445
+ expect(reply.text).toContain("did not take");
446
+ expect(reply.text).toContain("Model not found");
447
+ expect(reply.text).not.toContain("Session-only");
448
+ expect(reply.selectedModel).toBeUndefined();
449
+ });
450
+
451
+ it("detects claude v2.1.205's real error shape: ⎿ Model 'name' not found (TUI-probe verified)", async () => {
452
+ // Captured verbatim from a disposable claude v2.1.205 TUI, 2026-07-10.
453
+ const { deps } = makeDeps({
454
+ inject: async () => okResult("⎿ Model 'claude-bogus-99' not found"),
455
+ });
456
+ const reply = await handleModelCommand({ kind: "set", model: "claude-bogus-99" }, deps);
457
+ expect(reply.text).toContain("did not take");
458
+ expect(reply.text).toContain("not found");
459
+ expect(reply.selectedModel).toBeUndefined();
460
+ });
461
+
462
+ it("scrollback prose CONTAINING 'model not found' mid-sentence is NOT a failure (anchored error scan)", async () => {
463
+ // The error regex is line-start anchored, like the confirmation prefix —
464
+ // prose that merely mentions the phrase must not flip a successful switch
465
+ // into a reported failure (the false-FAILURE variant of the scrollback-leak
466
+ // class this PR eliminates).
467
+ const { deps } = makeDeps({
468
+ inject: async () => okResult("deploy failed: model not found in registry"),
469
+ });
470
+ const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
471
+ expect(reply.text).not.toContain("did not take");
472
+ expect(reply.text).not.toContain("model not found");
473
+ // No confirmation either → honest unverified message, nothing recorded.
474
+ expect(reply.text).toContain("couldn't confirm the switch");
475
+ expect(reply.selectedModel).toBeUndefined();
476
+ });
477
+
478
+ it("recognises claude v2.1.205's real arg-form confirmation (⎿ glyph + 'and saved as your default')", async () => {
479
+ // Captured verbatim from a disposable claude v2.1.205 TUI, 2026-07-10:
480
+ // the arg form is NOT silent — it prints this line, and the ⎿ glyph
481
+ // survives the inject capture. The name extraction must stop at "and
482
+ // saved", not swallow the whole sentence.
483
+ const { deps } = makeDeps({
484
+ inject: async () =>
485
+ okResult("⎿ Set model to Opus 4.8 and saved as your default for new sessions"),
486
+ });
487
+ const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
488
+ expect(reply.text).toContain("Set model to Opus 4.8");
489
+ expect(reply.selectedModel).toBe("Opus 4.8");
490
+ });
491
+
492
+ it("does NOT record an override when the confirmation is 'Kept model as' (no change)", async () => {
493
+ const { deps } = makeDeps({
494
+ inject: async () => okResult("⏺ Kept model as Opus 4.8 (default)"),
495
+ });
496
+ const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
497
+ // The kept line is still relayed as a confirmation, but nothing is recorded.
498
+ expect(reply.selectedModel).toBeUndefined();
387
499
  });
388
500
  });
389
501
 
@@ -423,14 +535,15 @@ describe("handleModelCommand — Claude → sr-* session relaunch", () => {
423
535
  expect(restartCalls).toHaveLength(0);
424
536
  });
425
537
 
426
- it("sr-* → Claude still takes the scheduleRestart path (not scheduleModelRelaunch)", async () => {
538
+ it("sr-* → Claude rides scheduleModelRelaunch (carrier) so the Claude model survives the restart", async () => {
427
539
  const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
428
540
  getActiveSessionModel: () => "sr-glm-5",
429
541
  });
430
542
  await handleModelCommand({ kind: "set", model: "opus" }, deps);
431
543
  expect(calls).toHaveLength(0);
432
- expect(relaunchCalls).toHaveLength(0);
433
- expect(restartCalls).toHaveLength(1);
544
+ expect(restartCalls).toHaveLength(0);
545
+ expect(relaunchCalls).toHaveLength(1);
546
+ expect(relaunchCalls[0].model).toBe("opus");
434
547
  });
435
548
 
436
549
  it("surfaces scheduleModelRelaunch failures without propagating the error", async () => {
@@ -498,14 +611,15 @@ describe("handleModelCommand — arbitrary sr-* passthrough (no whitelist)", ()
498
611
  }
499
612
  });
500
613
 
501
- it("switching FROM an arbitrary sr-* back to Claude takes the restart path", async () => {
614
+ it("switching FROM an arbitrary sr-* back to Claude carries the Claude model via the relaunch carrier", async () => {
502
615
  const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
503
616
  getActiveSessionModel: () => "sr-gpt-oss-120b",
504
617
  });
505
618
  await handleModelCommand({ kind: "set", model: "opus" }, deps);
506
619
  expect(calls).toHaveLength(0);
507
- expect(relaunchCalls).toHaveLength(0);
508
- expect(restartCalls).toHaveLength(1);
620
+ expect(restartCalls).toHaveLength(0);
621
+ expect(relaunchCalls).toHaveLength(1);
622
+ expect(relaunchCalls[0].model).toBe("opus");
509
623
  });
510
624
  });
511
625
 
@@ -565,14 +679,15 @@ describe("SR_MODEL_ALIASES / expandSrAlias", () => {
565
679
  expect(relaunchCalls[0].model).toBe("sr-gemini-2.5-flash");
566
680
  });
567
681
 
568
- it("handleModelCommand with alias schedules restart when session is on sr-*", async () => {
569
- const { deps, calls, restartCalls } = makeDeps({
682
+ it("handleModelCommand with a Claude alias relaunches (carrier) when session is on sr-*", async () => {
683
+ const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
570
684
  getActiveSessionModel: () => "sr-deepseek-v3",
571
685
  });
572
686
  await handleModelCommand({ kind: "set", model: "opus" }, deps);
573
687
  expect(calls).toHaveLength(0);
574
- expect(restartCalls).toHaveLength(1);
575
- expect(restartCalls[0]).toContain("opus");
688
+ expect(restartCalls).toHaveLength(0);
689
+ expect(relaunchCalls).toHaveLength(1);
690
+ expect(relaunchCalls[0].model).toBe("opus");
576
691
  });
577
692
  });
578
693
 
@@ -746,6 +861,40 @@ describe("handleModelMenuCallback", () => {
746
861
  // The gateway records this so /status reflects the live session model.
747
862
  expect(out.selectedModel).toBe("Haiku 4.5");
748
863
  });
864
+
865
+ it("tapping the Default row when already on it (Kept model as) records NO override", async () => {
866
+ // Bug 6: "Kept model as X" is a no-change; the old code fell back to
867
+ // target.label and stored "Default (recommended)" verbatim into /status.
868
+ const { deps } = makeMenuDeps({
869
+ select: async () => ({ ok: true as const, confirmation: "Kept model as Opus 4.8 (default)" }),
870
+ });
871
+ const out = await handleModelMenuCallback(modelSelectCallbackData("Default (recommended)"), deps);
872
+ expect(out.reply.text).toContain("✅");
873
+ // Crucially, NOTHING is recorded — no display label leaks into the override.
874
+ expect(out.selectedModel).toBeUndefined();
875
+ expect(out.reply.text).not.toContain("Default (recommended)");
876
+ });
877
+
878
+ it("a real switch on the Default row stores a canonical token, never the display label", async () => {
879
+ // The Default row confirms with a real model name; the /status value is that
880
+ // name (never "Default (recommended)"), and the carrier token is canonical.
881
+ const { deps } = makeMenuDeps({
882
+ select: async () => ({ ok: true as const, confirmation: "Set model to Opus 4.8 for this session only" }),
883
+ });
884
+ const out = await handleModelMenuCallback(modelSelectCallbackData("Default (recommended)"), deps);
885
+ expect(out.selectedModel).toBe("Opus 4.8");
886
+ // "Default (recommended)" yields no --model token → carrier boots the config default.
887
+ expect(out.selectedModelToken).toBeUndefined();
888
+ });
889
+
890
+ it("selecting an alias row exposes a canonical --model token for the sr→claude carrier", async () => {
891
+ const { deps } = makeMenuDeps({
892
+ select: async () => ({ ok: true as const, confirmation: "Set model to Sonnet for this session" }),
893
+ });
894
+ const out = await handleModelMenuCallback(modelSelectCallbackData("Sonnet"), deps);
895
+ expect(out.selectedModel).toBe("Sonnet");
896
+ expect(out.selectedModelToken).toBe("sonnet");
897
+ });
749
898
  });
750
899
 
751
900
  describe("sessionModelFromConfirmation", () => {
@@ -754,6 +903,11 @@ describe("sessionModelFromConfirmation", () => {
754
903
  expect(sessionModelFromConfirmation("Set model to Opus 4.8 (1M context) for this session only")).toBe("Opus 4.8");
755
904
  expect(sessionModelFromConfirmation("Switched to Haiku 4.5")).toBe("Haiku 4.5");
756
905
  });
906
+ it("terminates the name at 'and saved' (claude v2.1.205 arg-form phrasing, TUI-probe verified)", () => {
907
+ expect(
908
+ sessionModelFromConfirmation("⎿ Set model to Opus 4.8 and saved as your default for new sessions"),
909
+ ).toBe("Opus 4.8");
910
+ });
757
911
  it("returns null when no recognizable name is present", () => {
758
912
  expect(sessionModelFromConfirmation("Kept model as Opus 4.8 (default)")).toBeNull();
759
913
  expect(sessionModelFromConfirmation("")).toBeNull();
@@ -945,29 +1099,35 @@ describe("handleModelMenuCallback — sr-* selection", () => {
945
1099
  });
946
1100
  }
947
1101
 
948
- it("sr-* tap uses inject path, not cursor nav", async () => {
949
- const { deps, calls, injectCalls } = makeMenuDepsWithSr();
1102
+ it("sr-* tap delegates to the carrier relaunch — never a picker-rejecting inject", async () => {
1103
+ // The gateway intercepts mdl:sr: before this function; if a direct caller
1104
+ // reaches it, delegating to scheduleModelRelaunch is the ONLY safe path (an
1105
+ // inject of `/model sr-*` 4xxs — picker rejects the id, base-URL never repointed).
1106
+ const relaunch: Array<{ model: string }> = [];
1107
+ const { deps, calls, injectCalls } = makeMenuDepsWithSr({
1108
+ scheduleModelRelaunch: async (model) => { relaunch.push({ model }); },
1109
+ });
950
1110
  const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`, deps);
951
- // inject was called with the raw /model command
952
- expect(injectCalls).toContainEqual({ agent: "klanker", command: "/model sr-gemini-2.5-pro" });
953
- // select (cursor nav) was NOT called
1111
+ // No inject, no cursor nav — a carrier relaunch on the exact sr-* id.
1112
+ expect(injectCalls).toHaveLength(0);
954
1113
  expect(calls.select).toHaveLength(0);
955
- expect(out.answer).toContain("Set model to sonnet");
1114
+ expect(relaunch).toEqual([{ model: "sr-gemini-2.5-pro" }]);
956
1115
  expect(out.selectedModel).toBe("sr-gemini-2.5-pro");
957
- // No keyboard on success: static reply path skips discover() to avoid the
958
- // spurious "(picker unavailable)" line that discover() reliably produces
959
- // immediately after an inject. Operator taps /model for a fresh menu.
960
1116
  expect(out.reply.keyboard).toBeUndefined();
961
- // Banner present and text doesn't contain picker-unavailable noise
962
- expect(out.reply.text).toContain('✅');
1117
+ expect(out.reply.text).toContain('🔄');
963
1118
  expect(out.reply.text).not.toContain('picker unavailable');
964
1119
  });
965
1120
 
966
- it("sr-* tap while busy returns toast-only with no inject", async () => {
967
- const { deps, injectCalls } = makeMenuDepsWithSr({ isBusy: () => true });
1121
+ it("sr-* tap while busy returns toast-only with no relaunch", async () => {
1122
+ const relaunch: string[] = [];
1123
+ const { deps, injectCalls } = makeMenuDepsWithSr({
1124
+ isBusy: () => true,
1125
+ scheduleModelRelaunch: async (model) => { relaunch.push(model); },
1126
+ });
968
1127
  const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`, deps);
969
1128
  expect(out.toastOnly).toBe(true);
970
1129
  expect(injectCalls).toHaveLength(0);
1130
+ expect(relaunch).toHaveLength(0);
971
1131
  });
972
1132
 
973
1133
  it("rejects malformed sr-* callback data", async () => {
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { formatModelLabel, isModelSentinel } from '../model-label.js'
3
+
4
+ describe('isModelSentinel', () => {
5
+ it('flags synthetic / placeholder values starting with "<"', () => {
6
+ expect(isModelSentinel('<synthetic>')).toBe(true)
7
+ expect(isModelSentinel('<compact>')).toBe(true)
8
+ })
9
+
10
+ it('flags empty / whitespace / non-string values', () => {
11
+ expect(isModelSentinel('')).toBe(true)
12
+ expect(isModelSentinel(' ')).toBe(true)
13
+ expect(isModelSentinel(undefined)).toBe(true)
14
+ expect(isModelSentinel(null)).toBe(true)
15
+ expect(isModelSentinel(42)).toBe(true)
16
+ })
17
+
18
+ it('flags junk that is not shaped like a model id', () => {
19
+ expect(isModelSentinel('not a model')).toBe(true) // whitespace
20
+ expect(isModelSentinel('!!!')).toBe(true) // leading punctuation
21
+ })
22
+
23
+ it('accepts real resolved model ids', () => {
24
+ expect(isModelSentinel('claude-opus-4-8')).toBe(false)
25
+ expect(isModelSentinel('claude-haiku-4-5-20251001')).toBe(false)
26
+ expect(isModelSentinel('sr-glm-5')).toBe(false)
27
+ expect(isModelSentinel('sr-gpt-5.5')).toBe(false)
28
+ })
29
+ })
30
+
31
+ describe('formatModelLabel', () => {
32
+ it('renders the friendly short form for claude models', () => {
33
+ expect(formatModelLabel('claude-opus-4-8')).toBe('opus 4.8')
34
+ expect(formatModelLabel('claude-sonnet-5')).toBe('sonnet 5')
35
+ })
36
+
37
+ it('drops a trailing YYYYMMDD date stamp', () => {
38
+ expect(formatModelLabel('claude-haiku-4-5-20251001')).toBe('haiku 4.5')
39
+ expect(formatModelLabel('claude-opus-4-8-20260115')).toBe('opus 4.8')
40
+ })
41
+
42
+ it('shows sr-* ids verbatim', () => {
43
+ expect(formatModelLabel('sr-glm-5')).toBe('sr-glm-5')
44
+ expect(formatModelLabel('sr-gpt-5.5')).toBe('sr-gpt-5.5')
45
+ expect(formatModelLabel('sr-gemini-2.5-pro')).toBe('sr-gemini-2.5-pro')
46
+ })
47
+
48
+ it('returns null for sentinels and absent values (caller omits the field)', () => {
49
+ expect(formatModelLabel('<synthetic>')).toBeNull()
50
+ expect(formatModelLabel('')).toBeNull()
51
+ expect(formatModelLabel(null)).toBeNull()
52
+ expect(formatModelLabel(undefined)).toBeNull()
53
+ expect(formatModelLabel('has spaces')).toBeNull()
54
+ })
55
+
56
+ it('shows unknown but model-shaped ids verbatim', () => {
57
+ expect(formatModelLabel('gpt-5')).toBe('gpt-5')
58
+ expect(formatModelLabel('opus')).toBe('opus')
59
+ })
60
+
61
+ it('handles a claude id with only a family segment', () => {
62
+ expect(formatModelLabel('claude-opus')).toBe('opus')
63
+ })
64
+ })
@@ -253,6 +253,21 @@ describe('renderOperatorEvent — unknown-5xx', () => {
253
253
  })
254
254
  })
255
255
 
256
+ describe('renderOperatorEvent — config-warning', () => {
257
+ it('is framed as non-urgent (dismiss-only, no restart/reauth/logs actions)', () => {
258
+ const { text, keyboard } = renderOperatorEvent(
259
+ makeEvent('config-warning', { detail: 'person_id: dropped 1 malformed users: entry at boot — lisa (empty person_id)' }),
260
+ )
261
+ expect(text).toContain('Config warning')
262
+ expect(text).toContain('Non-urgent')
263
+ const buttons = keyboard.inline_keyboard.flat()
264
+ expect(buttons.every(b => b.callback_data?.includes('dismiss'))).toBe(true)
265
+ expect(buttons.some(b => b.callback_data?.includes('restart'))).toBe(false)
266
+ expect(buttons.some(b => b.callback_data?.includes('reauth'))).toBe(false)
267
+ expect(buttons.some(b => b.callback_data?.includes('logs'))).toBe(false)
268
+ })
269
+ })
270
+
256
271
  describe('renderOperatorEvent — markdown escaping (#2669)', () => {
257
272
  it('passes < > literally in agent name (markdown, #2669)', () => {
258
273
  const { text } = renderOperatorEvent(makeEvent('unknown-4xx', { agent: '<evil>' }))
@@ -276,6 +291,8 @@ describe('renderOperatorEvent — all kinds produce valid keyboard structure', (
276
291
  'agent-restarted-unexpectedly',
277
292
  'unknown-4xx',
278
293
  'unknown-5xx',
294
+ 'config-warning',
295
+ 'always-allow-persist-failed',
279
296
  ]
280
297
 
281
298
  for (const kind of allKinds) {