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
@@ -345,6 +345,42 @@ describe('projectTranscriptLine', () => {
345
345
  messageId: '103',
346
346
  })
347
347
  })
348
+
349
+ // ─── Live model capture (message.model) ──────────────────────────────
350
+ it('emits a model event (first) from message.model on an assistant line', () => {
351
+ const line = JSON.stringify({
352
+ type: 'assistant',
353
+ message: {
354
+ model: 'claude-opus-4-8',
355
+ content: [{ type: 'tool_use', name: 'Bash', id: 'toolu_01', input: {} }],
356
+ },
357
+ })
358
+ // Model event is emitted BEFORE the content events so a same-batch render
359
+ // already reflects the current model.
360
+ expect(projectTranscriptLine(line)).toEqual([
361
+ { kind: 'model', model: 'claude-opus-4-8' },
362
+ { kind: 'tool_use', toolName: 'Bash', toolUseId: 'toolu_01', input: {} },
363
+ ])
364
+ })
365
+
366
+ it('skips a synthetic model sentinel (keeps no model event)', () => {
367
+ const line = JSON.stringify({
368
+ type: 'assistant',
369
+ message: {
370
+ model: '<synthetic>',
371
+ content: [{ type: 'thinking', thinking: '...' }],
372
+ },
373
+ })
374
+ expect(projectTranscriptLine(line)).toEqual([{ kind: 'thinking' }])
375
+ })
376
+
377
+ it('omits the model event when message.model is absent', () => {
378
+ const line = JSON.stringify({
379
+ type: 'assistant',
380
+ message: { content: [{ type: 'thinking', thinking: '...' }] },
381
+ })
382
+ expect(projectTranscriptLine(line)).toEqual([{ kind: 'thinking' }])
383
+ })
348
384
  })
349
385
 
350
386
  // ─── Bug 1 regression: per-file cursor state survives re-attachment ────
@@ -490,6 +526,34 @@ describe('projectSubagentLine', () => {
490
526
  ])
491
527
  })
492
528
 
