switchroom 0.19.18 → 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 (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +56 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +5242 -2239
  7. package/dist/host-control/main.js +12241 -11375
  8. package/dist/vault/approvals/kernel-server.js +113 -7
  9. package/dist/vault/broker/server.js +259 -76
  10. package/package.json +6 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/skills/switchroom-release/SKILL.md +103 -20
  13. package/telegram-plugin/bridge/bridge.ts +14 -0
  14. package/telegram-plugin/card-format.ts +92 -3
  15. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
  17. package/telegram-plugin/dist/server.js +13 -0
  18. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  19. package/telegram-plugin/format.ts +19 -7
  20. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  21. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  23. package/telegram-plugin/gateway/gateway.ts +66 -56
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  25. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  26. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  27. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  28. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  29. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  30. package/telegram-plugin/gateway/store-file.ts +244 -0
  31. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  32. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  33. package/telegram-plugin/retry-api-call.ts +15 -2
  34. package/telegram-plugin/send-gate.ts +1 -1
  35. package/telegram-plugin/status-no-truncate.ts +64 -1
  36. package/telegram-plugin/status-pin-driver.ts +50 -27
  37. package/telegram-plugin/status-pin.ts +43 -5
  38. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  40. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  41. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  42. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  43. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  44. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  45. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  46. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  47. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  48. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  49. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  50. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  51. package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
  52. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  53. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  54. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  55. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  56. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  57. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  58. package/telegram-plugin/tool-activity-summary.ts +85 -13
  59. package/telegram-plugin/worker-activity-feed.ts +56 -2
  60. package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
  61. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  62. package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
  63. package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
  64. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  65. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  66. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  67. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  73. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
  76. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -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) {
@@ -96,6 +96,85 @@ function urlHostPath(u) {
96
96
  }
97
97
  }
98
98
 
99
+ /**
100
+ * ALLOWLIST-style summary of a Bash command, for when the model omitted the
101
+ * optional `description`.
102
+ *
103
+ * The failure this closes: `description` is optional, so a sub-agent that never
104
+ * writes one produced the constant label "Running a command" for EVERY Bash
105
+ * call. The worker feed's narrative dedup then dropped every repeat and the
106
+ * card froze on one line for the whole job — the user could not tell a running
107
+ * worker from a wedged one. The card must never depend on a model volunteering
108
+ * an optional field.
109
+ *
110
+ * SECURITY: a command line routinely carries tokens, passwords, URLs with
111
+ * credentials, and private paths, and this string is rendered into a Telegram
112
+ * message. So this is NOT a clip of the command — nothing is echoed unless it
113
+ * passes a strict allowlist:
114
+ * - only the PROGRAM name (basename, no directory), and
115
+ * - for a known multiplexer (git/docker/npm/…), at most one bare subcommand
116
+ * of pure lowercase letters/hyphens.
117
+ * Anything with a slash, `=`, digit-mixed shape, quote, or any other character
118
+ * is refused and we fall back to the generic label. Arguments, flag VALUES,
119
+ * env assignments, redirections and heredocs are never considered at all.
120
+ *
121
+ * Returns '' when nothing safe can be derived (caller uses the generic label).
122
+ */
123
+ export function summariseBashCommand(cmd) {
124
+ if (typeof cmd !== 'string') return ''
125
+ // First line only — a heredoc/multiline body must never be inspected.
126
+ const firstLine = cmd.split('\n', 1)[0]
127
+ if (!firstLine) return ''
128
+
129
+ // Split on shell separators and prefer the first segment that actually runs
130
+ // something (`cd /x && git status` should read as "git status", not "cd").
131
+ const NAV = new Set(['cd', 'pushd', 'popd', 'export', 'set', 'source', '.', 'unset'])
132
+ const PREFIXES = new Set(['sudo', 'env', 'nohup', 'time', 'exec', 'command', 'doas', 'runuser', 'nice', 'xargs'])
133
+ const segments = firstLine.split(/&&|\|\||[;|]/).map((s) => s.trim()).filter(Boolean)
134
+ if (segments.length === 0) return ''
135
+
136
+ const PROGRAM_RE = /^[A-Za-z][A-Za-z0-9._+-]{0,19}$/
137
+ const SUBCOMMAND_RE = /^[a-z][a-z-]{1,15}$/
138
+ const MULTIPLEXERS = new Set([
139
+ 'git', 'docker', 'npm', 'npx', 'bun', 'yarn', 'pnpm', 'cargo', 'go', 'gh',
140
+ 'kubectl', 'systemctl', 'apt', 'apt-get', 'brew', 'pip', 'pip3', 'poetry',
141
+ 'uv', 'terraform', 'aws', 'gcloud', 'helm', 'switchroom', 'make', 'openssl',
142
+ ])
143
+
144
+ /** Program + optional subcommand for one segment, or null. */
145
+ function fromSegment(seg) {
146
+ let tokens = seg.split(/\s+/).filter(Boolean)
147
+ // Drop leading env assignments (FOO=bar) and wrapper prefixes.
148
+ while (tokens.length > 0 && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]) || PREFIXES.has(tokens[0]))) {
149
+ tokens = tokens.slice(1)
150
+ }
151
+ if (tokens.length === 0) return null
152
+ // Basename only: never surface a directory (paths leak layout, usernames).
153
+ const head = safeBasename(tokens[0])
154
+ if (!PROGRAM_RE.test(head)) return null
155
+ if (NAV.has(head)) return { program: head, nav: true }
156
+ if (!MULTIPLEXERS.has(head)) return { program: head, nav: false }
157
+ // ONLY the immediately-following token may be a subcommand. Scanning past
158
+ // flags would surface a flag VALUE (`docker --context prod-internal ps` →
159
+ // "prod-internal"), which is exactly the kind of private name this
160
+ // function exists to keep out of a chat message. `git -C /x status` losing
161
+ // its "status" is the correct trade.
162
+ const next = tokens[1]
163
+ return next != null && SUBCOMMAND_RE.test(next)
164
+ ? { program: `${head} ${next}`, nav: false }
165
+ : { program: head, nav: false }
166
+ }
167
+
168
+ let firstAny = null
169
+ for (const seg of segments) {
170
+ const r = fromSegment(seg)
171
+ if (r == null) continue
172
+ if (firstAny == null) firstAny = r
173
+ if (!r.nav) return r.program
174
+ }
175
+ return firstAny != null ? firstAny.program : ''
176
+ }
177
+
99
178
  /**
100
179
  * Compute a label for a (toolName, input) pair. Returns null when the
101
180
  * tool should NOT be labeled (suppress / fall through to existing
@@ -111,8 +190,15 @@ export function computeLabel(toolName, input) {
111
190
  // never reaches the live draft. Uses the model-authored `description`
112
191
  // for Bash/Task, matching the gateway's describeToolUse rendering.
113
192
  switch (toolName) {
114
- case 'Bash':
115
- return clip(asText(i.description), 70).trim() || 'Running a command'
193
+ case 'Bash': {
194
+ const described = clip(asText(i.description), 70).trim()
195
+ if (described) return described
196
+ // No model-authored description — derive a sanitised one from the
197
+ // command itself rather than emitting the constant "Running a command"
198
+ // that freezes the step feed. See summariseBashCommand.
199
+ const derived = summariseBashCommand(asText(i.command))
200
+ return derived ? `Running ${derived}` : 'Running a command'
201
+ }
116
202
  case 'Task':
117
203
  case 'Agent': {
118
204
  const d = clip(asText(i.description), 60).trim()
@@ -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
  }