switchroom 0.18.6 → 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 (71) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +1 -0
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +269 -56
  6. package/dist/host-control/main.js +2 -1
  7. package/dist/vault/approvals/kernel-server.js +1 -0
  8. package/dist/vault/broker/server.js +1 -0
  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/telegram-plugin/dist/bridge/bridge.js +22 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +1937 -580
  15. package/telegram-plugin/dist/server.js +24 -0
  16. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  17. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  18. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  19. package/telegram-plugin/gateway/gateway.ts +1071 -130
  20. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  21. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  22. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  23. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  24. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  25. package/telegram-plugin/gateway/model-command.ts +212 -51
  26. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  27. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  28. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  29. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  30. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  31. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  32. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  33. package/telegram-plugin/model-label.ts +69 -0
  34. package/telegram-plugin/operator-events.ts +24 -0
  35. package/telegram-plugin/permission-diff.ts +128 -0
  36. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  37. package/telegram-plugin/registry/subagents.test.ts +90 -0
  38. package/telegram-plugin/session-tail.ts +28 -0
  39. package/telegram-plugin/silent-end.ts +49 -4
  40. package/telegram-plugin/subagent-watcher.ts +222 -37
  41. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  42. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  43. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  44. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  45. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
  46. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  47. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  48. package/telegram-plugin/tests/model-command.test.ts +202 -42
  49. package/telegram-plugin/tests/model-label.test.ts +64 -0
  50. package/telegram-plugin/tests/operator-events.test.ts +1 -0
  51. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  52. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  53. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  54. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  55. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  56. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  57. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  58. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  59. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  60. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  61. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  62. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  63. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  64. package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
  65. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  66. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  67. package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
  68. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  69. package/telegram-plugin/tool-activity-summary.ts +22 -2
  70. package/telegram-plugin/typing-wrap.ts +72 -25
  71. package/telegram-plugin/worker-activity-feed.ts +9 -0
@@ -60,6 +60,8 @@ export interface SilentEndDeps {
60
60
  * never suppress on doubt.
61
61
  */
62
62
  hasOutboundDeliveredSince?: (chatId: string, sinceMs: number, threadId?: number | null) => boolean
63
+ /** Wall-clock now, ms. Defaults to `Date.now`; injectable for tests. */
64
+ now?: () => number
63
65
  }
64
66
 
65
67
  /**
@@ -80,6 +82,31 @@ export interface SilentEndDeps {
80
82
  */
81
83
  export const SILENT_END_MAX_RETRIES = 2
82
84
 
