switchroom 0.16.46 → 0.17.0

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 (109) hide show
  1. package/dist/agent-scheduler/index.js +83 -81
  2. package/dist/auth-broker/index.js +104 -88
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -15
  5. package/dist/cli/notion-write-pretool.mjs +85 -83
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +1720 -1392
  8. package/dist/cli/ui/index.html +84 -12
  9. package/dist/host-control/main.js +209 -173
  10. package/dist/vault/approvals/kernel-server.js +86 -83
  11. package/dist/vault/broker/server.js +284 -139
  12. package/package.json +3 -3
  13. package/profiles/_base/cron-session.sh.hbs +1 -1
  14. package/profiles/_base/start.sh.hbs +54 -3
  15. package/skills/switchroom-architecture/telegram.md +8 -15
  16. package/skills/switchroom-cli/SKILL.md +4 -5
  17. package/skills/telegram-test-harness/SKILL.md +1 -1
  18. package/telegram-plugin/README.md +18 -29
  19. package/telegram-plugin/bridge/bridge.ts +1 -41
  20. package/telegram-plugin/bridge/tool-filter.ts +3 -4
  21. package/telegram-plugin/dist/bridge/bridge.js +120 -155
  22. package/telegram-plugin/dist/gateway/gateway.js +1127 -1029
  23. package/telegram-plugin/dist/server.js +168 -203
  24. package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
  25. package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
  26. package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
  27. package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
  28. package/telegram-plugin/gateway/gateway.ts +566 -631
  29. package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
  30. package/telegram-plugin/gateway/inbound-spool.ts +108 -10
  31. package/telegram-plugin/gateway/model-command.ts +51 -3
  32. package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
  33. package/telegram-plugin/gateway/represent-guard.ts +28 -11
  34. package/telegram-plugin/gateway/status-pin-store.ts +124 -45
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
  36. package/telegram-plugin/history.ts +5 -0
  37. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
  38. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
  39. package/telegram-plugin/registry/subagents-schema.ts +126 -1
  40. package/telegram-plugin/registry/turns-schema.ts +65 -1
  41. package/telegram-plugin/session-tail.ts +26 -4
  42. package/telegram-plugin/slot-banner-driver.ts +42 -2
  43. package/telegram-plugin/status-query-telemetry.ts +100 -0
  44. package/telegram-plugin/stream-reply-handler.ts +15 -16
  45. package/telegram-plugin/subagent-watcher.ts +182 -30
  46. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
  47. package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
  48. package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
  49. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
  50. package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
  51. package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
  52. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
  53. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
  54. package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
  55. package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
  56. package/telegram-plugin/tests/model-command.test.ts +54 -1
  57. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
  58. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
  59. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
  60. package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
  61. package/telegram-plugin/tests/registry-turns.test.ts +67 -0
  62. package/telegram-plugin/tests/represent-guard.test.ts +42 -6
  63. package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
  64. package/telegram-plugin/tests/session-tail.test.ts +10 -1
  65. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
  66. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
  67. package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
  68. package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
  69. package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
  70. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
  71. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
  72. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
  73. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
  75. package/telegram-plugin/tests/tool-filter.test.ts +1 -3
  76. package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
  77. package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
  78. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
  79. package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
  80. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
  81. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
  82. package/telegram-plugin/tool-activity-summary.ts +19 -0
  83. package/telegram-plugin/turn-flush-safety.ts +16 -1
  84. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
  85. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
  86. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
  87. package/telegram-plugin/worker-activity-feed.ts +75 -15
  88. package/vendor/hindsight-memory/CHANGELOG.md +24 -0
  89. package/vendor/hindsight-memory/README.md +5 -0
  90. package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
  91. package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
  92. package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
  93. package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
  94. package/vendor/hindsight-memory/scripts/recall.py +74 -1
  95. package/vendor/hindsight-memory/scripts/retain.py +8 -1
  96. package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
  97. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
  98. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
  99. package/vendor/hindsight-memory/settings.json +4 -0
  100. package/vendor/hindsight-memory/tests/test_client.py +130 -0
  101. package/vendor/hindsight-memory/tests/test_config.py +47 -0
  102. package/vendor/hindsight-memory/tests/test_content.py +18 -0
  103. package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
  104. package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
  105. package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
  106. package/telegram-plugin/silent-reply.ts +0 -58
  107. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
  108. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
  109. package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
@@ -222,6 +222,79 @@ export type StatusPinPersistOp =
222
222
  | { kind: 'pin'; messageId: number }
223
223
  | { kind: 'clear' }
224
224
 