529
+ it('emits sub_agent_model (first) from message.model on a sub-agent assistant line', () => {
530
+ const st = { hasEmittedStart: true }
531
+ const line = JSON.stringify({
532
+ type: 'assistant',
533
+ message: {
534
+ model: 'sr-glm-5',
535
+ content: [{ type: 'tool_use', id: 'toolu_a', name: 'Read', input: { file_path: '/a' } }],
536
+ },
537
+ })
538
+ const events = projectSubagentLine(line, 'X', st)
539
+ expect(events[0]).toEqual({ kind: 'sub_agent_model', agentId: 'X', model: 'sr-glm-5' })
540
+ expect(events[1].kind).toBe('sub_agent_tool_use')
541
+ })
542
+
543
+ it('skips a synthetic sub-agent model sentinel', () => {
544
+ const st = { hasEmittedStart: true }
545
+ const line = JSON.stringify({
546
+ type: 'assistant',
547
+ message: {
548
+ model: '<synthetic>',
549
+ content: [{ type: 'tool_use', id: 'toolu_a', name: 'Read', input: { file_path: '/a' } }],
550
+ },
551
+ })
552
+ const events = projectSubagentLine(line, 'X', st)
553
+ expect(events.some((e) => e.kind === 'sub_agent_model')).toBe(false)
554
+ expect(events[0].kind).toBe('sub_agent_tool_use')
555
+ })
556
+
493
557
  it('emits sub_agent_tool_use for regular tools; nested Agent fires ONLY nested_spawn', () => {
494
558
  const st = { hasEmittedStart: true }
495
559
  const line = JSON.stringify({
@@ -190,6 +190,59 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
190
190
  expect(state.retryCount).toBe(SILENT_END_MAX_RETRIES)
191
191
  })
192
192
 
193
+ it('blocks + writes retryCount=1 when an early qualifying reply is followed by an undelivered verdict (trailing-content bug repro)', () => {
194
+ // Confirmed-incident shape: (1) a background-task notification
195
+ // arrives, (2) the agent calls reply ONCE early with a stale,
196
+ // notification-bearing ack ("running now" — disable_notification
197
+ // unset, so it satisfies isFinalAnswerReply regardless of length),
198
+ // (3) the agent then does more work and writes a large substantive
199
+ // verdict as plain assistant text with NO second reply call. Pre-fix,
200
+ // the hook's "reply called at least once this turn" check allowed
201
+ // this to slip through silently — the trailing verdict never reached
202
+ // the user. Post-fix the hook must block on this shape exactly like
203
+ // the zero-reply case.
204
+ const transcript = writeTranscript(tmp, [
205
+ ENQUEUE,
206
+ reply('running now'),
207
+ { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }] } },
208
+ { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/tmp/x' } }] } },
209
+ {
210
+ type: 'assistant',
211
+ message: { content: [{ type: 'text', text: 'Here is the actual verdict: ' + 'X'.repeat(300) }] },
212
+ },
213
+ ])
214
+ const r = runHook({
215
+ event: { session_id: 's1', transcript_path: transcript },
216
+ stateDir,
217
+ })
218
+ expect(r.status).toBe(0)
219
+ const out = JSON.parse(r.stdout)
220
+ expect(out.decision).toBe('block')
221
+ expect(out.reason).toMatch(/Send your final answer/)
222
+ const statePath = join(stateDir, 'silent-end-pending.json')
223
+ expect(existsSync(statePath)).toBe(true)
224
+ const state = JSON.parse(readFileSync(statePath, 'utf8'))
225
+ expect(state.retryCount).toBe(1)
226
+ expect(state.chatId).toBe('111')
227
+ expect(state.turnKey).toBe('111:_')
228
+ })
229
+
230
+ it('does NOT false-positive on a normal single-reply turn ending on the reply tool_use', () => {
231
+ const transcript = writeTranscript(tmp, [
232
+ ENQUEUE,
233
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Let me check.' }] } },
234
+ { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }] } },
235
+ reply('Here is your answer.', { disable_notification: false }),
236
+ ])
237
+ const r = runHook({
238
+ event: { session_id: 's1', transcript_path: transcript },
239
+ stateDir,
240
+ })
241
+ expect(r.status).toBe(0)
242
+ expect(r.stdout.trim()).toBe('')
243
+ expect(existsSync(join(stateDir, 'silent-end-pending.json'))).toBe(false)
244
+ })
245
+
193
246
  it('NO_REPLY in transcript → allow stop, no state file written', () => {
194
247
  const transcript = writeTranscript(tmp, [
195
248
  ENQUEUE,
@@ -184,6 +184,144 @@ describe('scanTurnForFinalReply — final-reply detection', () => {
184
184
  })
185
185
  })
186
186
 
187
+ // ── "at least once" bug regression — trailing content after an early
188
+ // qualifying reply must still block ────────────────────────────────
189
+
190
+ describe('scanTurnForFinalReply — trailing undelivered content after an early qualifying reply (bug repro)', () => {
191
+ it('early notification-bearing ack + later substantive plain text → block (not "reply called once" amnesty)', () => {
192
+ // Repro of the confirmed incident: the agent (1) got a background-task
193
+ // notification, (2) called reply ONCE early with a short, stale ack —
194
+ // notification-bearing (disable_notification unset/false), which
195
+ // ALWAYS qualifies as "final" under isFinalAnswerReply regardless of
196
+ // text length — then (3) wrote a large substantive verdict as plain
197
+ // assistant text with NO second reply call. The pre-fix scan returned
198
+ // 'allow' on the first qualifying block and never looked further; the
199
+ // fix must walk the whole turn and catch the undelivered trailing text.
200
+ const text = jsonl(
201
+ ENQUEUE,
202
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'running now' }),
203
+ assistantToolUse('Bash', { command: 'ls' }),
204
+ assistantToolUse('Read', { file_path: '/tmp/x' }),
205
+ assistantText('Here is the actual verdict after investigation: ' + 'X'.repeat(300)),
206
+ )
207
+ const r = scanTurnForFinalReply(text)
208
+ expect(r.decided).toBe('block')
209
+ expect(r.reason).toBe('trailing-text-after-reply')
210
+ // turnKey/chatId must still be populated so the gateway's retry
211
+ // bookkeeping works exactly as it does for the zero-reply block path.
212
+ expect(r.chatId).toBe('111')
213
+ expect(r.turnKey).toBe('111:_')
214
+ })
215
+
216
+ it('early qualifying reply + trailing SHORT pleasantry → allow (#2956 review: substance floor, no-spam)', () => {
217
+ // A short trailing text after a delivered reply (a closer like "let me
218
+ // know if you need anything else.") is NOT a dropped answer — it's a
219
+ // pleasantry the persona prompts discourage but which must not burn
220
+ // retry budget or force a redundant second reply. Only SUBSTANTIVE
221
+ // trailing text (≥ FINAL_ANSWER_MIN_CHARS) blocks. Pre-fix this
222
+ // false-positive blocked and re-prompted a healthy turn.
223
+ const text = jsonl(
224
+ ENQUEUE,
225
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ok', disable_notification: false }),
226
+ assistantText('actually, one more thing you should know'),
227
+ )
228
+ const r = scanTurnForFinalReply(text)
229
+ expect(r.decided).toBe('allow')
230
+ })
231
+
232
+ it('early qualifying reply + trailing SUBSTANTIVE undelivered text (≥ floor) → block (at-least-once holds)', () => {
233
+ // The substance floor must NOT weaken the at-least-once guarantee for
234
+ // a real dropped answer: a long trailing verdict the model forgot to
235
+ // send still blocks. The trailing text here clears FINAL_ANSWER_MIN_CHARS.
236
+ const text = jsonl(
237
+ ENQUEUE,
238
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ok', disable_notification: false }),
239
+ assistantText('Here is the actual verdict after investigation: ' + 'X'.repeat(200)),
240
+ )
241
+ const r = scanTurnForFinalReply(text)
242
+ expect(r.decided).toBe('block')
243
+ expect(r.reason).toBe('trailing-text-after-reply')
244
+ })
245
+
246
+ it('final qualifying reply is the LAST content block → allow (healthy shape, no false positive)', () => {
247
+ const text = jsonl(
248
+ ENQUEUE,
249
+ assistantText('Let me check that.'),
250
+ assistantToolUse('Bash', { command: 'ls' }),
251
+ assistantToolUse('mcp__switchroom-telegram__reply', {
252
+ text: 'Here is your answer.',
253
+ disable_notification: false,
254
+ }),
255
+ )
256
+ const r = scanTurnForFinalReply(text)
257
+ expect(r.decided).toBe('allow')
258
+ expect(r.reason).toBe('final-reply')
259
+ })
260
+
261
+ it('turn ends on a tool_result with no trailing text at all → allow (not a normal-turn false positive)', () => {
262
+ // A turn whose very last assistant content is the delivering
263
+ // reply tool_use, followed only by a (user-role) tool_result line —
264
+ // never any further assistant text. Must not be flagged.
265
+ const text = jsonl(
266
+ ENQUEUE,
267
+ assistantToolUse('mcp__switchroom-telegram__reply', {
268
+ text: 'Done — here is the summary.',
269
+ disable_notification: false,
270
+ }),
271
+ JSON.stringify({ type: 'user', message: { content: [{ type: 'tool_result', content: 'ok' }] } }),
272
+ )
273
+ const r = scanTurnForFinalReply(text)
274
+ expect(r.decided).toBe('allow')
275
+ expect(r.reason).toBe('final-reply')
276
+ })
277
+
278
+ it('undelivered text sandwiched between two qualifying replies is superseded by the LATER reply → allow', () => {
279
+ const text = jsonl(
280
+ ENQUEUE,
281
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'first answer', disable_notification: false }),
282
+ assistantText('actually let me reconsider that'),
283
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'corrected final answer', disable_notification: false }),
284
+ )
285
+ const r = scanTurnForFinalReply(text)
286
+ expect(r.decided).toBe('allow')
287
+ expect(r.reason).toBe('final-reply')
288
+ })
289
+
290
+ it('explicit trailing NO_REPLY after an earlier qualifying reply overrides → allow (intentional final silence)', () => {
291
+ const text = jsonl(
292
+ ENQUEUE,
293
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'draft answer', disable_notification: false }),
294
+ assistantText('actually, scrap that, nothing more to add\nNO_REPLY'),
295
+ )
296
+ const r = scanTurnForFinalReply(text)
297
+ expect(r.decided).toBe('allow')
298
+ expect(r.reason).toBe('silent-marker-text')
299
+ })
300
+
301
+ it('reverse case: an EARLIER NO_REPLY silence marker followed by later undelivered prose → block', () => {
302
+ // Mirror image of the "trailing NO_REPLY overrides" allow-case above.
303
+ // Here the model signals silence FIRST — as plain transcript text,
304
+ // not through the reply tool — and then keeps going, writing a real
305
+ // substantive answer afterward that it never sent through `reply` or
306
+ // `stream_reply`. The NO_REPLY marker is a "deliver" event (silence is
307
+ // a valid outcome), but it must not amnesty content written AFTER it,
308
+ // same failure shape as the original "at least once" bug: a naive scan
309
+ // that stops at the FIRST qualifying delivery/silence event would
310
+ // return 'allow' here and the trailing answer would be silently
311
+ // dropped. The fix's "last delivery event, then check for trailing
312
+ // text" walk must still catch this.
313
+ const text = jsonl(
314
+ ENQUEUE,
315
+ assistantText('Nothing to report right now.\nNO_REPLY'),
316
+ assistantToolUse('Bash', { command: 'ls' }),
317
+ assistantText('Wait, actually I found something you need to know: ' + 'Y'.repeat(300)),
318
+ )
319
+ const r = scanTurnForFinalReply(text)
320
+ expect(r.decided).toBe('block')
321
+ expect(r.reason).toBe('trailing-text-after-reply')
322
+ })
323
+ })
324
+
187
325
  describe('scanTurnForFinalReply — silent-marker carve-out', () => {
188
326
  it('NO_REPLY → allow', () => {
189
327
  const text = jsonl(
@@ -10,6 +10,7 @@ import {
10
10
  recordSilentTurnEnd,
11
11
  recordUndeliveredTurnEnd,
12
12
  SILENT_END_MAX_RETRIES,
13
+ SILENT_END_STALE_RECORD_MAX_AGE_MS,
13
14
  } from '../silent-end.js'
14
15
  import { isFinalAnswerReply } from '../final-answer-detect.js'
15
16
 
@@ -206,12 +207,56 @@ describe('recordSilentTurnEnd — #1161 exhaustion detection', () => {
206
207
  chatId: 'c', threadId: null, turnKey: 'c:_',
207
208
  retryCount: SILENT_END_MAX_RETRIES, timestamp: 0,
208
209
  }))
209
- const r = recordSilentTurnEnd({ chatId: 'c', threadId: null, turnKey: 'c:_' })
210
+ // Pin `now` alongside the record's timestamp=0 so this exercises the
211
+ // genuine same-turn ladder, not fix #8's age-based staleness bound
212
+ // (which is covered by its own dedicated tests below).
213
+ const r = recordSilentTurnEnd(
214
+ { chatId: 'c', threadId: null, turnKey: 'c:_' },
215
+ { now: () => 0 },
216
+ )
210
217
  expect(r.exhausted).toBe(true)
211
218
  // State cleared so the Stop hook on this final turn allows the stop.
212
219
  expect(readSilentEndState()).toBeNull()
213
220
  })
214
221
 
222
+ it('fix #8: an exhausted record older than the plausible turn lifetime starts a fresh retry budget (no delivery evidence needed)', () => {
223
+ // A crash/interrupt bypassed the gateway's own exhaust-read-and-clear,
224
+ // so a spent (retryCount >= MAX) record from an OLD turn survives on
225
+ // disk. turnKey is the STABLE statusKey(chatId, threadId) — it matches
226
+ // this brand-new dark turn on the same chat/thread even though it
227
+ // belongs to a completely different turn instance.
228
+ const path = join(stateDir, 'silent-end-pending.json')
229
+ writeFileSync(path, JSON.stringify({
230
+ chatId: 'c', threadId: null, turnKey: 'c:_',
231
+ retryCount: SILENT_END_MAX_RETRIES, timestamp: 0,
232
+ }))
233
+ const now = SILENT_END_STALE_RECORD_MAX_AGE_MS + 1000 // just past the age bound
234
+ const r = recordSilentTurnEnd(
235
+ { chatId: 'c', threadId: null, turnKey: 'c:_' },
236
+ { now: () => now },
237
+ )
238
+ // Must run its OWN re-prompt ladder, not immediately fall back.
239
+ expect(r.exhausted).toBe(false)
240
+ expect(readSilentEndState()).toMatchObject({ turnKey: 'c:_', retryCount: 0 })
241
+ })
242
+
243
+ it('fix #8: a genuinely same-turn exhausted record (within the age bound) still reports exhausted — no regression', () => {
244
+ const path = join(stateDir, 'silent-end-pending.json')
245
+ const recentTimestamp = 1_000_000
246
+ writeFileSync(path, JSON.stringify({
247
+ chatId: 'c', threadId: null, turnKey: 'c:_',
248
+ retryCount: SILENT_END_MAX_RETRIES, timestamp: recentTimestamp,
249
+ }))
250
+ // Well within the plausible single-turn retry-ladder window.
251
+ const now = recentTimestamp + 5000
252
+ const r = recordSilentTurnEnd(
253
+ { chatId: 'c', threadId: null, turnKey: 'c:_' },
254
+ { now: () => now },
255
+ )
256
+ expect(r.exhausted).toBe(true)
257
+ expect(readSilentEndState()).toBeNull()
258
+ })
259
+
215
260
  it('treats a capped prior state for a DIFFERENT turn as a fresh silent-end', () => {
216
261
  const path = join(stateDir, 'silent-end-pending.json')
217
262
  writeFileSync(path, JSON.stringify({
@@ -108,6 +108,45 @@ describe('subagent-tracker-pretool', () => {
108
108
  expect(row!.last_activity_at).toBe(row!.started_at)
109
109
  })
110
110
 
111
+ it('persists tool_input.model as the first-paint model on the row', () => {
112
+ const event = {
113
+ session_id: 'sess-model',
114
+ tool_name: 'Agent',
115
+ tool_use_id: 'toolu_model001',
116
+ tool_input: {
117
+ subagent_type: 'worker',
118
+ description: 'Build with a pinned model',
119
+ run_in_background: true,
120
+ model: 'claude-opus-4-8',
121
+ },
122
+ }
123
+ const result = runHook(PRETOOL_SCRIPT, event)
124
+ expect(result.status).toBe(0)
125
+
126
+ const db = openDb()
127
+ const row = db.prepare('SELECT model FROM subagents WHERE id = ?').get('toolu_model001') as
128
+ | { model: string | null }
129
+ | undefined
130
+ expect(row?.model).toBe('claude-opus-4-8')
131
+ })
132
+
133
+ it('leaves model null when the Agent dispatch carries no model', () => {
134
+ const event = {
135
+ session_id: 'sess-nomodel',
136
+ tool_name: 'Agent',
137
+ tool_use_id: 'toolu_nomodel001',
138
+ tool_input: { subagent_type: 'worker', description: 'no model', run_in_background: false },
139
+ }
140
+ const result = runHook(PRETOOL_SCRIPT, event)
141
+ expect(result.status).toBe(0)
142
+
143
+ const db = openDb()
144
+ const row = db.prepare('SELECT model FROM subagents WHERE id = ?').get('toolu_nomodel001') as
145
+ | { model: string | null }
146
+ | undefined
147
+ expect(row?.model ?? null).toBeNull()
148
+ })
149
+
111
150
  it('does not write a row when tool_name is not Agent', () => {
112
151
  const event = {
113
152
  session_id: 'sess-abc123',
@@ -37,14 +37,19 @@ describe('boot promotion — in-flight worker replays its activity through onPro
37
37
  let currentTime = 100_000
38
38
  // The file EXISTS AT BOOT with real in-flight activity: a prompt and two
39
39
  // tool steps, still running (no turn_end). mtime is fresh (30s ago) so
40
- // the freshness gate promotes it.
41
- const content = Buffer.from(buildJSONL(
40
+ // the freshness gate makes it ELIGIBLE — but fix #5 also requires actual
41
+ // post-boot growth before promoting (mtime freshness alone can't
42
+ // distinguish this from a worker killed moments before the restart).
43
+ // `content` grows below via the captured fs.watch callback to supply
44
+ // that confirmation.
45
+ let content = Buffer.from(buildJSONL(
42
46
  { type: 'user', message: { content: [{ type: 'text', text: 'long research task' }] } },
43
47
  { type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/repo/a.ts' } }] } },
44
48
  { type: 'assistant', message: { content: [{ type: 'tool_use', id: 't2', name: 'Bash', input: { command: 'npm test' } }] } },
45
49
  ), 'utf-8')
46
50
 
47
51
  let lastOpened: string | null = null
52
+ let watchCb: (() => void) | null = null
48
53
  const mockFs = {
49
54
  existsSync: ((p: fs.PathLike) => {
50
55
  const ps = String(p)
@@ -69,7 +74,10 @@ describe('boot promotion — in-flight worker replays its activity through onPro
69
74
  src.copy(buf as Buffer, offset)
70
75
  return src.length
71
76
  }) as unknown as typeof fs.readSync,
72
- watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
77
+ watch: ((_p: fs.PathLike, cb?: () => void) => {
78
+ watchCb = cb ?? null
79
+ return { close: vi.fn() } as unknown as fs.FSWatcher
80
+ }) as unknown as typeof fs.watch,
73
81
  }
74
82
 
75
83
  const progressCalls: Array<{ agentId: string; progressLine?: string; toolCount: number }> = []
@@ -86,10 +94,25 @@ describe('boot promotion — in-flight worker replays its activity through onPro
86
94
  },
87
95
  })
88
96
 
97
+ // Fix #5: registration alone (mtime-fresh, no observed growth yet) must
98
+ // NOT promote — it stays historical/pending until growth confirms it.
99
+ const preGrowthEntry = watcher.getRegistry().get(agentId)
100
+ expect(preGrowthEntry?.historical).toBe(true)
101
+ expect(preGrowthEntry?.bootPromotionPending).toBeDefined()
102
+
103
+ // Simulate the worker taking one more real step post-boot: the JSONL
104
+ // grows, and Claude Code's fs notification fires.
105
+ content = Buffer.concat([content, Buffer.from(buildJSONL(
106
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'still going' }] } },
107
+ ), 'utf-8')])
108
+ currentTime += 1000
109
+ watchCb?.()
110
+
89
111
  const entry = watcher.getRegistry().get(agentId)
90
112
  expect(entry).toBeDefined()
91
- // Promoted live (fresh mtime, still running).
113
+ // Promoted live now that post-boot growth was actually observed.
92
114
  expect(entry?.historical).toBe(false)
115
+ expect(entry?.bootPromotionPending).toBeUndefined()
93
116
  // Replay rebuilt the entry WITHOUT double-counting the initial read.
94
117
  expect(entry?.toolCount).toBe(2)
95
118
  expect(entry?.lastTool?.name).toBe('Bash')
@@ -102,6 +125,86 @@ describe('boot promotion — in-flight worker replays its activity through onPro
102
125
  watcher.stop()
103
126
  })
104
127
 
128
+ it('fix #5: a running-at-boot file with fresh mtime but NO post-boot growth is never promoted (avoids a stale completed handback)', () => {
129
+ const agentId = 'inflight-no-growth-01'
130
+ const agentDir = '/home/user/.switchroom/agents/myagent'
131
+ const projectsRoot = `${agentDir}/.claude/projects`
132
+ const projectDir = `${projectsRoot}/mock-cwd`
133
+ const sessionDir = `${projectDir}/sess`
134
+ const subagentsDir = `${sessionDir}/subagents`
135
+ const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
136
+
137
+ let currentTime = 100_000
138
+ // Fresh mtime (30s old) but the file NEVER grows again — indistinguishable
139
+ // from a worker that was killed moments before this restart.
140
+ const content = Buffer.from(buildJSONL(
141
+ { type: 'user', message: { content: [{ type: 'text', text: 'task that got killed' }] } },
142
+ { type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/repo/a.ts' } }] } },
143
+ ), 'utf-8')
144
+
145
+ let lastOpened: string | null = null
146
+ const intervals: Array<{ fn: () => void; ms: number; fireAt: number }> = []
147
+ const mockFs = {
148
+ existsSync: ((p: fs.PathLike) => {
149
+ const ps = String(p)
150
+ return ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir || ps === jsonlPath
151
+ }) as typeof fs.existsSync,
152
+ readdirSync: ((p: fs.PathLike) => {
153
+ const ps = String(p)
154
+ if (ps === projectsRoot) return ['mock-cwd']
155
+ if (ps === projectDir) return ['sess']
156
+ if (ps === sessionDir) return ['subagents']
157
+ if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
158
+ return []
159
+ }) as unknown as typeof fs.readdirSync,
160
+ statSync: (() => ({ size: content.length, mtimeMs: 100_000 - 30_000 }) as fs.Stats) as typeof fs.statSync,
161
+ openSync: ((p: fs.PathLike) => { lastOpened = String(p); return 7 }) as unknown as typeof fs.openSync,
162
+ closeSync: (() => { lastOpened = null }) as typeof fs.closeSync,
163
+ readSync: ((
164
+ _fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
165
+ ): number => {
166
+ if (lastOpened !== jsonlPath) return 0
167
+ const src = content.slice(position ?? 0, (position ?? 0) + length)
168
+ src.copy(buf as Buffer, offset)
169
+ return src.length
170
+ }) as unknown as typeof fs.readSync,
171
+ watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
172
+ }
173
+
174
+ const finishCalls: Array<{ agentId: string; outcome: string }> = []
175
+ const watcher = startSubagentWatcher({
176
+ agentDir,
177
+ fs: mockFs,
178
+ inflightPromoteMaxAgeMs: 60_000, // small bound so the test can cross it
179
+ now: () => currentTime,
180
+ setInterval: (fn, ms) => {
181
+ intervals.push({ fn, ms, fireAt: currentTime + ms })
182
+ return { ref: 0 }
183
+ },
184
+ clearInterval: () => {},
185
+ setTimeout: () => ({ ref: 0 }),
186
+ clearTimeout: () => {},
187
+ onFinish: ({ agentId: id, outcome }) => finishCalls.push({ agentId: id, outcome }),
188
+ })
189
+
190
+ const entryAtBoot = watcher.getRegistry().get(agentId)
191
+ expect(entryAtBoot?.historical).toBe(true)
192
+ expect(entryAtBoot?.bootPromotionPending).toBeDefined()
193
+
194
+ // Advance well past the promotion window WITHOUT the file ever growing.
195
+ currentTime += 120_000
196
+ for (const iv of intervals) if (iv.fireAt <= currentTime) iv.fn()
197
+
198
+ const entry = watcher.getRegistry().get(agentId)
199
+ // Never promoted: still historical, no completed/failed handback ever
200
+ // synthesised from these pre-restart bytes.
201
+ expect(entry?.historical).toBe(true)
202
+ expect(entry?.bootPromotionPending).toBeUndefined() // gave up after the deadline
203
+ expect(finishCalls).toHaveLength(0)
204
+
205
+ watcher.stop()
206
+ })
207
+
105
208
  it('a STALE running-at-boot file stays historical and fires no onProgress (no replay spam)', () => {
106
209
  const agentId = 'stale-at-boot-01'
107
210
  const agentDir = '/home/user/.switchroom/agents/myagent'
@@ -224,14 +224,22 @@ describe('Gap 1 — background worker in-flight across a gateway restart', () =>
224
224
  expect(h.finishCalls[0].agentId).toBe('gap1-complete')
225
225
  expect(h.finishCalls[0].outcome).toBe('completed') // pre-fix: 'orphan' → dropped
226
226
  expect(h.finishCalls[0].resultText).toContain('root cause')
227
- // The promotion is logged so the path is observable in prod.
228
- expect(h.logs.some((l) => l.includes('in-flight at bootpromoting to live'))).toBe(true)
227
+ // Fix #5: promotion now requires an observed post-boot growth event
228
+ // (mtime freshness alone is refused)the `append` above supplied it,
229
+ // and the promotion is logged so the path is observable in prod.
230
+ expect(h.logs.some((l) => l.includes('confirmed live') && l.includes('post-boot JSONL growth'))).toBe(true)
229
231
  })
230
232
 
231
- it('an in-flight-at-boot worker that dies silently is rescued by stall synthesis', () => {
233
+ it('an in-flight-at-boot worker that dies silently (after proving liveness) is rescued by stall synthesis', () => {
232
234
  // Pre-fix, historical entries were skipped by stall detection, so a
233
235
  // worker that crossed a restart and then went silent sat running
234
236
  // forever — no handback ever. After promotion it gets the safety net.
237
+ //
238
+ // Fix #5: promotion additionally requires proof of post-boot liveness —
239
+ // a single real step (e.g. a narrative line) grows the JSONL past its
240
+ // boot-time snapshot, confirming this is NOT a worker killed moments
241
+ // before the restart. Only THEN does it get the stall-synthesis safety
242
+ // net when it subsequently goes silent for good.
235
243
  const h = makeHarness({
236
244
  agentId: 'gap1-silent',
237
245
  bootLines: [subAgentUserMsg('bg task')],
@@ -239,6 +247,11 @@ describe('Gap 1 — background worker in-flight across a gateway restart', () =>
239
247
  silentStallTerminalMs: 120_000,
240
248
  })
241
249
 
250
+ h.append(subAgentText('still investigating'))
251
+ h.advance(600) // one poll observes the post-boot growth → promotes
252
+
253
+ expect(h.watcher.getRegistry().get('gap1-silent')?.historical).toBe(false)
254
+
242
255
  h.advance(62_000) // stall threshold crossed
243
256
  expect(h.stallTerminalCalls).toHaveLength(0)
244
257
  h.advance(121_000) // silent-stall terminal window elapses → synthesis
@@ -247,6 +260,30 @@ describe('Gap 1 — background worker in-flight across a gateway restart', () =>
247
260
  expect(h.finishCalls[0].outcome).toBe('completed')
248
261
  })
249
262
 
263
+ it('fix #5: an in-flight-at-boot worker that NEVER shows post-boot growth is left historical (no stale completed handback)', () => {
264
+ // The bug this closes: a worker killed <15min before a restart is
265
+ // byte-for-byte indistinguishable from a live one by mtime alone. Absent
266
+ // any actual post-boot growth, it must NOT be promoted — so it can never
267
+ // synthesise a false 'completed' handback from the pre-restart bytes.
268
+ const h = makeHarness({
269
+ agentId: 'gap1-killed-before-restart',
270
+ bootLines: [subAgentUserMsg('bg task')],
271
+ stallThresholdMs: 60_000,
272
+ silentStallTerminalMs: 120_000,
273
+ inflightPromoteMaxAgeMs: 60_000,
274
+ })
275
+
276
+ // No append — the file never grows again after boot.
277
+ h.advance(62_000)
278
+ h.advance(121_000)
279
+ h.advance(600_000) // well past the promotion window too
280
+
281
+ expect(h.watcher.getRegistry().get('gap1-killed-before-restart')?.historical).toBe(true)
282
+ expect(h.stallTerminalCalls).toHaveLength(0)
283
+ expect(h.finishCalls).toHaveLength(0)
284
+ expect(h.logs.some((l) => l.includes('never observed post-boot JSONL growth'))).toBe(true)
285
+ })
286
+
250
287
  it('a worker already DONE at boot stays suppressed (no spurious replay)', () => {
251
288
  // The legitimate use of `historical`: a worker that finished in a prior
252
289
  // session must NOT re-fire a handback on every restart. This is the
@@ -298,7 +335,8 @@ describe('Gap 1 freshness gate — v0.14.24 stale-replay regression', () => {
298
335
 
299
336
  expect(h.finishCalls).toHaveLength(1)
300
337
  expect(h.finishCalls[0].outcome).toBe('completed')
301
- expect(h.logs.some((l) => l.includes('promoting to live'))).toBe(true)
338
+ // Fix #5: the append above supplies the required post-boot growth proof.
339
+ expect(h.logs.some((l) => l.includes('confirmed live'))).toBe(true)
302
340
  })
303
341
 
304
342
  it('kill-switch (bootPromoteEnabled=false) suppresses even a fresh running-at-boot worker', () => {