85
+ /**
86
+ * Fix #8 (adversarial-validation progress-card fix pass): `turnKey` is the
87
+ * STABLE `statusKey(chatId, threadId)` — it never changes across turns on
88
+ * the same chat/thread — so `writeSilentEndState`'s "inherit retryCount IFF
89
+ * turnKey matches" rule has no per-turn discriminator on its own. Normally
90
+ * that's fine because `clearSilentEndState` always fires the moment a reply
91
+ * lands, wiping the exhausted record before the next turn starts. But if the
92
+ * gateway's own exhaust-read-and-clear is bypassed (crash/interrupt mid-turn,
93
+ * `gateway.ts` ~turn_end handler never runs), an exhausted
94
+ * (`retryCount >= SILENT_END_MAX_RETRIES`) record can survive into a LATER,
95
+ * unrelated turn. That later turn's first silent-end would then misread the
96
+ * old record as "this turn already exhausted its ladder" and skip its own
97
+ * re-prompt entirely.
98
+ *
99
+ * A record legitimately re-written within one turn's own retry ladder is
100
+ * always fresh (each Stop-hook block re-writes `timestamp`), so bounding by
101
+ * age is a safe, cheap staleness signal that doesn't require a new identity
102
+ * field: any record older than a turn's plausible lifetime could not still
103
+ * be "this turn's" in-flight retry state, and is therefore stale-carryover,
104
+ * not an active ladder. 30 minutes comfortably exceeds any real single-turn
105
+ * Stop-hook retry cycle (seconds, not minutes) while staying well inside the
106
+ * fleet's other staleness bounds (e.g. the 3h boot-resume window).
107
+ */
108
+ export const SILENT_END_STALE_RECORD_MAX_AGE_MS = 30 * 60_000
109
+
83
110
  /**
84
111
  * User-facing fallback text delivered when a user-message turn ends with no
85
112
  * final answer AND the deterministic Stop-hook re-prompt has already been
@@ -313,12 +340,30 @@ export function recordSilentTurnEnd(
313
340
  // If so, the record is satisfied-but-misdetected — drop it silently
314
341
  // and let this dark turn start its OWN fresh retry cycle instead of
315
342
  // inheriting someone else's spent budget.
316
- if (deps?.hasOutboundDeliveredSince?.(args.chatId, prev.timestamp, args.threadId)) {
343
+ // Fix #8: age-based staleness bound. `turnKey` has no per-turn nonce
344
+ // (see SILENT_END_STALE_RECORD_MAX_AGE_MS doc), so a record whose
345
+ // `timestamp` is older than a turn's plausible lifetime cannot belong to
346
+ // THIS dark turn's own retry ladder even absent any delivery evidence —
347
+ // it's carryover from a prior turn whose gateway-side clear was
348
+ // bypassed (crash/interrupt). Check this before (and independent of)
349
+ // the delivery-based check below so a bypassed-clear record is caught
350
+ // even when no reply was ever delivered on the chat/thread at all.
351
+ const now = deps?.now?.() ?? Date.now()
352
+ const staleByAge = now - prev.timestamp > SILENT_END_STALE_RECORD_MAX_AGE_MS
353
+ if (
354
+ staleByAge ||
355
+ deps?.hasOutboundDeliveredSince?.(args.chatId, prev.timestamp, args.threadId)
356
+ ) {
317
357
  emitLog(
318
358
  deps,
319
- `silent-end: stale exhausted record for turnKey=${args.turnKey} ` +
320
- `(retryCount=${prev.retryCount}) but a reply was delivered since ` +
321
- `${prev.timestamp} treating as satisfied-but-misdetected, not exhausted\n`,
359
+ staleByAge
360
+ ? `silent-end: stale exhausted record for turnKey=${args.turnKey} ` +
361
+ `(retryCount=${prev.retryCount}, age=${now - prev.timestamp}ms > ` +
362
+ `${SILENT_END_STALE_RECORD_MAX_AGE_MS}ms) — treating as carryover ` +
363
+ `from a prior turn, not exhausted\n`
364
+ : `silent-end: stale exhausted record for turnKey=${args.turnKey} ` +
365
+ `(retryCount=${prev.retryCount}) but a reply was delivered since ` +
366
+ `${prev.timestamp} — treating as satisfied-but-misdetected, not exhausted\n`,
322
367
  )
323
368
  // MUST clear before writing (adversarial review of #2892):
324
369
  // writeSilentEndState re-inherits retryCount whenever the on-disk
@@ -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}`)
@@ -932,6 +1022,7 @@ export function readSubTail(
932
1022
  },
933
1023
  lastTool: entry.lastTool,
934
1024
  toolCount: entry.toolCount,
1025
+ model: entry.currentModel,
935
1026
  })
936
1027
  return true
937
1028
  } catch (cbErr) {
@@ -1015,6 +1106,31 @@ export function readSubTail(
1015
1106
  }
1016
1107
  log?.(`subagent-watcher: stall cleared for ${entry.agentId} (activity resumed after ${idleSecBeforeBump}s — re-arming detection)`)
1017
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
+ }
1018
1134
  if (ev.kind === 'sub_agent_tool_use') {
1019
1135
  // Narrative-dedup gate step 2: a sub_agent_text block was pending;
1020
1136
  // this tool is the lookahead that decides it (SHOW unless it drafts
@@ -1073,6 +1189,7 @@ export function readSubTail(
1073
1189
  lastTool: entry.lastTool,
1074
1190
  toolCount: entry.toolCount,
1075
1191
  progressLine: toolLine,
1192
+ model: entry.currentModel,
1076
1193
  })
1077
1194
  } catch (cbErr) {
1078
1195
  log?.(`subagent-watcher: onProgress (tool) callback error ${entry.agentId}: ${(cbErr as Error).message}`)
@@ -1233,6 +1350,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1233
1350
  config.inflightPromoteMaxAgeMs
1234
1351
  ?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_MAX_AGE_MS')
1235
1352
  ?? DEFAULT_INFLIGHT_PROMOTE_MAX_AGE_MS
1353
+ const terminatedAgentIdsCap = config.terminatedAgentIdsCap ?? TERMINATED_AGENT_IDS_CAP
1236
1354
  // Kill-switch: not parseEnvMs (which rejects `0`) — an explicit `=0`
1237
1355
  // here MUST disable promotion (revert to pre-v0.14.23 suppression).
1238
1356
  const bootPromoteEnabled =
@@ -1361,6 +1479,17 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1361
1479
  } catch (err) {
1362
1480
  log?.(`subagent-watcher: backfill error for ${agentId}: ${(err as Error).message}`)
1363
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 */ }
1364
1493
  }
