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
@@ -48,7 +48,7 @@ import { sanitiseToolArg } from './fleet-state.js'
48
48
  import { clipNarrative, describeToolUse } from './tool-activity-summary.js'
49
49
  import { REPLY_TOOLS, isDraftOfReply } from './narrative-dedup.js'
50
50
  import { truncate } from './card-format.js'
51
- import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents, recordNestedSubagentDispatch } from './registry/subagents-schema.js'
51
+ import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents, recordNestedSubagentDispatch, recordSubagentModel } from './registry/subagents-schema.js'
52
52
  import { touchTurnActiveMarker } from './gateway/turn-active-marker.js'
53
53
 
54
54
  // ─── Types ───────────────────────────────────────────────────────────────────
@@ -193,6 +193,44 @@ export interface WorkerEntry {
193
193
  * Pre hook pending" window that froze nested cards on "starting…".
194
194
  */
195
195
  lastBackfillAttemptAt?: number
196
+ /**
197
+ * Fix #5: set on a boot-scan `running` file whose mtime passed the
198
+ * `inflightPromoteMaxAgeMs` freshness gate but has NOT yet shown any
199
+ * post-boot JSONL growth. mtime freshness alone can't distinguish a
200
+ * worker killed moments before the restart (indistinguishable file state)
201
+ * from a genuinely still-running one — so promotion is deferred until
202
+ * `checkBootPromotionGrowth` observes the file's size actually advance
203
+ * past its boot-time snapshot (real proof of life), or `deadlineAt`
204
+ * passes with no growth (treated as historical/orphan — never promoted).
205
+ * `undefined` once resolved either way.
206
+ */
207
+ bootPromotionPending?: { deadlineAt: number }
208
+ /**
209
+ * Fix #1(+#2): the `run_in_background` flag as last observed from the
210
+ * registry `subagents` row, carried on the entry itself rather than
211
+ * re-derived only at `onFinish` time. The registry row is the sole
212
+ * source of truth for this flag (the watcher never sees the dispatching
213
+ * tool_input directly), but the row is looked up by `jsonl_agent_id` —
214
+ * exactly the linkage that can be permanently missing (meta.json
215
+ * unreadable, or the fuzzy backfill found no unambiguous candidate).
216
+ * Once we DO observe the row (at registration or on any later liveness
217
+ * tick — the backfill retry can link it after the fact), we cache the
218
+ * flag here so a subsequent `onFinish` can fall back to it even if
219
+ * `getSubagentByJsonlId` fails again at finish time. `undefined` until
220
+ * the row has been observed at least once.
221
+ */
222
+ background?: boolean
223
+ /**
224
+ * Live model this worker is running, as a raw resolved model id (e.g.
225
+ * `claude-opus-4-8`). Set from the worker's OWN transcript `message.model`
226
+ * (`sub_agent_model` event) — the authoritative live source — and persisted to
227
+ * the registry row on change (recordSubagentModel) so boot-replay / handback
228
+ * cards can render it without a live entry. Threaded onto the onProgress
229
+ * payload so the worker card's metrics line shows the model. Undefined until
230
+ * the worker's first assistant line lands; before that the card falls back to
231
+ * the dispatch-time `tool_input.model` persisted on the registry row.
232
+ */
233
+ currentModel?: string
196
234
  }
197
235
 
198
236
  export interface SubagentWatcherConfig {
@@ -271,6 +309,13 @@ export interface SubagentWatcherConfig {
271
309
  * stale-handback replay regression.
272
310
  */
273
311
  inflightPromoteMaxAgeMs?: number
312
+ /**
313
+ * Fix #9a: override for `TERMINATED_AGENT_IDS_CAP` — the bound on the
314
+ * `terminatedAgentIds` re-discovery dedup guard. Exposed mainly so tests
315
+ * can exercise eviction without inserting thousands of entries. Defaults
316
+ * to `TERMINATED_AGENT_IDS_CAP` (a few thousand) in production.
317
+ */
318
+ terminatedAgentIdsCap?: number
274
319
  /**
275
320
  * Kill-switch for the boot-scan promotion path. When false, a
276
321
  * running-at-boot worker is never promoted — the watcher reverts to the
@@ -390,6 +435,15 @@ export interface SubagentWatcherConfig {
390
435
  * no `sub_agent_text` line was ever observed. Feeds the
391
436
  * `subagent_handback` inbound. */
392
437
  resultText: string
438
+ /**
439
+ * Fix #1(+#2): the entry's own cached `background` flag (see
440
+ * `WorkerEntry.background`), threaded through so the gateway can fall
441
+ * back to it when its own registry lookup at finish time also comes up
442
+ * empty (unlinked `jsonl_agent_id`). `undefined` when the row was never
443
+ * observed by the watcher either — the gateway degrades further from
444
+ * there (see the onFinish handler's non-empty-resultText fallback).
445
+ */
446
+ background: boolean | undefined
393
447
  }) => void