225
+ /**
226
+ * Per-PATH async serial lock. status-pins.json has multiple concurrent writers
227
+ * (the fg:/wk: status-pin reconciles AND the banner:owner row), each of which
228
+ * is a read-modify-write against the shared file. Without serialisation two
229
+ * writers can interleave their load→persist and clobber each other's rows.
230
+ *
231
+ * `withStoreLock(path, fn)` chains `fn` after the current tail promise for that
232
+ * path and returns fn's result. The stored tail NEVER rejects (a failing op
233
+ * can't wedge the chain), and `fn` runs regardless of whether the prior op
234
+ * resolved or rejected.
235
+ */
236
+ const storeLockTails = new Map<string, Promise<unknown>>()
237
+
238
+ export function withStoreLock<T>(
239
+ path: string,
240
+ fn: () => Promise<T>,
241
+ ): Promise<T> {
242
+ const prev = storeLockTails.get(path) ?? Promise.resolve()
243
+ // Run fn after prev settles either way (fn ignores the settled value).
244
+ const run = prev.then(fn, fn)
245
+ // Keep a non-rejecting tail so one failed op can't poison every later one.
246
+ storeLockTails.set(
247
+ path,
248
+ run.then(
249
+ () => undefined,
250
+ () => undefined,
251
+ ),
252
+ )
253
+ return run
254
+ }
255
+
256
+ /**
257
+ * READ-MODIFY-WRITE for exactly ONE pinKey's row, against the authoritative
258
+ * on-disk file. Loads the current snapshot from disk, drops any row for
259
+ * `pinKey`, appends `row` (unless null = removal), and persists atomically.
260
+ *
261
+ * Crucially the OTHER rows come from disk, never from an in-memory map that may
262
+ * lag disk — so a writer can never drop another key's pending/confirmed row.
263
+ * NOT locked itself; callers run it inside `withStoreLock`.
264
+ */
265
+ function applyStatusPinRow(
266
+ path: string,
267
+ fs: StatusPinStoreFsSeam,
268
+ pinKey: string,
269
+ row: PersistedStatusPin | null,
270
+ log: (line: string) => void,
271
+ ): void {
272
+ const current = loadStatusPins(path, fs)
273
+ const others = current.filter((p) => p.pinKey !== pinKey)
274
+ const next = row == null ? others : [...others, row]
275
+ persistStatusPins(path, fs, next, log)
276
+ }
277
+
278
+ /**
279
+ * Public single-key mutation, serialised through the per-path lock. Upserts (or
280
+ * removes, when `row` is null) ONLY `pinKey`'s row, preserving every other row
281
+ * as it currently is ON DISK. This is the write path for pin kinds that don't
282
+ * need the pending→confirm dance (e.g. the banner:owner row); the status-pin
283
+ * reconcile uses `reconcileAndPersistStatusPin`, which holds the same lock
284
+ * across its whole pending→pin→confirm sequence.
285
+ */
286
+ export function mutateStatusPinRow(
287
+ path: string,
288
+ fs: StatusPinStoreFsSeam,
289
+ pinKey: string,
290
+ row: PersistedStatusPin | null,
291
+ log: (line: string) => void = (l) => process.stderr.write(l),
292
+ ): Promise<void> {
293
+ return withStoreLock(path, async () => {
294
+ applyStatusPinRow(path, fs, pinKey, row, log)
295
+ })
296
+ }
297
+
225
298
  /**
226
299
  * Persist-ordering wrapper closing the persist-AFTER-pin leak window.
227
300
  *
@@ -237,21 +310,26 @@ export type StatusPinPersistOp =
237
310
  * pending record on disk, and boot cleanup unpins pending records too (see
238
311
  * runStatusPinBootCleanup), so the orphan is recovered next boot.
239
312
  *
240
- * `snapshotOthers` returns the OTHER live claims (every key except `pinKey`) so
241
- * the whole set is rewritten atomically each step mirrors how the gateway
242
- * snapshots its Map. `applyPin` performs the real Telegram pin/unpin (the
243
- * gateway binds `reconcilePin`) and returns the next in-memory state so the
244
- * caller can update its Map. Never throws persistence is best-effort/fail-open
245
- * and pin errors are already swallowed by `applyPin` (reconcilePin).
313
+ * CONCURRENCY: the ENTIRE op (pending write await applyPin → confirm/clear
314
+ * write) runs under the per-path `withStoreLock`, so no other writer can
315
+ * interleave during the applyPin await window. Each disk write is a
316
+ * read-modify-write (`applyStatusPinRow`) keyed on THIS pinKey against the
317
+ * on-disk file the other keys' rows (including a banner row, or another
318
+ * concurrent key's pending row) come from disk and are always preserved. This
319
+ * closes the race where a snapshot rebuilt from the (lagging) in-memory map
320
+ * dropped an in-flight key's row.
321
+ *
322
+ * `applyPin` performs the real Telegram pin/unpin (the gateway binds
323
+ * `reconcilePin`) and returns the next in-memory state so the caller can update
324
+ * its Map. Never throws — persistence is best-effort/fail-open and pin errors
325
+ * are already swallowed by `applyPin` (reconcilePin).
246
326
  */
