switchroom 0.19.19 → 0.19.22

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 (53) hide show
  1. package/dist/auth-broker/index.js +53 -0
  2. package/dist/cli/switchroom.js +2444 -1264
  3. package/dist/host-control/main.js +54 -1
  4. package/dist/vault/approvals/kernel-server.js +53 -0
  5. package/dist/vault/broker/server.js +53 -0
  6. package/package.json +4 -2
  7. package/skills/switchroom-release/SKILL.md +103 -20
  8. package/telegram-plugin/card-format.ts +92 -3
  9. package/telegram-plugin/dist/gateway/gateway.js +769 -172
  10. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  11. package/telegram-plugin/format.ts +19 -7
  12. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  13. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  14. package/telegram-plugin/gateway/gateway.ts +66 -56
  15. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  16. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  17. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  18. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  19. package/telegram-plugin/retry-api-call.ts +15 -2
  20. package/telegram-plugin/send-gate.ts +1 -1
  21. package/telegram-plugin/status-no-truncate.ts +64 -1
  22. package/telegram-plugin/status-pin-driver.ts +50 -27
  23. package/telegram-plugin/status-pin.ts +43 -5
  24. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  25. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  26. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  27. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  28. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  29. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  30. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  31. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  32. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  33. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  34. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  35. package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
  36. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  37. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  38. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  39. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  40. package/telegram-plugin/tool-activity-summary.ts +85 -13
  41. package/telegram-plugin/worker-activity-feed.ts +5 -1
  42. package/vendor/hindsight-memory/scripts/drain_pending.py +193 -25
  43. package/vendor/hindsight-memory/scripts/lib/pending.py +84 -5
  44. package/vendor/hindsight-memory/scripts/lib/retain_split.py +21 -10
  45. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  46. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +158 -4
  47. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  48. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  49. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  51. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  52. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +19 -11
  53. package/vendor/hindsight-memory/tests/test_drain_pending.py +28 -2