394
448
  /**
395
449
  * #1720: fires on every `sub_agent_text` event for a running
@@ -419,6 +473,12 @@ export interface SubagentWatcherConfig {
419
473
  * feed. Undefined on `sub_agent_text` ticks — the gateway falls back
420
474
  * to `latestSummary` (the narrative line), preserving prior behavior. */
421
475
  progressLine?: string
476
+ /** Live model this worker is running (raw resolved id, e.g.
477
+ * `claude-opus-4-8`), from `WorkerEntry.currentModel`. Threaded onto the
478
+ * worker/nested card's metrics line. Undefined before the worker's first
479
+ * assistant line — the gateway then falls back to the registry's
480
+ * dispatch-time model. */
481
+ model?: string
422
482
  }) => void
423
483
  /** `Date.now` override for tests. */
424
484
  now?: () => number
@@ -572,6 +632,18 @@ const TERMINAL_CLEANUP_GRACE_MS = 30_000
572
632
  */
573
633
  const BACKFILL_RETRY_INTERVAL_MS = 3000
574
634
 
635
+ /**
636
+ * Fix #9a: cap on `terminatedAgentIds` (the re-discovery dedup guard in
637
+ * `cleanupTerminalAgent` / `scanSubagentsDir`). Pre-fix the Set only grew
638
+ * (added on every terminal cleanup, only ever cleared wholesale in
639
+ * `stop()`), so a long-lived gateway with sustained sub-agent throughput
640
+ * accumulated ids without bound. A `Set` preserves insertion order, so once
641
+ * the cap is hit the OLDEST id is evicted on each new insert (simple ring
642
+ * buffer semantics) — recently-terminated ids (the ones actually at risk of
643
+ * a re-discovery race) always stay covered.
644
+ */
645
+ const TERMINATED_AGENT_IDS_CAP = 5000
646
+
575
647
  // ─── JSONL tail per sub-agent ─────────────────────────────────────────────
576
648
 