247
- export async function reconcileAndPersistStatusPin(args: {
327
+ export function reconcileAndPersistStatusPin(args: {
248
328
  path: string
249
329
  fs: StatusPinStoreFsSeam
250
330
  pinKey: string
251
331
  chatId: string
252
332
  op: StatusPinPersistOp
253
- /** Live claims for every OTHER key (rewritten alongside on each step). */
254
- snapshotOthers: () => PersistedStatusPin[]
255
333
  /** Execute the real pin/unpin; returns the confirmed message id (pin) or
256
334
  * null (cleared). Must never throw — API errors are swallowed inside. */
257
335
  applyPin: () => Promise<{ messageId: number } | null>
@@ -260,43 +338,44 @@ export async function reconcileAndPersistStatusPin(args: {
260
338
  const { path, fs, pinKey, chatId, op } = args
261
339
  const log = args.log ?? ((l: string) => process.stderr.write(l))
262
340
 
263
- if (op.kind === 'pin') {
264
- // Persist INTENT first, marked pending BEFORE the pin API call. If we
265
- // crash after the pin lands but before the confirm rewrite, this pending
266
- // record is what boot cleanup uses to unpin the orphan.
267
- persistStatusPins(
268
- path,
269
- fs,
270
- [
271
- ...args.snapshotOthers(),
341
+ // Hold the per-path lock across the WHOLE op so no other writer (a banner
342
+ // persist, or another key's reconcile) can rebuild/overwrite the file during
343
+ // the applyPin await window and drop this key's pending/confirmed row.
344
+ return withStoreLock(path, async () => {
345
+ if (op.kind === 'pin') {
346
+ // Persist INTENT first, marked pending — BEFORE the pin API call. If we
347
+ // crash after the pin lands but before the confirm rewrite, this pending
348
+ // record is what boot cleanup uses to unpin the orphan.
349
+ applyStatusPinRow(
350
+ path,
351
+ fs,
352
+ pinKey,
272
353
  { pinKey, chatId, messageId: op.messageId, pending: true },
273
- ],
274
- log,
275
- )
276
- const next = await args.applyPin()
277
- if (next == null) {
278
- // Pin failed (claim NOT taken by reconcilePin). Clear the pending record
279
- // so we don't leave a phantom claim for a pin that never landed.
280
- persistStatusPins(path, fs, args.snapshotOthers(), log)
281
- return null
282
- }
283
- // Pin confirmed — rewrite the record without the pending flag.
284
- persistStatusPins(
285
- path,
286
- fs,
287
- [
288
- ...args.snapshotOthers(),
354
+ log,
355
+ )
356
+ const next = await args.applyPin()
357
+ if (next == null) {
358
+ // Pin failed (claim NOT taken by reconcilePin). Clear the pending
359
+ // record so we don't leave a phantom claim for a pin that never landed.
360
+ applyStatusPinRow(path, fs, pinKey, null, log)
361
+ return null
362
+ }
363
+ // Pin confirmed — rewrite the record without the pending flag.
364
+ applyStatusPinRow(
365
+ path,
366
+ fs,
367
+ pinKey,
289
368
  { pinKey, chatId, messageId: next.messageId },
290
- ],
291
- log,
292
- )
293
- return next
294
- }
369
+ log,
370
+ )
371
+ return next
372
+ }
295
373
 
296
- // clear: unpin (best-effort) THEN drop the record. Ordering is safe here —
297
- // if we crash after the unpin but before the rewrite, the stale record just
298
- // gets unpinned again next boot (idempotent), never a lingering pin.
299
- const next = await args.applyPin()
300
- persistStatusPins(path, fs, args.snapshotOthers(), log)
301
- return next
374
+ // clear: unpin (best-effort) THEN drop the record. Ordering is safe here —
375
+ // if we crash after the unpin but before the rewrite, the stale record just
376
+ // gets unpinned again next boot (idempotent), never a lingering pin.
377
+ const next = await args.applyPin()
378
+ applyStatusPinRow(path, fs, pinKey, null, log)
379
+ return next
380
+ })
302
381
  }
@@ -8,6 +8,23 @@ export interface WorkerFeedDispatch {
8
8
  * ("🛠 Worker · <feedDescription>").
9
9
  */
10
10
  feedDescription: string
11
+ /**
12
+ * True when a registry row was found for the worker. A MISSING row must
13
+ * not be silently read as "foreground" by callers deciding the status
14
+ * surface — a row-less worker is most often a nested (depth-2+) dispatch
15
+ * whose row hasn't linked yet, and nesting it into an unrelated live main
16
+ * turn (or dropping it) is exactly the depth-2+ freeze/misroute bug.
17
+ */
18
+ hasRow: boolean
19
+ /**
20
+ * True when the row records a NESTED dispatch (spawned by another
21
+ * sub-agent — `parent_agent_id` set). A nested worker can never nest into
22
+ * the gateway's current turn (its parent is a worker, not a live turn),
23
+ * so callers must surface it via the worker feed regardless of its own
24
+ * background flag, and must not deliver a user handback for it (its
25
+ * result returns to its dispatching worker as the Task tool result).
26
+ */
27
+ isNested: boolean
11
28
  }
12
29
 
13
30
  /**
@@ -33,5 +50,7 @@ export function resolveWorkerFeedDispatch(
33
50
  return {
34
51
  isBackground: sub?.background ?? false,
35
52
  feedDescription: (sub?.description ?? '') || watcherDescription,
53
+ hasRow: sub != null,
54
+ isNested: sub?.parent_agent_id != null,
36
55
  }
37
56
  }
@@ -140,6 +140,11 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
140
140
  // and survives crashes more cleanly than rollback journal.
141
141
  db.exec('PRAGMA journal_mode = WAL')
142
142
  db.exec('PRAGMA synchronous = NORMAL')
143
+ // Without a busy_timeout, bun:sqlite defaults to 0ms and a contending
144
+ // writer fails IMMEDIATELY with SQLITE_BUSY. History rows feed owed-reply
145
+ // logic (a dropped write can lose an owed reply), so wait-and-retry rather
146
+ // than drop. Mirrors the registry DB's pattern (turns-schema.ts).
147
+ db.exec('PRAGMA busy_timeout = 5000')
143
148
  db.exec(`
144
149
  CREATE TABLE IF NOT EXISTS messages (
145
150
  chat_id TEXT NOT NULL,
@@ -193,8 +193,7 @@ function main() {
193
193
  'This turn is ending without your final answer reaching the user. ' +
194
194
  'If you wrote an answer as plain text (not via a tool), the user ' +
195
195
  'cannot see it — only text sent through the reply tool is delivered. ' +
196
- 'Send your final answer now by calling mcp__switchroom-telegram__reply ' +
197
- '(or mcp__switchroom-telegram__stream_reply with done=true). ' +
196
+ 'Send your final answer now by calling mcp__switchroom-telegram__reply. ' +
198
197
  'If your final answer has already reached the user, or you ' +
199
198
  'intentionally have nothing to add, reply with exactly NO_REPLY.',
200
199
  }),
@@ -54,7 +54,8 @@ const SCHEMA_SQL = `
54
54
  ended_at INTEGER,
55
55
  status TEXT NOT NULL,
56
56
  result_summary TEXT,
57
- jsonl_agent_id TEXT
57
+ jsonl_agent_id TEXT,
58
+ parent_agent_id TEXT
58
59
  );
59
60
  CREATE INDEX IF NOT EXISTS subagents_turn ON subagents(parent_turn_key);
60
61
  CREATE INDEX IF NOT EXISTS subagents_status ON subagents(status);
@@ -199,6 +200,13 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
199
200
  db.exec('ALTER TABLE subagents ADD COLUMN jsonl_agent_id TEXT')
200
201
  db.exec('CREATE INDEX IF NOT EXISTS subagents_jsonl_id ON subagents(jsonl_agent_id)')
201
202
  }
203
+ // Migrate older DBs that pre-date parent_agent_id (nested-worker keying).
204
+ const hasParentAgentCol = db
205
+ .prepare("SELECT name FROM pragma_table_info('subagents') WHERE name = 'parent_agent_id'")
206
+ .get()
207
+ if (hasParentAgentCol == null) {
208
+ db.exec('ALTER TABLE subagents ADD COLUMN parent_agent_id TEXT')
209
+ }
202
210
  // Verify the marker-derived parent_turn_key (snapParams[2]) actually has
203
211
  // a row in the turns table before trusting it. The gateway writes the
204
212
  // turn-active marker even when recordTurnStart's INSERT failed (the two
@@ -104,6 +104,18 @@ export interface Subagent {
104
104
  result_summary: string | null
105
105
  /** JSONL filename stem (e.g. "a37ad7639ae61476c") for watcher ID linkage. */
106
106
  jsonl_agent_id: string | null
107
+ /**
108
+ * JSONL stem of the DISPATCHING sub-agent, for a NESTED (depth-2+) worker
109
+ * — one spawned by another sub-agent rather than by the main session.
110
+ * NULL for a main-session dispatch. Written by the watcher when it
111
+ * observes an Agent/Task tool_use inside a worker's own JSONL
112
+ * (recordNestedSubagentDispatch). Enables transitive origin-chat
113
+ * resolution: a nested worker whose parent_turn_key can never be stamped
114
+ * (its dispatching context is a background worker that outlives the main
115
+ * turn, so turn-active.json is gone) inherits routing from its ancestor
116
+ * chain instead.
117
+ */
118
+ parent_agent_id: string | null
107
119
  }
108
120
 
109
121
  export interface RecordSubagentStartArgs {
@@ -194,7 +206,8 @@ const SUBAGENTS_SCHEMA_SQL = `
194
206
  ended_at INTEGER,
195
207
  status TEXT NOT NULL,
196
208
  result_summary TEXT,
197
- jsonl_agent_id TEXT
209
+ jsonl_agent_id TEXT,
210
+ parent_agent_id TEXT
198
211
  );
199
212
  CREATE INDEX IF NOT EXISTS subagents_turn ON subagents(parent_turn_key);
200
213
  CREATE INDEX IF NOT EXISTS subagents_status ON subagents(status);
@@ -221,6 +234,12 @@ export function applySubagentsSchema(db: SqliteDatabase): void {
221
234
  if (!hasJsonlId) {
222
235
  db.exec('ALTER TABLE subagents ADD COLUMN jsonl_agent_id TEXT')
223
236
  }
237
+ // Idempotent migration for DBs created before parent_agent_id existed
238
+ // (nested-worker keying — see the Subagent.parent_agent_id doc).
239
+ const hasParentAgentId = cols.some((c) => c.name === 'parent_agent_id')
240
+ if (!hasParentAgentId) {
241
+ db.exec('ALTER TABLE subagents ADD COLUMN parent_agent_id TEXT')
242
+ }
224
243
  // Always (re-)apply the index. `IF NOT EXISTS` makes this a no-op when it
225
244
  // already exists. Splitting it from SUBAGENTS_SCHEMA_SQL is what fixes the
226
245
  // pre-existing-table failure mode — by the time we reach this line, the
@@ -296,6 +315,7 @@ interface RawSubagentRow {
296
315
  status: string
297
316
  result_summary: string | null
298
317
  jsonl_agent_id: string | null
318
+ parent_agent_id?: string | null
299
319
  }
300
320
 
301
321
  function mapSubagentRow(row: RawSubagentRow): Subagent {
@@ -312,6 +332,7 @@ function mapSubagentRow(row: RawSubagentRow): Subagent {
312
332
  status: row.status as SubagentStatus,
313
333
  result_summary: row.result_summary,
314
334
  jsonl_agent_id: row.jsonl_agent_id,
335
+ parent_agent_id: row.parent_agent_id ?? null,
315
336
  }
316
337
  }
317
338
 
@@ -579,3 +600,107 @@ export function getSubagent(db: SqliteDatabase, id: string): Subagent | null {
579
600
  | undefined
580
601
  return row ? mapSubagentRow(row) : null
581
602
  }
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // Nested (depth-2+) worker keying — the unified progress-card fix
606
+ // ---------------------------------------------------------------------------
607
+
608
+ export interface RecordNestedSubagentDispatchArgs {
609
+ /** tool_use id of the nested Agent/Task dispatch — the subagents PK the
610
+ * child's meta.json `toolUseId` will later link against. */
611
+ toolUseId: string
612
+ /** JSONL stem of the DISPATCHING worker (the parent sub-agent). */
613
+ parentJsonlAgentId: string
614
+ agentType?: string | null
615
+ description?: string | null
616
+ /** `run_in_background` from the nested dispatch's tool_input. */
617
+ background: boolean
618
+ now: number
619
+ }
620
+
621
+ /**
622
+ * Record a NESTED sub-agent dispatch observed by the watcher in a worker's
623
+ * own JSONL (a `sub_agent_tool_use` whose tool is Agent/Task).
624
+ *
625
+ * Why this exists: the PreToolUse tracker hook derives `parent_turn_key`
626
+ * from the gateway's `turn-active.json` marker, which only exists during
627
+ * the MAIN turn. A nested worker is dispatched by a background depth-1
628
+ * worker that outlives that turn, so the marker is gone → the hook stamps
629
+ * NULL (or, under concurrent dispatch, the hook's write can be lost
630
+ * entirely). The row is then missing/unattributed: origin-chat resolution
631
+ * returns null (card misroutes to the owner DM), `resolveWorkerFeedDispatch`
632
+ * defaults the worker to foreground, and the card freezes on "starting…"
633
+ * forever. The watcher, however, tails the parent worker's JSONL and SEES
634
+ * the nested dispatch — so it is the reliable recorder for depth-2+.
635
+ *
636
+ * Behaviour (idempotent, safe to call on every observation):
637
+ * - INSERT OR IGNORE a row keyed on the dispatch tool_use_id (harmless
638
+ * no-op when the pretool hook's row already landed).
639
+ * - Stamp `parent_agent_id` (the dispatching worker's JSONL stem) when
640
+ * not already set.
641
+ * - Inherit `parent_turn_key` transitively from the parent worker's row
642
+ * when NULL — the parent's row was stamped at ITS dispatch (main turn
643
+ * still active), so the chain bottoms out at a real turn key.
644
+ */
645
+ export function recordNestedSubagentDispatch(
646
+ db: SqliteDatabase,
647
+ args: RecordNestedSubagentDispatchArgs,
648
+ ): void {
649
+ db.prepare(`
650
+ INSERT OR IGNORE INTO subagents
651
+ (id, parent_session_id, parent_turn_key, agent_type, description,
652
+ background, started_at, last_activity_at, status, jsonl_agent_id,
653
+ parent_agent_id)
654
+ VALUES (?, NULL, NULL, ?, ?, ?, ?, ?, 'running', NULL, ?)
655
+ `).run(
656
+ args.toolUseId,
657
+ args.agentType ?? null,
658
+ args.description ?? null,
659
+ args.background ? 1 : 0,
660
+ args.now,
661
+ args.now,
662
+ args.parentJsonlAgentId,
663
+ )
664
+ // Repair path for a hook-inserted row: stamp nested parentage and inherit
665
+ // the origin turn key from the parent worker's row when missing. COALESCE
666
+ // keeps any value already present (hook-stamped or previously inherited).
667
+ db.prepare(`
668
+ UPDATE subagents
669
+ SET parent_agent_id = COALESCE(parent_agent_id, ?),
670
+ parent_turn_key = COALESCE(
671
+ parent_turn_key,
672
+ (SELECT p.parent_turn_key FROM subagents p
673
+ WHERE p.jsonl_agent_id = ? LIMIT 1)
674
+ )
675
+ WHERE id = ?
676
+ `).run(args.parentJsonlAgentId, args.parentJsonlAgentId, args.toolUseId)
677
+ }
678
+
679
+ /**
680
+ * Resolve the ORIGIN turn key for a worker, walking the nested-parent chain.
681
+ *
682
+ * Depth-1: the row's own `parent_turn_key`. Depth-2+: when NULL, follow
683
+ * `parent_agent_id` (the dispatching worker's JSONL stem) up the chain until
684
+ * a row with a non-NULL `parent_turn_key` is found. Bounded (default 5 hops)
685
+ * and cycle-safe. Returns null when nothing in the chain is attributed —
686
+ * callers keep their existing DM fallback.
687
+ */
688
+ export function resolveSubagentOriginTurnKey(
689
+ db: SqliteDatabase,
690
+ jsonlAgentId: string,
691
+ maxHops = 5,
692
+ ): string | null {
693
+ const seen = new Set<string>()
694
+ let currentJsonlId: string | null = jsonlAgentId
695
+ for (let hop = 0; hop <= maxHops && currentJsonlId != null; hop++) {
696
+ if (seen.has(currentJsonlId)) return null
697
+ seen.add(currentJsonlId)
698
+ const row = db
699
+ .prepare('SELECT parent_turn_key, parent_agent_id FROM subagents WHERE jsonl_agent_id = ? LIMIT 1')
700
+ .get(currentJsonlId) as { parent_turn_key: string | null; parent_agent_id: string | null } | undefined
701
+ if (row == null) return null
702
+ if (row.parent_turn_key != null && row.parent_turn_key.length > 0) return row.parent_turn_key
703
+ currentJsonlId = row.parent_agent_id ?? null
704
+ }
705
+ return null
706
+ }
@@ -108,6 +108,18 @@ export interface Turn {
108
108
  * cleanly-restarted (`'restart'`) orphans.
109
109
  */
110
110
  interrupt_reason: string | null
111
+ /**
112
+ * Ms epoch at which a boot-resume/report inbound was durably queued for
113
+ * this interrupted turn (see `markTurnResumed`). It is the at-most-once
114
+ * ledger for resume: `findLatestTurnIfInterrupted` treats a turn with a
115
+ * non-null `resumed_at` as already handled and never re-fires it. This
116
+ * distinguishes "we already committed a resume for this turn (side
117
+ * effects may have run)" from "work incomplete, never resumed" — closing
118
+ * the accept-vs-consume double-execution window (#2793 part A) where the
119
+ * "latest turn not clean" proxy re-minted a fresh resume on every restart.
120
+ * Null for turns that were never resumed.
121
+ */
122
+ resumed_at: number | null
111
123
  created_at: number
112
124
  updated_at: number
113
125
  }
@@ -148,6 +160,7 @@ const SCHEMA_SQL = `
148
160
  assistant_reply_preview TEXT,
149
161
  tool_call_count INTEGER,
150
162
  interrupt_reason TEXT,
163
+ resumed_at INTEGER,
151
164
  created_at INTEGER NOT NULL,
152
165
  updated_at INTEGER NOT NULL
153
166
  );
@@ -169,6 +182,14 @@ const PHASE2_MIGRATIONS = [
169
182
  `ALTER TABLE turns ADD COLUMN interrupt_reason TEXT`,
170
183
  ]
171
184
 
185
+ // Column added for at-most-once resume (#2793 part A). Stamped when the
186
+ // gateway durably queues a boot-resume inbound for an interrupted turn, so a
187
+ // subsequent restart cannot re-mint a fresh resume for the same turn (which
188
+ // would re-run side effects that already executed before the crash).
189
+ const PHASE3_MIGRATIONS = [
190
+ `ALTER TABLE turns ADD COLUMN resumed_at INTEGER`,
191
+ ]
192
+
172
193
  function applySchema(db: SqliteDatabase): void {
173
194
  db.exec('PRAGMA journal_mode = WAL')
174
195
  db.exec('PRAGMA synchronous = NORMAL')
@@ -185,7 +206,7 @@ function applySchema(db: SqliteDatabase): void {
185
206
  // Run migrations. SQLite doesn't support "ADD COLUMN IF NOT EXISTS", so
186
207
  // we swallow the "duplicate column" error to stay idempotent on
187
208
  // pre-existing registry.db files.
188
- for (const sql of [...PHASE1_MIGRATIONS, ...PHASE2_MIGRATIONS]) {
209
+ for (const sql of [...PHASE1_MIGRATIONS, ...PHASE2_MIGRATIONS, ...PHASE3_MIGRATIONS]) {
189
210
  try {
190
211
  db.exec(sql)
191
212
  } catch (err) {
@@ -261,6 +282,7 @@ interface RawTurnRow {
261
282
  assistant_reply_preview: string | null
262
283
  tool_call_count: number | null
263
284
  interrupt_reason: string | null
285
+ resumed_at: number | null
264
286
  created_at: number
265
287
  updated_at: number
266
288
  }
@@ -281,6 +303,7 @@ function mapRow(row: RawTurnRow): Turn {
281
303
  assistant_reply_preview: row.assistant_reply_preview,
282
304
  tool_call_count: row.tool_call_count,
283
305
  interrupt_reason: row.interrupt_reason,
306
+ resumed_at: row.resumed_at,
284
307
  created_at: row.created_at,
285
308
  updated_at: row.updated_at,
286
309
  }
@@ -559,6 +582,38 @@ const INTERRUPTED_VIA: ReadonlySet<TurnEndedVia> = new Set<TurnEndedVia>([
559
582
  'unknown',
560
583
  ])
561
584
 
585
+ /**
586
+ * Stamp `resumed_at` on an interrupted turn at the moment the gateway has
587
+ * DURABLY committed to resuming it (the boot-resume inbound is on the
588
+ * inbound spool / in-memory buffer). This is the at-most-once ledger for
589
+ * resume: once stamped, `findLatestTurnIfInterrupted` no longer returns the
590
+ * turn, so a subsequent restart cannot mint a SECOND, distinct resume for
591
+ * the same turn and re-run side effects that already executed before the
592
+ * crash (#2793 part A).
593
+ *
594
+ * Ordering matters: the caller must stamp only AFTER the resume inbound is
595
+ * durably spooled (synchronously, before any async delivery can ack it), so
596
+ * a crash between "decide to resume" and "persist the resume" can never mark
597
+ * a turn resumed that was never actually spooled. The spool's own
598
+ * at-least-once redelivery (keyed on `resume_turn_key`) still guarantees the
599
+ * one committed resume is delivered; this ledger only prevents re-minting.
600
+ *
601
+ * Idempotent and first-write-wins: an already-stamped turn keeps its
602
+ * original `resumed_at`. No-ops if `turnKey` is not found.
603
+ */
604
+ export function markTurnResumed(
605
+ db: SqliteDatabase,
606
+ turnKey: string,
607
+ now: number = Date.now(),
608
+ ): void {
609
+ db.prepare(`
610
+ UPDATE turns
611
+ SET resumed_at = ?,
612
+ updated_at = ?
613
+ WHERE turn_key = ? AND resumed_at IS NULL
614
+ `).run(now, now, turnKey)
615
+ }
616
+
562
617
  /**
563
618
  * Return the single most-recently-started turn IFF it was interrupted
564
619
  * (`ended_at IS NULL`, or `ended_via` in {restart, sigterm, timeout,
@@ -576,6 +631,14 @@ const INTERRUPTED_VIA: ReadonlySet<TurnEndedVia> = new Set<TurnEndedVia>([
576
631
  * Ordering uses `started_at DESC` (not `updated_at`) so the boot reaper,
577
632
  * which mass-stamps orphans with identical timestamps, can't reorder the
578
633
  * temporal "last turn" the user actually remembers.
634
+ *
635
+ * At-most-once (#2793 part A): a turn that already carries `resumed_at` was
636
+ * committed to a resume on an earlier boot. Even if it never reached a clean
637
+ * `'stop'` (its resume ran side effects but the process died before the
638
+ * follow-up turn wrote `ended_at`, or the resume inbound was accepted but
639
+ * never consumed), it is NOT re-fired — the "latest turn not clean" proxy
640
+ * would otherwise re-mint a fresh resume on every restart and double-execute
641
+ * side effects. Resume is at-most-once per turn.
579
642
  */
580
643
  export function findLatestTurnIfInterrupted(db: SqliteDatabase): Turn | null {
581
644
  const row = db.prepare(`
@@ -585,6 +648,7 @@ export function findLatestTurnIfInterrupted(db: SqliteDatabase): Turn | null {
585
648
  `).get() as RawTurnRow | undefined
586
649
  if (!row) return null
587
650
  const turn = mapRow(row)
651
+ if (turn.resumed_at != null) return null
588
652
  if (turn.ended_at == null) return turn
589
653
  if (turn.ended_via != null && INTERRUPTED_VIA.has(turn.ended_via)) return turn
590
654
  return null
@@ -124,7 +124,19 @@ export type SessionEvent =
124
124
  | { kind: 'sub_agent_text'; agentId: string; text: string; blockIndex: number; lastInMessage: boolean }
125
125
  | { kind: 'sub_agent_tool_result'; agentId: string; toolUseId: string; isError?: boolean; errorText?: string }
126
126
  | { kind: 'sub_agent_turn_end'; agentId: string }
127
- | { kind: 'sub_agent_nested_spawn'; agentId: string }
127
+ | {
128
+ kind: 'sub_agent_nested_spawn'
129
+ agentId: string
130
+ /** tool_use id of the nested Agent/Task dispatch — the `subagents`
131
+ * registry PK the child's meta.json `toolUseId` links against. Lets
132
+ * the watcher record/repair the nested worker's registry row
133
+ * (recordNestedSubagentDispatch — the depth-2+ keying fix). */
134
+ toolUseId?: string | null
135
+ /** The dispatch tool_input (description / subagent_type /
136
+ * run_in_background) — registry metadata only, never rendered
137
+ * (design §5.5's "no recursion in rendering" rule is unchanged). */
138
+ input?: Record<string, unknown>
139
+ }
128
140
  /**
129
141
  * Emitted when a sub-agent JSONL has >= CAP_TOOL_USE_THRESHOLD tool_use
130
142
  * records but no terminal record (no `type:result`, `subtype:end`, or
@@ -146,9 +158,14 @@ function parseChannelMeta(content: string): {
146
158
  messageId: string | null
147
159
  threadId: string | null
148
160
  } {
149
- // Look for `chat_id="..."` etc in the channel XML tag
161
+ // Look for `chat_id="..."` etc in the channel XML tag. LEFT-ANCHOR on an
162
+ // attribute boundary (start-of-string or a whitespace/quote before the name)
163
+ // so `message_id` matches ONLY the real attribute — never a same-suffix
164
+ // sibling like `target_message_id`, `reply_to_message_id`, or
165
+ // `original_message_id`. Without the boundary, grab('message_id') would
166
+ // match the FIRST `*_message_id` substring, mis-attributing the enqueue's id.
150
167
  const grab = (key: string): string | null => {
151
- const m = content.match(new RegExp(`${key}="([^"]+)"`))
168
+ const m = content.match(new RegExp(`(?:^|[\\s"'])${key}="([^"]+)"`))
152
169
  return m ? m[1] : null
153
170
  }
154
171
  return {
@@ -480,7 +497,12 @@ export function projectSubagentLine(
480
497
  // that would surface the sub-sub-agent's description and break
481
498
  // the "no recursion in rendering" rule.
482
499
  if (name === 'Agent' || name === 'Task') {
483
- events.push({ kind: 'sub_agent_nested_spawn', agentId })
500
+ events.push({
501
+ kind: 'sub_agent_nested_spawn',
502
+ agentId,
503
+ toolUseId: (c.id as string | undefined) ?? null,
504
+ input: (c.input as Record<string, unknown> | undefined) ?? undefined,
505
+ })
484
506
  } else {
485
507
  events.push({
486
508
  kind: 'sub_agent_tool_use',