1365
1494
 
1366
1495
  const tail: SubTail = {
@@ -1409,37 +1538,16 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1409
1538
  } else if (fileAgeMs > inflightPromoteMaxAgeMs) {
1410
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)`)
1411
1540
  } else {
1412
- entry.historical = false
1413
- log?.(`subagent-watcher: ${agentId} was in-flight at boot promoting to live (last write ${Math.round(fileAgeMs / 1000)}s ago; user still awaiting handback)`)
1414
- // The prior gateway life's registration normally linked
1415
- // jsonl_agent_id already, but re-run the backfill idempotently in
1416
- // case that life crashed before the link persisted the handback's
1417
- // isBackground lookup is keyed on jsonl_agent_id, and an unlinked row
1418
- // would mis-resolve the worker as foreground and drop the handback.
1419
- if (db != null) {
1420
- try {
1421
- backfillJsonlAgentId(db, filePath, agentId, log)
1422
- } catch (err) {
1423
- log?.(`subagent-watcher: backfill error for ${agentId}: ${(err as Error).message}`)
1424
- }
1425
- }
1426
- // Historical/onProgress gate-ordering fix: the initial read above ran
1427
- // while `entry.historical` was still TRUE, so every onProgress cue it
1428
- // would have fired was suppressed by the `!entry.historical` guard —
1429
- // and the tail cursor is now at EOF, so for a quiet worker no later
1430
- // tick ever re-fires them. The card painted as a bare stub and froze
1431
- // on "starting…" forever. Re-read from the start now that the entry
1432
- // is live: the replayed events rebuild toolCount/lastTool/narrative
1433
- // AND fire onProgress so the worker's card reaches real activity.
1434
- entry.toolCount = 0
1435
- entry.lastTool = null
1436
- entry.pendingNarrative = null
1437
- tail.cursor = 0
1438
- tail.pendingPartial = ''
1439
- tail.hasEmittedStart = false
1440
- readSubTail(entry, tail, n, (desc) => {
1441
- log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
1442
- }, 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)`)
1443
1551
  }
1444
1552
  }
1445
1553
 
@@ -1470,6 +1578,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1470
1578
  const entry = registry.get(agentId)
1471
1579
  const t = tails.get(agentId)
1472
1580
  if (!entry || !t) return
1581
+ checkBootPromotionGrowth(entry, t, nowFn())
1473
1582
  readSubTail(entry, t, nowFn(), (desc) => {
1474
1583
  log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
1475
1584
  }, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent, config.onProgress)
@@ -1480,6 +1589,68 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1480
1589
  }
1481
1590
  }
1482
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
+
1483
1654
  // ─── State-transition notifications ─────────────────────────────────────
1484
1655
 
1485
1656
  function maybySendStateTransition(agentId: string): void {
@@ -1522,6 +1693,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1522
1693
  resultText: entry.errored
1523
1694
  ? entry.lastResultText || entry.errorDetail || ''
1524
1695
  : entry.lastResultText,
1696
+ background: entry.background,
1525
1697
  })
1526
1698
  } catch (cbErr) {
1527
1699
  log?.(`subagent-watcher: onFinish callback error ${agentId}: ${(cbErr as Error).message}`)
@@ -1543,6 +1715,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1543
1715
  durationMs: nowFn() - entry.dispatchedAt,
1544
1716
  description: entry.description,
1545
1717
  resultText: entry.lastResultText,
1718
+ background: entry.background,
1546
1719
  })
1547
1720
  } catch (cbErr) {
1548
1721
  log?.(`subagent-watcher: onFinish callback error ${agentId}: ${(cbErr as Error).message}`)
@@ -1593,6 +1766,14 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1593
1766
  // Issue #1116 (Bug B): record that this agent has been fully
1594
1767
  // processed so a rescan that rediscovers the still-present JSONL
1595
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
+ }
1596
1777
  terminatedAgentIds.add(agentId)
1597
1778
  log?.(`subagent-watcher: cleaned up terminal agent ${agentId}`)
1598
1779
  }
@@ -1894,6 +2075,10 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1894
2075
  if (entry.state !== 'running') continue
1895
2076
  const tail = tails.get(agentId)
1896
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)
1897
2082
  readSubTail(entry, tail, n, (desc) => {
1898
2083
  log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
1899
2084
  }, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent, config.onProgress)