577
649
  interface SubTail {
@@ -662,20 +734,30 @@ export function backfillJsonlAgentId(
662
734
 
663
735
  // Fallback path: fuzzy (agentType, description) match for older Claude Code
664
736
  // versions whose meta.json predates the toolUseId field.
737
+ //
738
+ // Fix #3: with N unlinked rows sharing (agent_type, description), a bare
739
+ // `ORDER BY started_at DESC LIMIT 1` assigns by start-order, not true
740
+ // correspondence — a benign FIFO for interchangeable rows, but a genuine
741
+ // mislink for a cross-topic identical dispatch. Read up to 2 candidates
742
+ // (not just 1): if more than one row shares the key, the match is
743
+ // ambiguous — refuse to link (leave jsonl_agent_id NULL) and let the
744
+ // marker/window path attribute it instead, rather than guessing.
665
745
  if (candidateId == null && (meta.agentType || meta.description)) {
666
- const fuzzy = db
746
+ const fuzzyCandidates = db
667
747
  .prepare(`
668
748
  SELECT id FROM subagents
669
749
  WHERE jsonl_agent_id IS NULL
670
750
  AND agent_type IS ?
671
751
  AND description IS ?
672
752
  ORDER BY started_at DESC
673
- LIMIT 1
753
+ LIMIT 2
674
754
  `)
675
- .get(meta.agentType ?? null, meta.description ?? null) as { id: string } | null
676
- if (fuzzy != null) {
677
- candidateId = fuzzy.id
755
+ .all(meta.agentType ?? null, meta.description ?? null) as { id: string }[]
756
+ if (fuzzyCandidates.length === 1) {
757
+ candidateId = fuzzyCandidates[0]!.id
678
758
  log?.(`subagent-watcher: backfill fuzzy match ${agentId} → ${candidateId} (type=${meta.agentType} desc=${meta.description})`)
759
+ } else if (fuzzyCandidates.length > 1) {
760
+ log?.(`subagent-watcher: backfill fuzzy match refused for ${agentId} — ${fuzzyCandidates.length} ambiguous candidates share (type=${meta.agentType} desc=${meta.description}); leaving unlinked`)
679
761
  }
680
762
  }
681
763
 
@@ -809,6 +891,8 @@ export function readSubTail(
809
891
  /** Friendly display line for THIS tick (set on tool ticks; see the
810
892
  * SubagentWatcherConfig.onProgress doc). */
811
893
  progressLine?: string
894
+ /** Live model this worker is running (see SubagentWatcherConfig.onProgress). */
895
+ model?: string
812
896
  }) => void,
813
897
  ): void {
814
898
  try {
@@ -868,6 +952,12 @@ export function readSubTail(
868
952
  } else {
869
953
  bumpSubagentActivity(db, { id: existing.id, ts: now })
870
954
  isForeground = existing.background === 0
955
+ // Fix #1(+#2): keep the entry's cached background flag fresh —
956
+ // this is also how a NESTED worker (whose row links only via the
957
+ // throttled backfill retry above, after the one-shot attempt at
958
+ // registration lost the race) picks up its background flag before
959
+ // its own `onFinish` fires.
960
+ entry.background = existing.background === 1
871
961
  }
872
962
  } catch (dbErr) {
873
963
  log?.(`subagent-watcher: liveness write error ${entry.agentId}: ${(dbErr as Error).message}`)
@@ -918,8 +1008,8 @@ export function readSubTail(
918
1008
  // (stage-on-text, resolve-on-tool/turn_end) can replay a previously
919
1009
  // pending block exactly once. `latestSummary` carries the worker's
920
1010
  // narrative result (entry.lastResultText), never tool labels.
921
- const fireNarrativeProgress = (): void => {
922
- if (onProgress == null || entry.state !== 'running' || entry.historical) return
1011
+ const fireNarrativeProgress = (): boolean => {
1012
+ if (onProgress == null || entry.state !== 'running' || entry.historical) return false
923
1013
  try {
924
1014
  onProgress({
925
1015
  agentId: entry.agentId,
@@ -932,9 +1022,12 @@ export function readSubTail(
932
1022
  },
933
1023
  lastTool: entry.lastTool,
934
1024
  toolCount: entry.toolCount,
1025
+ model: entry.currentModel,
935
1026
  })
1027
+ return true
936
1028
  } catch (cbErr) {
937
1029
  log?.(`subagent-watcher: onProgress callback error ${entry.agentId}: ${(cbErr as Error).message}`)
1030
+ return false
938
1031
  }
939
1032
  }
940
1033
  // Resolve a pending sub-agent narrative against a lookahead event.
@@ -952,21 +1045,27 @@ export function readSubTail(
952
1045
  // compare the trailing block against the worker's last reply text
953
1046
  // (`entry.lastReplyText`) and suppress a draft. Background workers
954
1047
  // never set lastReplyText, so their trailing narration still SHOWs.
1048
+ // Returns true iff a narrative onProgress cue actually fired this
1049
+ // call — callers use this to skip a redundant/clobbering tool-label
1050
+ // onProgress cue for the SAME tick (see #1042 below: without this,
1051
+ // the tool-description onProgress unconditionally fires right after
1052
+ // and its replace-on-write onProgress always wins, so the narration
1053
+ // shown here is never actually visible on the pinned card).
955
1054
  const resolvePendingSubNarrative = (
956
1055
  toolName: string | null,
957
1056
  toolInput: Record<string, unknown> | undefined,
958
- ): void => {
959
- if (entry.pendingNarrative == null) return
1057
+ ): boolean => {
1058
+ if (entry.pendingNarrative == null) return false
960
1059
  const pending = entry.pendingNarrative
961
1060
  entry.pendingNarrative = null
962
1061
  if (toolName != null && REPLY_TOOLS.has(toolName)) {
963
1062
  const replyText = typeof toolInput?.text === 'string' ? (toolInput.text as string) : ''
964
- if (isDraftOfReply(pending.text, replyText)) return // draft of the reply → SUPPRESS
1063
+ if (isDraftOfReply(pending.text, replyText)) return false // draft of the reply → SUPPRESS
965
1064
  } else if (toolName == null && entry.lastReplyText != null && entry.lastReplyText.length > 0) {
966
1065
  // turn_end path: suppress a trailing draft of the delivered answer.
967
- if (isDraftOfReply(pending.text, entry.lastReplyText)) return
1066
+ if (isDraftOfReply(pending.text, entry.lastReplyText)) return false
968
1067
  }
969
- fireNarrativeProgress()
1068
+ return fireNarrativeProgress()
970
1069
  }
971
1070
  for (const ev of events) {
972
1071
  const idleSecBeforeBump = Math.round((now - entry.lastActivityAt) / 1000)
@@ -1007,12 +1106,37 @@ export function readSubTail(
1007
1106
  }
1008
1107
  log?.(`subagent-watcher: stall cleared for ${entry.agentId} (activity resumed after ${idleSecBeforeBump}s — re-arming detection)`)
1009
1108
  }
1109
+ if (ev.kind === 'sub_agent_model') {
1110
+ // Live model capture for this worker. The projection already filtered
1111
+ // sentinels, so `ev.model` is a real resolved id. Record it on the
1112
+ // entry (transcript wins over the dispatch-time tool_input.model) and
1113
+ // persist to the registry row on CHANGE — update-on-change like
1114
+ // last_activity_at — so a boot-replay / handback card can render the
1115
+ // model without a live entry. No card render here: the model rides the
1116
+ // next onProgress tick's payload (below).
1117
+ if (entry.currentModel !== ev.model) {
1118
+ entry.currentModel = ev.model
1119
+ if (db != null) {
1120
+ try {
1121
+ const rowRef = db
1122
+ .prepare('SELECT id FROM subagents WHERE jsonl_agent_id = ?')
1123
+ .get(entry.agentId) as { id: string } | null
1124
+ if (rowRef != null) {
1125
+ recordSubagentModel(db, { id: rowRef.id, model: ev.model })
1126
+ }
1127
+ } catch (dbErr) {
1128
+ log?.(`subagent-watcher: model DB write error ${entry.agentId}: ${(dbErr as Error).message}`)
1129
+ }
1130
+ }
1131
+ }
1132
+ continue
1133
+ }
1010
1134
  if (ev.kind === 'sub_agent_tool_use') {
1011
1135
  // Narrative-dedup gate step 2: a sub_agent_text block was pending;
1012
1136
  // this tool is the lookahead that decides it (SHOW unless it drafts
1013
1137
  // a reply tool's text). Runs before the tool's own progress cue so
1014
1138
  // a working preamble surfaces just ahead of its tool step.
1015
- resolvePendingSubNarrative(ev.toolName, ev.input)
1139
+ const narrativeJustFired = resolvePendingSubNarrative(ev.toolName, ev.input)
1016
1140
  // NIT 3: capture a foreground sub-agent's actual reply text so the
1017
1141
  // turn_end path can suppress a trailing draft of it (see
1018
1142
  // resolvePendingSubNarrative). Only REPLY_TOOLS carry the answer.
@@ -1039,7 +1163,17 @@ export function readSubTail(
1039
1163
  // stays the worker's narrative result (never polluted with tool
1040
1164
  // labels — the handback payload depends on it). Pure jsonl-tail →
1041
1165
  // render, no model call.
1042
- if (onProgress != null && entry.state === 'running' && !entry.historical) {
1166
+ //
1167
+ // Clobber guard: if a pending narrative just fired an onProgress
1168
+ // cue THIS SAME tick (above), skip this one — replace-on-write
1169
+ // rendering means whichever onProgress call fires last wins, so
1170
+ // firing both back-to-back always threw away the narration in
1171
+ // favour of the generic tool label. Narration already told the
1172
+ // user what's happening for this tick; the label is redundant
1173
+ // here. When nothing preceded this tool call (no pending
1174
+ // narrative), this still fires — that's the named foreground
1175
+ // blindspot fix, unchanged.
1176
+ if (onProgress != null && entry.state === 'running' && !entry.historical && !narrativeJustFired) {
1043
1177
  const toolLine = describeToolUse(ev.toolName, ev.input ?? {})
1044
1178
  if (toolLine != null && toolLine.length > 0) {
1045
1179
  try {
@@ -1055,6 +1189,7 @@ export function readSubTail(
1055
1189
  lastTool: entry.lastTool,
1056
1190
  toolCount: entry.toolCount,
1057
1191
  progressLine: toolLine,
1192
+ model: entry.currentModel,
1058
1193
  })
1059
1194
  } catch (cbErr) {
1060
1195
  log?.(`subagent-watcher: onProgress (tool) callback error ${entry.agentId}: ${(cbErr as Error).message}`)
@@ -1215,6 +1350,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1215
1350
  config.inflightPromoteMaxAgeMs
1216
1351
  ?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_MAX_AGE_MS')
1217
1352
  ?? DEFAULT_INFLIGHT_PROMOTE_MAX_AGE_MS
1353
+ const terminatedAgentIdsCap = config.terminatedAgentIdsCap ?? TERMINATED_AGENT_IDS_CAP
1218
1354
  // Kill-switch: not parseEnvMs (which rejects `0`) — an explicit `=0`
1219
1355
  // here MUST disable promotion (revert to pre-v0.14.23 suppression).
1220
1356
  const bootPromoteEnabled =
@@ -1343,6 +1479,17 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1343
1479
  } catch (err) {
1344
1480
  log?.(`subagent-watcher: backfill error for ${agentId}: ${(err as Error).message}`)
1345
1481
  }
1482
+ // Fix #1(+#2): if the backfill above (or an already-linked row from a
1483
+ // prior registration attempt) resolved jsonl_agent_id, cache the
1484
+ // row's `background` flag on the entry NOW — the earliest point it
1485
+ // can ever be known — so it survives even if a later `onFinish`
1486
+ // can't re-resolve the row (see `WorkerEntry.background` doc).
1487
+ try {
1488
+ const row = db
1489
+ .prepare('SELECT background FROM subagents WHERE jsonl_agent_id = ?')
1490
+ .get(agentId) as { background: number } | null
1491
+ if (row != null) entry.background = row.background === 1
1492
+ } catch { /* best-effort — entry.background stays undefined */ }
1346
1493
  }
1347
1494
 
1348
1495
  const tail: SubTail = {
@@ -1391,37 +1538,16 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1391
1538
  } else if (fileAgeMs > inflightPromoteMaxAgeMs) {
1392
1539
  log?.(`subagent-watcher: ${agentId} running at boot but stale (last write ${Math.round(fileAgeMs / 1000)}s ago > ${Math.round(inflightPromoteMaxAgeMs / 1000)}s) — leaving historical (dead prior-session worker, not in-flight)`)
1393
1540
  } else {
1394
- entry.historical = false
1395
- log?.(`subagent-watcher: ${agentId} was in-flight at boot promoting to live (last write ${Math.round(fileAgeMs / 1000)}s ago; user still awaiting handback)`)
1396
- // The prior gateway life's registration normally linked
1397
- // jsonl_agent_id already, but re-run the backfill idempotently in
1398
- // case that life crashed before the link persisted the handback's
1399
- // isBackground lookup is keyed on jsonl_agent_id, and an unlinked row
1400
- // would mis-resolve the worker as foreground and drop the handback.
1401
- if (db != null) {
1402
- try {
1403
- backfillJsonlAgentId(db, filePath, agentId, log)
1404
- } catch (err) {
1405
- log?.(`subagent-watcher: backfill error for ${agentId}: ${(err as Error).message}`)
1406
- }
1407
- }
1408
- // Historical/onProgress gate-ordering fix: the initial read above ran
1409
- // while `entry.historical` was still TRUE, so every onProgress cue it
1410
- // would have fired was suppressed by the `!entry.historical` guard —
1411
- // and the tail cursor is now at EOF, so for a quiet worker no later
1412
- // tick ever re-fires them. The card painted as a bare stub and froze
1413
- // on "starting…" forever. Re-read from the start now that the entry
1414
- // is live: the replayed events rebuild toolCount/lastTool/narrative
1415
- // AND fire onProgress so the worker's card reaches real activity.
1416
- entry.toolCount = 0
1417
- entry.lastTool = null
1418
- entry.pendingNarrative = null
1419
- tail.cursor = 0
1420
- tail.pendingPartial = ''
1421
- tail.hasEmittedStart = false
1422
- readSubTail(entry, tail, n, (desc) => {
1423
- log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
1424
- }, fs, log, db, parentStateDir, config.onUnstall, undefined, config.onProgress)
1541
+ // Fix #5: a fresh mtime alone does NOT confirm liveness — a worker
1542
+ // killed seconds before this restart looks byte-for-byte identical
1543
+ // to a genuinely still-running one. Defer promotion until
1544
+ // `checkBootPromotionGrowth` observes the file actually grow AFTER
1545
+ // this boot (real proof of life), bounded by the same
1546
+ // `inflightPromoteMaxAgeMs` window as an outer timeout. Until then
1547
+ // the entry stays `historical` (no stall detection, no completion
1548
+ // synthesis) see checkBootPromotionGrowth / promoteBootEntry.
1549
+ entry.bootPromotionPending = { deadlineAt: n + inflightPromoteMaxAgeMs }
1550
+ log?.(`subagent-watcher: ${agentId} running at boot (last write ${Math.round(fileAgeMs / 1000)}s ago) — awaiting post-boot JSONL growth before promoting to live (mtime freshness alone can't rule out a just-killed worker)`)
1425
1551
  }
1426
1552
  }
1427
1553
 
@@ -1452,6 +1578,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1452
1578
  const entry = registry.get(agentId)
1453
1579
  const t = tails.get(agentId)
1454
1580
  if (!entry || !t) return
1581
+ checkBootPromotionGrowth(entry, t, nowFn())
1455
1582
  readSubTail(entry, t, nowFn(), (desc) => {
1456
1583
  log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
1457
1584
  }, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent, config.onProgress)
@@ -1462,6 +1589,68 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1462
1589
  }
1463
1590
  }
1464
1591
 
1592
+ /**
1593
+ * Fix #5: the growth-confirmation half of boot promotion. Called on every
1594
+ * tick (poll + fs.watch) for an entry that's still awaiting confirmation
1595
+ * (`entry.bootPromotionPending` set — see the freshness-gate branch in
1596
+ * `registerAgent`). Promotes to live the moment the file's size advances
1597
+ * past `tail.cursor` (the boot-time snapshot — the initial full read in
1598
+ * `registerAgent` already parked the cursor at EOF-at-boot, so ANY further
1599
+ * growth is unambiguously post-boot). If the bounded window elapses with
1600
+ * no growth, gives up permanently — the entry stays `historical`, so it
1601
+ * can never synthesise a stale `completed` handback from pre-restart bytes
1602
+ * (checkStalls / onFinish both gate on `!entry.historical`).
1603
+ */
1604
+ function checkBootPromotionGrowth(entry: WorkerEntry, tail: SubTail, n: number): void {
1605
+ const pending = entry.bootPromotionPending
1606
+ if (!pending) return
1607
+ let size: number | null = null
1608
+ try {
1609
+ size = fs.statSync(entry.filePath).size
1610
+ } catch {
1611
+ /* unreadable — fall through to the deadline check below */
1612
+ }
1613
+ if (size != null && size > tail.cursor) {
1614
+ entry.bootPromotionPending = undefined
1615
+ entry.historical = false
1616
+ log?.(`subagent-watcher: ${entry.agentId} confirmed live — observed post-boot JSONL growth (${tail.cursor} → ${size} bytes); promoting`)
1617
+ // The prior gateway life's registration normally linked
1618
+ // jsonl_agent_id already, but re-run the backfill idempotently in
1619
+ // case that life crashed before the link persisted — the handback's
1620
+ // isBackground lookup is keyed on jsonl_agent_id, and an unlinked row
1621
+ // would mis-resolve the worker as foreground and drop the handback.
1622
+ if (db != null) {
1623
+ try {
1624
+ backfillJsonlAgentId(db, entry.filePath, entry.agentId, log)
1625
+ } catch (err) {
1626
+ log?.(`subagent-watcher: backfill error for ${entry.agentId}: ${(err as Error).message}`)
1627
+ }
1628
+ }
1629
+ // Historical/onProgress gate-ordering fix (carried over from the old
1630
+ // synchronous promotion path): the initial read in registerAgent ran
1631
+ // while `entry.historical` was still TRUE, so any onProgress cue it
1632
+ // would have fired was suppressed, and the cursor is at EOF-at-boot —
1633
+ // for a quiet worker no later tick would ever re-fire them. Re-read
1634
+ // from the start now that the entry is live: the replayed events
1635
+ // rebuild toolCount/lastTool/narrative AND fire onProgress so the
1636
+ // worker's card reaches real activity instead of a frozen stub.
1637
+ entry.toolCount = 0
1638
+ entry.lastTool = null
1639
+ entry.pendingNarrative = null
1640
+ tail.cursor = 0
1641
+ tail.pendingPartial = ''
1642
+ tail.hasEmittedStart = false
1643
+ readSubTail(entry, tail, n, (desc) => {
1644
+ log?.(`subagent-watcher: description updated for ${entry.agentId}: ${desc}`)
1645
+ }, fs, log, db, parentStateDir, config.onUnstall, undefined, config.onProgress)
1646
+ return
1647
+ }
1648
+ if (n >= pending.deadlineAt) {
1649
+ entry.bootPromotionPending = undefined
1650
+ log?.(`subagent-watcher: ${entry.agentId} never observed post-boot JSONL growth within the window — leaving historical/orphan (not promoting; avoids synthesising a stale 'completed' handback from a worker killed before this restart)`)
1651
+ }
1652
+ }
1653
+
1465
1654
  // ─── State-transition notifications ─────────────────────────────────────
1466
1655
 
1467
1656
  function maybySendStateTransition(agentId: string): void {
@@ -1504,6 +1693,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1504
1693
  resultText: entry.errored
1505
1694
  ? entry.lastResultText || entry.errorDetail || ''
1506
1695
  : entry.lastResultText,
1696
+ background: entry.background,
1507
1697
  })
1508
1698
  } catch (cbErr) {
1509
1699
  log?.(`subagent-watcher: onFinish callback error ${agentId}: ${(cbErr as Error).message}`)
@@ -1525,6 +1715,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1525
1715
  durationMs: nowFn() - entry.dispatchedAt,
1526
1716
  description: entry.description,
1527
1717
  resultText: entry.lastResultText,
1718
+ background: entry.background,
1528
1719
  })
1529
1720
  } catch (cbErr) {
1530
1721
  log?.(`subagent-watcher: onFinish callback error ${agentId}: ${(cbErr as Error).message}`)
@@ -1575,6 +1766,14 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1575
1766
  // Issue #1116 (Bug B): record that this agent has been fully
1576
1767
  // processed so a rescan that rediscovers the still-present JSONL
1577
1768
  // doesn't re-register and re-notify.
1769
+ //
1770
+ // Fix #9a: bound the Set so it can't grow unboundedly over a long-lived
1771
+ // gateway — evict the oldest id once the cap is hit (Set iteration
1772
+ // order is insertion order, so `.values().next().value` is the oldest).
1773
+ if (!terminatedAgentIds.has(agentId) && terminatedAgentIds.size >= terminatedAgentIdsCap) {
1774
+ const oldest = terminatedAgentIds.values().next().value
1775
+ if (oldest != null) terminatedAgentIds.delete(oldest)
1776
+ }
1578
1777
  terminatedAgentIds.add(agentId)
1579
1778
  log?.(`subagent-watcher: cleaned up terminal agent ${agentId}`)
1580
1779
  }
@@ -1876,6 +2075,10 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1876
2075
  if (entry.state !== 'running') continue
1877
2076
  const tail = tails.get(agentId)
1878
2077
  if (!tail) continue
2078
+ // Fix #5: defensive poll-loop fallback for the same growth check the
2079
+ // fs.watch callback runs — covers the case where fs.watch missed the
2080
+ // event that would otherwise have confirmed liveness.
2081
+ checkBootPromotionGrowth(entry, tail, n)
1879
2082
  readSubTail(entry, tail, n, (desc) => {
1880
2083
  log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
1881
2084
  }, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent, config.onProgress)