@@ -127,21 +127,62 @@ function spawnSqlRead(dbPath, sql, cb) {
127
127
  // Status detection
128
128
  // ---------------------------------------------------------------------------
129
129
 
130
+ /**
131
+ * Bare-string dispatch failure. When the Agent/Task dispatch never produced a
132
+ * worker at all, Claude Code hands the hook a BARE STRING and there is no
133
+ * `is_error` flag to read — the whole tool_response IS the message. Every
134
+ * bare-string result observed across the fleet (54 of 2692 Agent/Task
135
+ * dispatches scanned in every agent's claude-code transcript dir) is one of:
136
+ *
137
+ * 52x "Error: Cannot create agent worktree: not in a git repository…"
138
+ * 1x "Error: Agent type 'code-reviewer' not found. Available agents: …"
139
+ * 1x "InputValidationError: [\n { \"expected\": \"string\", … } ]"
140
+ *
141
+ * so the probe must match a `<Prefix>Error` head, not just a literal `Error:`
142
+ * — the plain-`Error:` form the pre-review patch used recorded that
143
+ * InputValidationError dispatch as a SUCCESSFUL sub-agent completion, with the
144
+ * validation error stored as its result_summary. Nothing else terminalizes
145
+ * those rows (no worker exists, so the watcher never sees one), so getting the
146
+ * status right here is the only chance.
147
+ */
148
+ const BARE_DISPATCH_ERROR_RE = /^\s*[A-Za-z]*Error\b/
149
+
150
+ /**
151
+ * Terminal status for a response already classified as KIND_COMPLETION.
152
+ *
153
+ * Signals, in precedence order:
154
+ * 1. `is_error` / `error` — the structured failure flags.
155
+ * 2. `status` on a structured Agent envelope (see structuredTerminalStatus).
156
+ * Machine-readable: `completed` is a success, anything else in the
157
+ * terminal set is a failure.
158
+ * 3. A bare-string dispatch failure (BARE_DISPATCH_ERROR_RE).
159
+ *
160
+ * The bare-string probe is deliberately scoped to `typeof toolResponse ===
161
+ * 'string'` rather than run over toolResponseText(): a worker's own report
162
+ * (a `content` array) that merely OPENS with "Error: I could not reproduce…"
163
+ * is a report ABOUT an error, not a failed dispatch, and must stay
164
+ * `completed`. The pre-review patch ran the probe over the flattened text and
165
+ * mislabelled exactly that case.
166
+ */
130
167
  function detectStatus(toolResponse) {
131
168
  if (!toolResponse) return 'completed'
132
169
  if (toolResponse.is_error === true) return 'failed'
133
170
  if (toolResponse.error != null) return 'failed'
134
- // Claude Code wraps sub-agent output in { type: 'text', text: '...' } arrays;
135
- // a top-level "error" key or is_error flag means the tool itself failed.
171
+ const structured = structuredTerminalStatus(toolResponse)
172
+ if (structured != null) return structured === 'completed' ? 'completed' : 'failed'
173
+ if (typeof toolResponse === 'string' && BARE_DISPATCH_ERROR_RE.test(toolResponse)) return 'failed'
136
174
  return 'completed'
137
175
  }
138
176
 
139
177
  function extractResultSummary(toolResponse) {
140
178
  if (!toolResponse) return null
141
- // Claude Code's Agent tool wraps text in `content: [{ type: 'text', text }]`.
142
- // Try that first since it's the actual production shape.
143
- if (Array.isArray(toolResponse.content)) {
144
- const textPart = toolResponse.content.find(
179
+ // Claude Code's Agent tool wraps text in `content: [{ type: 'text', text }]`,
180
+ // and some versions hand the hook that content array unwrapped.
181
+ const blocks = Array.isArray(toolResponse)
182
+ ? toolResponse
183
+ : (Array.isArray(toolResponse.content) ? toolResponse.content : null)
184
+ if (blocks != null) {
185
+ const textPart = blocks.find(
145
186
  (c) => c && typeof c === 'object' && c.type === 'text' && typeof c.text === 'string',
146
187
  )
147
188
  if (textPart) return textPart.text.slice(0, 200) || null
@@ -163,20 +204,103 @@ function extractResultSummary(toolResponse) {
163
204
  */
164
205
  function toolResponseText(toolResponse) {
165
206
  if (!toolResponse) return ''
166
- if (Array.isArray(toolResponse.content)) {
167
- return toolResponse.content
207
+ if (typeof toolResponse === 'string') return toolResponse
208
+ const blocks = Array.isArray(toolResponse)
209
+ ? toolResponse
210
+ : (Array.isArray(toolResponse.content) ? toolResponse.content : null)
211
+ if (blocks != null) {
212
+ return blocks
168
213
  .filter((c) => c && typeof c === 'object' && c.type === 'text' && typeof c.text === 'string')
169
214
  .map((c) => c.text)
170
215
  .join('\n')
171
216
  }
172
217
  if (typeof toolResponse.result === 'string') return toolResponse.result
173
218
  if (typeof toolResponse.output === 'string') return toolResponse.output
174
- if (typeof toolResponse === 'string') return toolResponse
175
219
  return ''
176
220
  }
177
221
 
178
222
  /**
179
- * Detect Claude Code's async-launch ACK in a PostToolUse tool_response.
223
+ * DETERMINISTIC async-launch signal (#3667).
224
+ *
225
+ * Claude Code's Agent/Task tool hands PostToolUse a STRUCTURED result object
226
+ * for an async dispatch — verified verbatim against claude-code 2.1.219 by
227
+ * reading `toolUseResult` out of a live parent transcript:
228
+ *
229
+ * { isAsync: true, status: 'async_launched', agentId: '<stem>',
230
+ * description: '…', resolvedModel: '…', prompt: '…',
231
+ * outputFile: '…', canReadOutputFile: true }
232
+ *
233
+ * There is NO `content` array and NO `result`/`output` string on it, so the
234
+ * prose tiers in isAsyncLaunchAck() below saw an EMPTY string and returned
235
+ * false for every real dispatch — which is exactly how ~95% of registry rows
236
+ * came to be terminalized ~0.2s after launch by the foreground path (#3667).
237
+ * Prose matching was never reached in production; these two machine-readable
238
+ * fields are the real signal, so they are checked FIRST and the prose tiers
239
+ * are demoted to a backstop.
240
+ */
241
+ function isStructuredAsyncLaunch(toolResponse) {
242
+ if (toolResponse == null || typeof toolResponse !== 'object') return false
243
+ if (Array.isArray(toolResponse)) return false
244
+ if (toolResponse.status === 'async_launched') return true
245
+ // `isAsync` alone says "this dispatch is asynchronous", not "it is still
246
+ // running". If a future claude-code reuses the same envelope to report an
247
+ // async agent's OUTCOME, an explicitly terminal `status` wins — the row is
248
+ // then classified KIND_COMPLETION by structuredTerminalStatus below, so the
249
+ // hook can still terminalize it. A non-terminal / absent status (queued,
250
+ // running, in_progress, none at all) means "still in flight" → launch.
251
+ if (toolResponse.isAsync === true) return structuredTerminalStatus(toolResponse) == null
252
+ return false
253
+ }
254
+
255
+ /**
256
+ * The terminal statuses claude-code's Agent envelope uses for an end-of-life
257
+ * result. Single source of truth: read by isStructuredAsyncLaunch (to refuse
258
+ * calling a finished dispatch a launch), classifyAgentResponse (to call it a
259
+ * completion) and detectStatus (to pick completed vs failed). Keeping one set
260
+ * is what stops those three from drifting into disagreement.
261
+ */
262
+ const TERMINAL_AGENT_STATUSES = new Set([
263
+ 'completed', 'failed', 'error', 'cancelled', 'canceled',
264
+ ])
265
+
266
+ /**
267
+ * DETERMINISTIC completion signal — the mirror of isStructuredAsyncLaunch.
268
+ *
269
+ * The synchronous-completion envelope is machine-readable too. Captured from
270
+ * production transcripts (agent `marko`, three real sync dispatches of 164s /
271
+ * 387s / 490s):
272
+ *
273
+ * { status: 'completed', agentId, agentType, resolvedModel, prompt,
274
+ * content: [{ type: 'text', text: '…' }], totalDurationMs, totalTokens,
275
+ * totalToolUseCount, usage, toolStats }
276
+ *
277
+ * Reading `status` STRUCTURALLY, ahead of the prose ACK backstop, matters for
278
+ * the same reason the launch side does: isAsyncLaunchAck() pattern-matches the
279
+ * flattened response TEXT, and on a real completion that text is the worker's
280
+ * own report. A worker whose report happens to quote the ACK ("Async agent
281
+ * launched successfully"), or to print `agentId: <id>` on its own line near
282
+ * the word "background" — i.e. any worker reporting on sub-agent dispatch,
283
+ * including one reviewing this hook — was classified KIND_ASYNC_LAUNCH,
284
+ * promoted to background, and never terminalized by the hook. The structured
285
+ * status settles it before prose ever gets a vote.
286
+ *
287
+ * Returns the lowercased terminal status, or null when the response carries no
288
+ * machine-readable terminal status (which includes `async_launched`).
289
+ */
290
+ function structuredTerminalStatus(toolResponse) {
291
+ if (toolResponse == null || typeof toolResponse !== 'object') return null
292
+ if (Array.isArray(toolResponse)) return null
293
+ if (typeof toolResponse.status !== 'string') return null
294
+ const s = toolResponse.status.toLowerCase()
295
+ return TERMINAL_AGENT_STATUSES.has(s) ? s : null
296
+ }
297
+
298
+ /**
299
+ * Detect Claude Code's async-launch ACK PROSE in a PostToolUse tool_response.
300
+ *
301
+ * Backstop only — see isStructuredAsyncLaunch() for the primary, structural
302
+ * signal. Retained because a claude-code version that drops the structured
303
+ * fields but still returns the ACK text must not regress to terminalizing.
180
304
  *
181
305
  * A `run_in_background` Agent/Task returns IMMEDIATELY with an
182
306
  * acknowledgement ("Async agent launched successfully … The agent is working
@@ -206,9 +330,10 @@ function toolResponseText(toolResponse) {
206
330
  * print a bare id on its own line. This survives BOTH prose phrases
207
331
  * (tiers 1 & 2) rewording in the same bump.
208
332
  *
209
- * If all three miss, promotion degrades to the pretool's input-derived flag —
210
- * still correct whenever the model DID pass run_in_background, never worse than
211
- * before. The exact ACK contract is pinned by drift-variant tests in
333
+ * If all three miss, classifyAgentResponse falls through to the completion /
334
+ * unknown split and an unrecognised shape is NOT terminalized, so a total
335
+ * prose miss can no longer produce the #3667 "completed 0.2s after launch"
336
+ * row. The exact ACK contract is pinned by drift-variant tests in
212
337
  * subagent-tracker-hooks.test.ts ("async-launch ACK contract"); when bumping
213
338
  * the pinned claude-code version, re-verify the live ACK against those.
214
339
  *
@@ -238,6 +363,117 @@ function isAsyncLaunchAck(toolResponse) {
238
363
  return false
239
364
  }
240
365
 
366
+ // ---------------------------------------------------------------------------
367
+ // Response classification (#3667)
368
+ // ---------------------------------------------------------------------------
369
+
370
+ /** The dispatch was ACKed, not finished — the worker is still running. */
371
+ const KIND_ASYNC_LAUNCH = 'async_launch'
372
+ /** A real, recognisable end-of-life result — safe to terminalize the row. */
373
+ const KIND_COMPLETION = 'completion'
374
+ /** Shape we do not recognise — we know NOTHING about liveness. */
375
+ const KIND_UNKNOWN = 'unknown'
376
+
377
+ /**
378
+ * Does this tool_response carry a recognisable COMPLETED sub-agent result?
379
+ *
380
+ * Deliberately an allowlist of known-real shapes, never a fallthrough. The
381
+ * bug this replaces (#3667) came from the opposite posture: anything the hook
382
+ * failed to recognise was treated as a completion (`detectStatus` returned
383
+ * 'completed' for a null / unknown response), so one unrecognised payload
384
+ * shape silently poisoned `status` + `ended_at` on nearly every row.
385
+ */
386
+ function hasCompletionShape(toolResponse) {
387
+ if (toolResponse == null) return false
388
+ if (typeof toolResponse === 'string') return toolResponse.length > 0
389
+ if (typeof toolResponse !== 'object') return false
390
+ if (toolResponse.is_error === true || toolResponse.error != null) return true
391
+ const blocks = Array.isArray(toolResponse)
392
+ ? toolResponse
393
+ : (Array.isArray(toolResponse.content) ? toolResponse.content : null)
394
+ if (blocks != null) {
395
+ return blocks.some(
396
+ (c) => c && typeof c === 'object' && c.type === 'text' && typeof c.text === 'string',
397
+ )
398
+ }
399
+ if (typeof toolResponse.result === 'string') return true
400
+ if (typeof toolResponse.output === 'string') return true
401
+ return false
402
+ }
403
+
404
+ /**
405
+ * Classify a PostToolUse tool_response for an Agent/Task dispatch.
406
+ *
407
+ * The whole point of the three-way split is that only KIND_COMPLETION is
408
+ * allowed to write a terminal row. KIND_ASYNC_LAUNCH and KIND_UNKNOWN both
409
+ * promote to background and bump activity only, leaving the watcher's
410
+ * JSONL-driven `recordSubagentEnd` (turn_end / stall synthesis) as the
411
+ * authoritative end-of-life signal, with `reapStuckRunningRows` behind it.
412
+ * Failing to terminalize here is recoverable; terminalizing a live worker is
413
+ * not — it makes the `wk:<agentId>` reaper unpin a running worker's card and
414
+ * turns every duration in the registry into fiction.
415
+ *
416
+ * Order matters. Both STRUCTURAL signals are read before the prose backstop,
417
+ * because the prose tiers match the flattened response TEXT and on a real
418
+ * completion that text is the worker's own report — see
419
+ * structuredTerminalStatus() for the self-referential failure that causes.
420
+ *
421
+ * KIND_UNKNOWN takes the same DB path as a launch (background = 1, no terminal
422
+ * write) rather than staying background = 0. That is NOT a guess about
423
+ * liveness — it is what keeps the row inside the safety net. `background = 0`
424
+ * is a contract, not a free-form flag: `reapStuckRunningRows` deliberately
425
+ * excludes foreground rows *because* "their lifecycle goes through PostToolUse
426
+ * which writes `completed` directly" (subagents-schema.ts). A row this hook
427
+ * declines to terminalize while leaving background = 0 satisfies neither
428
+ * owner — PostToolUse won't end it and the reaper won't sweep it — so under a
429
+ * claude-code shape drift EVERY row would sit `running` forever, unbounded and
430
+ * silent. Routed through the promote path instead, the worst case is bounded:
431
+ * the watcher ends it if the JSONL links, and the 1h reaper otherwise moves it
432
+ * to `stalled` with a reason recorded in result_summary.
433
+ */
434
+ function classifyAgentResponse(toolResponse) {
435
+ if (isStructuredAsyncLaunch(toolResponse)) return KIND_ASYNC_LAUNCH
436
+ if (structuredTerminalStatus(toolResponse) != null) return KIND_COMPLETION
437
+ if (isAsyncLaunchAck(toolResponse)) return KIND_ASYNC_LAUNCH
438
+ if (hasCompletionShape(toolResponse)) return KIND_COMPLETION
439
+ return KIND_UNKNOWN
440
+ }
441
+
442
+ /**
443
+ * Choose the UPDATE to apply for a (background-flag, kind) pair.
444
+ *
445
+ * SINGLE source of truth, shared by the node:sqlite and sqlite3-CLI branches
446
+ * of updateRow(). Those two used to carry hand-duplicated conditionals that
447
+ * disagreed on their default arm: the sync branch fell through to the
448
+ * non-terminal update while the CLI branch fell through to the TERMINAL one,
449
+ * so any `kind` neither branch enumerated (a future fourth kind, or a caller
450
+ * that omitted the field) would silently terminalize a live row on exactly one
451
+ * of the two paths — the same class of failure as #3667, reachable only on the
452
+ * fallback runtime where nobody would look.
453
+ *
454
+ * Returns 'foreground' (terminal write), 'promote' (set background = 1,
455
+ * activity bump only) or 'background' (activity bump only). Anything not
456
+ * explicitly recognised as a completion fails SAFE — non-terminal.
457
+ */
458
+ function chooseUpdate(isBackground, kind) {
459
+ if (isBackground) return 'background'
460
+ if (kind === KIND_COMPLETION) return 'foreground'
461
+ return 'promote'
462
+ }
463
+
464
+ /**
465
+ * One-line description of an unrecognised tool_response, for the stderr
466
+ * warning. Emits only the SHAPE (typeof + top-level key names) — never
467
+ * values, because the async payload embeds the full dispatch prompt and this
468
+ * line lands in journald.
469
+ */
470
+ function describeShape(toolResponse) {
471
+ if (toolResponse == null) return 'null'
472
+ if (Array.isArray(toolResponse)) return `array(${toolResponse.length})`
473
+ if (typeof toolResponse !== 'object') return typeof toolResponse
474
+ return `object{${Object.keys(toolResponse).slice(0, 12).join(',')}}`
475
+ }
476
+
241
477
  // ---------------------------------------------------------------------------
242
478
  // DB write
243
479
  // ---------------------------------------------------------------------------
@@ -249,23 +485,35 @@ function isAsyncLaunchAck(toolResponse) {
249
485
  * and last_activity_at — PostToolUse fires on actual completion.
250
486
  *
251
487
  * Background agents (background = 1): PostToolUse fires on the launch ACK
252
- * (~10 s), NOT on actual completion. Only bump last_activity_at and capture
253
- * result_summary; leave status/ended_at alone so the watcher's
488
+ * (measured 78-880 ms in production), NOT on actual completion. Only bump
489
+ * last_activity_at and capture result_summary; leave status/ended_at alone
490
+ * so the watcher's
254
491
  * recordSubagentEnd (driven by the JSONL turn_end event) remains the
255
492
  * authoritative end-of-life signal.
256
493
  *
257
- * Mis-recorded background (DB background = 0 but `asyncLaunch` is true):
494
+ * Mis-recorded background (DB background = 0 but `kind` is KIND_ASYNC_LAUNCH):
258
495
  * Claude Code returned the async-launch ACK even though run_in_background was
259
- * absent from the tool_input the pretool saw, so the row was wrongly recorded
260
- * foreground. PROMOTE it to background = 1 and take the background path — do
261
- * NOT terminalize, because the worker is still running (the ACK is a launch,
262
- * not a completion). This is the authoritative correction that makes the
263
- * gateway's worker-feed card fire (onProgress re-reads `background` per tick)
264
- * AND prevents the premature `completed` the foreground path would write.
496
+ * absent from the tool_input the pretool saw which on claude-code 2.1.219 is
497
+ * EVERY dispatch, since the runtime auto-backgrounds Agent calls and never
498
+ * echoes the flag. PROMOTE the row to background = 1 and take the background
499
+ * path do NOT terminalize, because the worker is still running (the ACK is a
500
+ * launch, not a completion). This is the authoritative correction that makes
501
+ * the gateway's worker-feed card fire (onProgress re-reads `background` per
502
+ * tick) AND prevents the premature `completed` the foreground path would write.
503
+ *
504
+ * Unrecognised shape (KIND_UNKNOWN): same path as a launch — promote to
505
+ * background = 1, bump activity, write no terminal status. We cannot tell a
506
+ * launch from a completion, so we decline to guess about the OUTCOME, but we
507
+ * must still hand the row to an owner: background = 0 means "PostToolUse ends
508
+ * this row", which is precisely what we are declining to do, and it is the
509
+ * flag `reapStuckRunningRows` filters on. See classifyAgentResponse (#3667).
510
+ *
511
+ * Both runtime branches below route through chooseUpdate() so they cannot
512
+ * disagree.
265
513
  *
266
514
  * The done(err | null) callback is invoked after all DB operations complete.
267
515
  */
268
- function updateRow(dbPath, { id, status, resultSummary, now, asyncLaunch }, done) {
516
+ function updateRow(dbPath, { id, status, resultSummary, now, kind }, done) {
269
517
  // SQL to read the background flag so we can choose the right update path.
270
518
  const SELECT_SQL = `SELECT background FROM subagents WHERE id = ?`
271
519
 
@@ -300,7 +548,7 @@ function updateRow(dbPath, { id, status, resultSummary, now, asyncLaunch }, done
300
548
  const snapStatus = status
301
549
  const snapResultSummary = resultSummary
302
550
  const snapNow = now
303
- const snapAsyncLaunch = asyncLaunch === true
551
+ const snapKind = kind
304
552
 
305
553
  // Resolve a synchronous SQLite binding (node:sqlite under Node 22+,
306
554
  // bun:sqlite under bun, else null → CLI fallback). See helper docs.
@@ -320,12 +568,13 @@ function updateRow(dbPath, { id, status, resultSummary, now, asyncLaunch }, done
320
568
  try { db.exec('PRAGMA busy_timeout = 5000') } catch { /* best-effort */ }
321
569
  const row = db.prepare(SELECT_SQL).get(snapId)
322
570
  const isBackground = row != null && row.background === 1
323
- if (isBackground) {
324
- db.prepare(BACKGROUND_SQL).run(snapResultSummary, snapNow, snapId)
325
- } else if (snapAsyncLaunch) {
571
+ const choice = chooseUpdate(isBackground, snapKind)
572
+ if (choice === 'foreground') {
573
+ db.prepare(FOREGROUND_SQL).run(snapNow, snapStatus, snapResultSummary, snapNow, snapId)
574
+ } else if (choice === 'promote') {
326
575
  db.prepare(PROMOTE_BACKGROUND_SQL).run(snapResultSummary, snapNow, snapId)
327
576
  } else {
328
- db.prepare(FOREGROUND_SQL).run(snapNow, snapStatus, snapResultSummary, snapNow, snapId)
577
+ db.prepare(BACKGROUND_SQL).run(snapResultSummary, snapNow, snapId)
329
578
  }
330
579
  db.close()
331
580
  done(null)
@@ -341,13 +590,14 @@ function updateRow(dbPath, { id, status, resultSummary, now, asyncLaunch }, done
341
590
  if (err) { done(err); return }
342
591
  // sqlite3 outputs "0" or "1" (or empty if row not found).
343
592
  const isBackground = bgResult === '1'
344
- if (isBackground) {
593
+ const choice = chooseUpdate(isBackground, snapKind)
594
+ if (choice === 'foreground') {
345
595
  spawnSql(
346
596
  snapDbPath,
347
- fillPlaceholders(BACKGROUND_SQL.trim(), [snapResultSummary, snapNow, snapId]),
597
+ fillPlaceholders(FOREGROUND_SQL.trim(), [snapNow, snapStatus, snapResultSummary, snapNow, snapId]),
348
598
  done,
349
599
  )
350
- } else if (snapAsyncLaunch) {
600
+ } else if (choice === 'promote') {
351
601
  spawnSql(
352
602
  snapDbPath,
353
603
  fillPlaceholders(PROMOTE_BACKGROUND_SQL.trim(), [snapResultSummary, snapNow, snapId]),
@@ -356,7 +606,7 @@ function updateRow(dbPath, { id, status, resultSummary, now, asyncLaunch }, done
356
606
  } else {
357
607
  spawnSql(
358
608
  snapDbPath,
359
- fillPlaceholders(FOREGROUND_SQL.trim(), [snapNow, snapStatus, snapResultSummary, snapNow, snapId]),
609
+ fillPlaceholders(BACKGROUND_SQL.trim(), [snapResultSummary, snapNow, snapId]),
360
610
  done,
361
611
  )
362
612
  }
@@ -371,7 +621,7 @@ function updateRow(dbPath, { id, status, resultSummary, now, asyncLaunch }, done
371
621
  * Synchronously read the `background` flag for a subagent row. Returns
372
622
  * 0 (foreground), 1 (background), or null (unknown — sync SQLite
373
623
  * unavailable, or row not found). Used to gate the foreground handback
374
- * nudge: a background sub-agent's PostToolUse fires on the ~10s launch
624
+ * nudge: a background sub-agent's PostToolUse fires on the sub-second launch
375
625
  * ACK, not on completion, so it must NOT be nudged here (the gateway's
376
626
  * subagent-watcher handles the background handback via inject_inbound).
377
627
  */
@@ -430,6 +680,16 @@ function main() {
430
680
  process.exit(0)
431
681
  }
432
682
 
683
+ // A well-formed JSON body that is not an object still has to be rejected
684
+ // BEFORE any property read. `JSON.parse('null')` succeeds and returns null,
685
+ // and `null.tool_name` is an uncaught TypeError — which in production means
686
+ // a non-zero hook exit, which means bin/run-hook.sh files a red issue card
687
+ // for a payload the hook was always going to ignore. (Pre-existing on main;
688
+ // not reachable from claude-code's own protocol, but this hook runs on every
689
+ // PostToolUse across the fleet, so it must not be one bad byte from
690
+ // throwing.)
691
+ if (event == null || typeof event !== 'object') process.exit(0)
692
+
433
693
  // Only care about sub-agent dispatches. Claude Code emits the dispatch
434
694
  // tool under either the legacy name 'Agent' or the newer 'Task'
435
695
  // depending on version. The matching session-tail / progress-card /
@@ -459,12 +719,29 @@ function main() {
459
719
 
460
720
  const toolResponse = event.tool_response ?? null
461
721
 
462
- // Authoritative background signal: Claude Code's async-launch ACK. Trusted
463
- // over the pretool's input-derived flag (which is missing whenever the
464
- // model/runtime omits run_in_background from tool_input see
465
- // isAsyncLaunchAck). Gates both the nudge below and the promote path in
466
- // updateRow.
467
- const asyncLaunch = isAsyncLaunchAck(toolResponse)
722
+ // Authoritative classification of what this PostToolUse actually means:
723
+ // an async LAUNCH (worker still running), a real COMPLETION, or an
724
+ // unrecognised shape. Trusted over the pretool's input-derived background
725
+ // flag, which is missing whenever the runtime omits run_in_background from
726
+ // tool_input (claude-code 2.1.219: always). Gates both the nudge below and
727
+ // the update path in updateRow. See classifyAgentResponse (#3667).
728
+ const kind = classifyAgentResponse(toolResponse)
729
+ if (kind === KIND_UNKNOWN) {
730
+ // Best-effort drift breadcrumb, NOT the safety mechanism. In production
731
+ // this hook runs under bin/run-hook.sh, which records an issue only on a
732
+ // NON-ZERO exit and auto-resolves on exit 0 — and claude-code discards a
733
+ // 0-exit hook's stderr — so this line reaches a human only under
734
+ // RUN_HOOK_DEBUG / journald. Exiting non-zero instead would card an error
735
+ // on every dispatch under drift, which is worse. What actually bounds the
736
+ // damage is the classification: an unrecognised shape is never
737
+ // terminalized and is routed to background = 1, so the watcher's
738
+ // recordSubagentEnd or the 1h reaper owns the row's end of life.
739
+ process.stderr.write(
740
+ `[subagent-tracker-posttool] unrecognised Agent tool_response shape `
741
+ + `(${describeShape(toolResponse)}) — leaving row non-terminal; `
742
+ + `watcher owns completion\n`,
743
+ )
744
+ }
468
745
 
469
746
  // conversational-pacing beat 4 (foreground half). A foreground
470
747
  // sub-agent's PostToolUse fires at real completion, mid-parent-turn,
@@ -472,13 +749,16 @@ function main() {
472
749
  // user-facing handback. Background sub-agents are gated OUT: their
473
750
  // PostToolUse fires on the launch ACK (BACKGROUND_SQL leaves status
474
751
  // untouched for that reason), and their handback is driven by the
475
- // gateway's subagent-watcher onFinish path instead. A launch ACK is also
476
- // gated out via `!asyncLaunch` at this point the DB flag may still read 0
477
- // (updateRow promotes it on the next tick), so the ACK is the reliable
478
- // tell. Fail-silent: an unknown background flag (null) skips the nudge.
752
+ // gateway's subagent-watcher onFinish path instead. A launch ACK and any
753
+ // unrecognised shape — is gated out via `kind === KIND_COMPLETION`: at this
754
+ // point the DB flag may still read 0 (updateRow promotes it on the next
755
+ // tick), so the classification is the reliable tell, and nudging "synthesise
756
+ // the handback" when nothing has been handed back is exactly the #3667
757
+ // symptom the parent saw. Fail-silent: an unknown background flag (null)
758
+ // skips the nudge.
479
759
  if (
480
760
  process.env.SWITCHROOM_SUBAGENT_HANDBACK !== '0'
481
- && !asyncLaunch
761
+ && kind === KIND_COMPLETION
482
762
  && detectStatus(toolResponse) === 'completed'
483
763
  && readBackgroundFlagSync(dbPath, id) === 0
484
764
  ) {
@@ -492,7 +772,7 @@ function main() {
492
772
  status: detectStatus(toolResponse),
493
773
  resultSummary: extractResultSummary(toolResponse),
494
774
  now: Date.now(),
495
- asyncLaunch,
775
+ kind,
496
776
  },
497
777
  (err) => {
498
778
  if (err) {
@@ -70,11 +70,24 @@ export interface RetryCallOpts {
70
70
  * send waits for a short window but fails fast (structured
71
71
  * `FLOOD_WAIT_ACTIVE`) when the remaining window is long.
72
72
  * - `useful` — progress-card creation, worker handbacks, checklists,
73
- * boot/config cards. Queued with a TTL; dropped when stale. DEFAULT
74
- * when unset.
73
+ * boot/config cards. Queued with a TTL; dropped when stale.
75
74
  * - `cosmetic` — typing, reactions, all card EDITS, stream updates,
76
75
  * heartbeats. Shed immediately when no token is free OR any flood
77
76
  * window is open.
77
+ *
78
+ * UNTAGGED default — NOT `useful`. This doc said "`useful` — DEFAULT when
79
+ * unset" and was factually wrong (#3664); it is the comment a reader
80
+ * reasoning about droppability lands on, so read the corrected rule:
81
+ *
82
+ * - An untagged non-edit SEND admits as `UNTAGGED_SEND_CLASS` in
83
+ * `send-gate.ts`, which is `'critical'` — non-droppable, never shed.
84
+ * - An untagged EDIT is recorded `useful`, but the gate only SHEDS edits
85
+ * classed `cosmetic`; an untagged edit coalesces (latest payload wins)
86
+ * rather than dropping.
87
+ *
88
+ * So nothing is droppable unless a call site OPTS IN by tagging
89
+ * `useful`/`cosmetic`. See the `PriorityClass` docblock in send-gate.ts for
90
+ * the authoritative statement.
78
91
  */
79
92
  priorityClass?: 'critical' | 'useful' | 'cosmetic'
80
93
  }
@@ -489,7 +489,7 @@ function hashPayload(payload: unknown): string {
489
489
  // `stableStringify(undefined)` (and any value that JSON.stringify drops)
490
490
  // returns undefined; hash a fixed sentinel so createHash never throws
491
491
  // (#3092 L2 — a caller may set editPayload: undefined alongside messageId).
492
- s = j === undefined ? 'undefined' : j
492
+ s = j === undefined ? '\u0000undefined' : j
493
493
  }
494
494
  return createHash('sha256').update(s).digest('hex')
495
495
  }
@@ -59,5 +59,68 @@ export const STATUS_LINE_MAX = 200
59
59
  */
60
60
  export const STATUS_CARD_CHAR_BUDGET = RICH_MESSAGE_MAX_CHARS
61
61
 
62
- /** Indent marker for a nested (foreground sub-agent) step line. */
62
+ /**
63
+ * Indent marker for a nested (foreground sub-agent) step line.
64
+ *
65
+ * NOTE: the three leading spaces here are ASCII, which Telegram's server-side
66
+ * markdown parser DROPS — the visible nesting cue on this surface is the `↳`
67
+ * glyph, not the indent. That is a known latent wart, deliberately left alone
68
+ * — it needs its own live render check on the single-worker / agent card,
69
+ * tracked in #3668. It is NOT broken by the #3662 mechanism (nothing here
70
+ * depends on the ASCII run being visible), so it is not fixed here. If #3668
71
+ * ever does make it a real indent, use U+2800 — NOT U+00A0, which was tried in
72
+ * #3662 and renders flat. Do NOT copy this string as the idiom for a real
73
+ * indent.
74
+ *
75
+ * @see WORKER_STEP_INDENT — the U+2800 indent used for actual left-nesting on
76
+ * the combined (2+ worker) card, and the evidence for why ASCII (and U+00A0)
77
+ * cannot work.
78
+ */
63
79
  export const NESTED_PREFIX = ' ↳ '
80
+
81
+ /**
82
+ * Left indent for a step line rendered UNDER a worker header on the combined
83
+ * (2+ worker) card — three U+2800 BRAILLE PATTERN BLANK, written as escapes so
84
+ * the bytes are visible in source.
85
+ *
86
+ * ── Why not ASCII, and why not U+00A0 ────────────────────────────────
87
+ * Card bodies reach Telegram as raw GFM markdown (`richMessage` →
88
+ * `sendRichMessage` / `editMessageText({ markdown })`, #2669) and are parsed
89
+ * SERVER-SIDE by a CommonMark/GFM-family parser. Leading ASCII spaces are
90
+ * stripped (and 4+ would open an indented CODE block), so an ASCII indent
91
+ * renders FLAT.
92
+ *
93
+ * #3662 shipped three U+00A0 here on the reasoning that a non-ASCII space is
94
+ * ordinary text content to that parser. That reasoning was an INFERENCE and it
95
+ * was WRONG: the merged fix was inert — the card still rendered flat on a
96
+ * phone. The bytes were never the problem; they reach the Bot API intact
97
+ * (verified by dumping wire bytes out of the real outbound path: `302 240` ×3
98
+ * present at every stage, final string byte-identical to input). Telegram's
99
+ * parser left-trims a leading INLINE whitespace run, and U+00A0 is Unicode
100
+ * whitespace — general category Zs. The #2692/#3229 precedent that motivated
101
+ * U+00A0 covered a materially different shape (a U+00A0-ONLY line inside a
102
+ * paragraph gap, which survives because BLOCK parsing sees it as non-blank),
103
+ * not a U+00A0 run LEADING a content line, which inline left-trim eats.
104
+ *
105
+ * U+2800 works because it is general category So (Symbol, other), NOT Zs: no
106
+ * whitespace-trimming rule — ASCII `\s`, Unicode `White_Space`, or `Zs` — can
107
+ * classify it as whitespace, yet it renders as blank width. That is a category
108
+ * fact rather than another inference about Telegram's parser, and it is ALSO
109
+ * live-verified: on 2026-07-26 four candidates were sent to a real phone in one
110
+ * message — three U+00A0 rendered FLAT (confirming the #3662 failure), three
111
+ * U+2800 INDENTED CORRECTLY, a leading `↳ ` rendered as visible ink (the
112
+ * NESTED_PREFIX idiom), and a leading `· ` was promoted by Telegram into a real
113
+ * list bullet.
114
+ *
115
+ * That last result is why `· ` is unusable, and it is the same reason the
116
+ * guide's other indent idiom, the blockquote (`> `), is unusable: card lines are
117
+ * joined by `stackCardLines`, which promotes EVERY inter-line break to a GFM
118
+ * hard break *because* card lines are never block-structure lines. A `> ` or
119
+ * `· ` prefix breaks that precondition, and the next worker's header line would
120
+ * be absorbed into the quote/list as a lazy continuation.
121
+ *
122
+ * Guard: `worker-feed-coalesce.test.ts` asserts the PROPERTY (the indent is not
123
+ * whitespace under any of the three rules), not just the bytes — a byte-only
124
+ * assertion is what let #3662 ship green and inert.
125
+ */
126
+ export const WORKER_STEP_INDENT = '\u2800\u2800\u2800'