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.
- package/dist/auth-broker/index.js +53 -0
- package/dist/cli/switchroom.js +2444 -1264
- package/dist/host-control/main.js +54 -1
- package/dist/vault/approvals/kernel-server.js +53 -0
- package/dist/vault/broker/server.js +53 -0
- package/package.json +4 -2
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/gateway/gateway.js +769 -172
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +5 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +193 -25
- package/vendor/hindsight-memory/scripts/lib/pending.py +84 -5
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +21 -10
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +158 -4
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +19 -11
- 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
|
-
|
|
135
|
-
|
|
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
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
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 (
|
|
167
|
-
|
|
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
|
-
*
|
|
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,
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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
|
-
* (
|
|
253
|
-
* result_summary; leave status/ended_at alone
|
|
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 `
|
|
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
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
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,
|
|
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
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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(
|
|
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
|
-
|
|
593
|
+
const choice = chooseUpdate(isBackground, snapKind)
|
|
594
|
+
if (choice === 'foreground') {
|
|
345
595
|
spawnSql(
|
|
346
596
|
snapDbPath,
|
|
347
|
-
fillPlaceholders(
|
|
597
|
+
fillPlaceholders(FOREGROUND_SQL.trim(), [snapNow, snapStatus, snapResultSummary, snapNow, snapId]),
|
|
348
598
|
done,
|
|
349
599
|
)
|
|
350
|
-
} else if (
|
|
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(
|
|
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
|
|
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
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
|
|
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
|
|
476
|
-
// gated out via
|
|
477
|
-
//
|
|
478
|
-
//
|
|
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
|
-
&&
|
|
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
|
-
|
|
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.
|
|
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 ? '
|
|
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
|
-
/**
|
|
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'
|