switchroom 0.18.6 → 0.18.8

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 (116) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +1 -0
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +1172 -812
  6. package/dist/host-control/main.js +2 -1
  7. package/dist/vault/approvals/kernel-server.js +1 -0
  8. package/dist/vault/broker/server.js +1 -0
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +146 -50
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-runtime/SKILL.md +2 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  15. package/telegram-plugin/dist/gateway/gateway.js +2965 -862
  16. package/telegram-plugin/dist/server.js +24 -0
  17. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  18. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  19. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  20. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  21. package/telegram-plugin/gateway/boot-card.ts +27 -0
  22. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  23. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  24. package/telegram-plugin/gateway/gateway.ts +1618 -198
  25. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  26. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  27. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  28. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  29. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  30. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  31. package/telegram-plugin/gateway/model-command.ts +227 -54
  32. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  33. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  34. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  35. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  36. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  37. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  38. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  39. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  40. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  41. package/telegram-plugin/hooks/hooks.json +10 -10
  42. package/telegram-plugin/hooks/run-hook.sh +84 -0
  43. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  44. package/telegram-plugin/model-label.ts +69 -0
  45. package/telegram-plugin/model-unavailable.ts +26 -0
  46. package/telegram-plugin/operator-events.ts +24 -0
  47. package/telegram-plugin/permission-diff.ts +128 -0
  48. package/telegram-plugin/pty-partial-handler.ts +39 -0
  49. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  50. package/telegram-plugin/registry/subagents.test.ts +90 -0
  51. package/telegram-plugin/render/rich-render.ts +79 -1
  52. package/telegram-plugin/retry-api-call.ts +62 -0
  53. package/telegram-plugin/session-tail.ts +28 -0
  54. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  55. package/telegram-plugin/silence-poke.ts +14 -0
  56. package/telegram-plugin/silent-end.ts +49 -4
  57. package/telegram-plugin/stream-controller.ts +156 -38
  58. package/telegram-plugin/subagent-watcher.ts +222 -37
  59. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  60. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  61. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  62. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  63. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  64. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  65. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  66. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  67. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  68. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  69. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
  70. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  71. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  72. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  73. package/telegram-plugin/tests/model-command.test.ts +203 -43
  74. package/telegram-plugin/tests/model-label.test.ts +64 -0
  75. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  76. package/telegram-plugin/tests/operator-events.test.ts +1 -0
  77. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  78. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  79. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  80. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  81. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  82. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  83. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  84. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  85. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  86. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  87. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  88. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  89. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  90. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  92. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  93. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  94. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  96. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  97. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  98. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  99. package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
  100. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  101. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  102. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  103. package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
  104. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  105. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  106. package/telegram-plugin/tool-activity-summary.ts +22 -2
  107. package/telegram-plugin/typing-wrap.ts +72 -25
  108. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  109. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  110. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  111. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  112. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  113. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  114. package/telegram-plugin/voice-ondemand.ts +25 -1
  115. package/telegram-plugin/voice-send.ts +154 -0
  116. package/telegram-plugin/worker-activity-feed.ts +9 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Freshness-aware source of truth for the /status session model.
3
+ *
4
+ * Two independent signals report what model the live session is running:
5
+ *
6
+ * - the TRANSCRIPT: `message.model` on each assistant line (session-tail's
7
+ * `model` event) — ground truth for the model that served the LAST API
8
+ * call, but only advances when a new assistant line lands;
9
+ * - the OVERRIDE: the in-memory `/model` switch record (#2982) — set the
10
+ * moment a switch is positively confirmed, ahead of any assistant line.
11
+ *
12
+ * Neither is unconditionally right. Preferring the transcript regresses the
13
+ * idle-after-switch window (#2982's core fix): a native `/model` inject while
14
+ * idle sets the override to the NEW model, but the transcript still holds the
15
+ * OLD one until the next assistant line — /status would lie. Preferring the
16
+ * override goes stale the other way (a relaunch carrier that never applied).
17
+ *
18
+ * The invariant is "prefer the NEWER observation": every write from either
19
+ * source is stamped with a shared monotonic sequence, and `resolve()` returns
20
+ * whichever was observed last. A fresh assistant line always reclaims the
21
+ * transcript as the source; a confirmed switch always beats an older
22
+ * transcript line. Pinned by tests/session-model-source.test.ts.
23
+ */
24
+
25
+ export interface SessionModelResolution {
26
+ /** The raw value from the winning source. Transcript entries are resolved
27
+ * model ids (`claude-opus-4-8`); override entries may be friendly labels
28
+ * ("Opus 4.8") or sr-* ids depending on the /model path that set them. */
29
+ model: string
30
+ source: 'transcript' | 'override'
31
+ }
32
+
33
+ export interface SessionModelSource {
34
+ /** Record a transcript observation (an assistant line's `message.model`,
35
+ * already sentinel-filtered by the session-tail projection). */
36
+ noteTranscriptModel(model: string): void
37
+ /** Record an override set (a positively-confirmed /model switch), or clear
38
+ * it with null. Setting stamps a fresh sequence, so the override wins over
39
+ * every EARLIER transcript observation until a new assistant line lands. */
40
+ setOverride(model: string | null): void
41
+ /** Current override value (the #2982 in-memory record), independent of
42
+ * freshness — for callers that need the override itself (e.g. the model
43
+ * menu's "session" marker), not the /status resolution. */
44
+ getOverride(): string | null
45
+ /** The freshest observation across both sources, or null when neither has
46
+ * reported yet. */
47
+ resolve(): SessionModelResolution | null
48
+ }
49
+
50
+ export function createSessionModelSource(): SessionModelSource {
51
+ let seq = 0
52
+ let transcript: { model: string; seq: number } | null = null
53
+ let override: { model: string; seq: number } | null = null
54
+ return {
55
+ noteTranscriptModel(model: string): void {
56
+ transcript = { model, seq: ++seq }
57
+ },
58
+ setOverride(model: string | null): void {
59
+ override = model == null ? null : { model, seq: ++seq }
60
+ },
61
+ getOverride(): string | null {
62
+ return override?.model ?? null
63
+ },
64
+ resolve(): SessionModelResolution | null {
65
+ if (transcript == null && override == null) return null
66
+ if (override == null) return { model: transcript!.model, source: 'transcript' }
67
+ if (transcript == null || transcript.seq < override.seq) {
68
+ return { model: override.model, source: 'override' }
69
+ }
70
+ return { model: transcript.model, source: 'transcript' }
71
+ },
72
+ }
73
+ }
@@ -12,10 +12,13 @@
12
12
  * a dead session lingers.
13
13
  *
14
14
  * This makes cleanup self-contained across restart: every pin claim persists
15
- * here; on boot the gateway loads the persisted set and unpins each entry
16
- * (a status pin from a PRIOR session is stale by definition — the turn it
17
- * represented is over or crashed), then clears the store. It does NOT re-adopt
18
- * or re-pin it only cleans up.
15
+ * here; on boot the gateway loads the persisted set and unpins each
16
+ * work-scoped entry (a status pin from a PRIOR session is stale by definition
17
+ * — the turn it represented is over or crashed), dropping rows only after a
18
+ * successful unpin (failed ones are retained with an attempt counter for a
19
+ * next-boot retry — see runStatusPinBootCleanup). Time-scoped `tool:` rows
20
+ * (the `pin_message` MCP tool, #3001) survive boots until their `expiresAt`.
21
+ * It does NOT re-adopt or re-pin — it only cleans up.
19
22
  *
20
23
  * Shape choice — SNAPSHOT, not append-log, mirroring obligation-store.ts. The
21
24
  * claim set is tiny and bounded (one entry per in-flight pinned key, normally
@@ -55,8 +58,27 @@ export interface PersistedStatusPin {
55
58
  messageId: number
56
59
  /** True while the pin API call is in-flight / unconfirmed (see above). */
57
60
  pending?: boolean
61
+ /** Wall-clock ms after which this pin is stale and boot cleanup unpins it.
62
+ * Rows WITHOUT this field are work-scoped (fg:/wk:/banner:) — stale the
63
+ * moment their owning session dies, so boot cleanup unpins them
64
+ * unconditionally. Rows WITH it (the `tool:` pins written by the
65
+ * `pin_message` MCP tool, #3001) represent deliberate agent pins that have
66
+ * no "work finished" event: they SURVIVE restarts and are only swept once
67
+ * expired. */
68
+ expiresAt?: number
69
+ /** Boot-cleanup unpin retry counter (#3001). Incremented each boot the
70
+ * unpin fails (flood-wait exhausted / transient 5xx); the row is retained
71
+ * for retry until BOOT_UNPIN_MAX_ATTEMPTS, then forfeited. Absent = 0. */
72
+ attempts?: number
58
73
  }
59
74
 
75
+ /** How many boots may retry a failing boot-cleanup unpin before the row is
76
+ * forfeited. Unpins are idempotent (unpinning an already-unpinned or deleted
77
+ * message no-ops), so retrying across boots is safe; the cap only bounds a
78
+ * permanently-undeliverable unpin (chat gone, bot removed) so it cannot
79
+ * re-fail on every boot forever. */
80
+ export const BOOT_UNPIN_MAX_ATTEMPTS = 5
81
+
60
82
  /** Envelope version. v1 had no `pending` field; a v1 row loads as a confirmed
61
83
  * pin (pending undefined). v2 adds the optional `pending` flag. Both load
62
84
  * fail-open — an unknown/newer version yields []. */
@@ -74,7 +96,9 @@ function isPinRow(x: unknown): x is PersistedStatusPin {
74
96
  typeof o.chatId === 'string' &&
75
97
  o.chatId.length > 0 &&
76
98
  typeof o.messageId === 'number' &&
77
- (o.pending === undefined || typeof o.pending === 'boolean')
99
+ (o.pending === undefined || typeof o.pending === 'boolean') &&
100
+ (o.expiresAt === undefined || typeof o.expiresAt === 'number') &&
101
+ (o.attempts === undefined || typeof o.attempts === 'number')
78
102
  )
79
103
  }
80
104
 
@@ -170,16 +194,27 @@ export function pinnedMessageIsOurs(
170
194
  * the ordering + best-effort contract is unit-testable against the REAL code
171
195
  * (the gateway's thin wrapper just binds the live fs / unpin api / logger).
172
196
  *
173
- * Any pin persisted by a PRIOR session is stale by definition its turn ended
174
- * or the session crashed before its unpin reconcile ran. This includes records
175
- * left `pending` (the persist-intent-first write from `reconcileAndPersist-
176
- * StatusPin`): a crash between the pin API call and its confirming rewrite
177
- * leaves a pending record whose pin MAY have landed in Telegram, so we must
178
- * treat it exactly like a confirmed one and unpin it. We therefore best-effort
179
- * unpin EVERY persisted entry (confirmed and pending alike a failure is
180
- * non-fatal) and then EMPTY the store regardless, so a permanently-
181
- * undeliverable unpin can't re-run on every boot. We do NOT re-adopt or re-pin.
182
- * Returns the counts for logging/testing.
197
+ * The restart rule (#3001): a WORK-SCOPED pin persisted by a PRIOR session
198
+ * (fg:/wk:/banner: any row without `expiresAt`) is stale by definition its
199
+ * work ended or the session crashed before its unpin reconcile ran, so
200
+ * restart = reset: it is unpinned here. This includes records left `pending`
201
+ * (the persist-intent-first write from `reconcileAndPersistStatusPin`): a
202
+ * crash between the pin API call and its confirming rewrite leaves a pending
203
+ * record whose pin MAY have landed in Telegram, so we must treat it exactly
204
+ * like a confirmed one and unpin it.
205
+ *
206
+ * TIME-SCOPED rows (`tool:` pins from the `pin_message` MCP tool, carrying
207
+ * `expiresAt`) have no "work finished" event, so a restart does NOT reset
208
+ * them: an unexpired row is RETAINED untouched across boots and only unpinned
209
+ * once `now >= expiresAt`.
210
+ *
211
+ * RETRY-SAFETY (#3001): a row is dropped only AFTER its unpin resolves. A
212
+ * failing unpin (flood-wait exhausted / transient 5xx) retains the row with an
213
+ * incremented `attempts` counter so the NEXT boot retries, up to
214
+ * BOOT_UNPIN_MAX_ATTEMPTS — then the row is forfeited (a permanently-
215
+ * undeliverable unpin must not re-fail on every boot forever). Unpins are
216
+ * idempotent, so the retry can never double-unpin harmfully. We do NOT
217
+ * re-adopt or re-pin. Returns the counts for logging/testing.
183
218
  *
184
219
  * CRITICAL: the caller MUST only invoke this AFTER winning the startup mutex.
185
220
  * The store is a shared per-agent file; on a double-boot a losing gateway
@@ -189,27 +224,52 @@ export async function runStatusPinBootCleanup(args: {
189
224
  path: string
190
225
  fs: StatusPinStoreFsSeam
191
226
  unpin: (chatId: string, messageId: number) => Promise<unknown>
227
+ now?: number
192
228
  log?: (line: string) => void
193
- }): Promise<{ cleared: number; total: number }> {
229
+ }): Promise<{ cleared: number; retained: number; kept: number; total: number }> {
194
230
  const log = args.log ?? ((l: string) => process.stderr.write(l))
231
+ const now = args.now ?? Date.now()
195
232
  const persisted = loadStatusPins(args.path, args.fs)
196
- if (persisted.length === 0) return { cleared: 0, total: 0 }
233
+ if (persisted.length === 0) return { cleared: 0, retained: 0, kept: 0, total: 0 }
197
234
  let cleared = 0
235
+ let retained = 0
236
+ let kept = 0
237
+ const next: PersistedStatusPin[] = []
198
238
  for (const pin of persisted) {
239
+ // Unexpired time-scoped row (tool: pin): deliberately survives the
240
+ // restart — keep it as-is, no unpin.
241
+ if (pin.expiresAt != null && pin.expiresAt > now) {
242
+ next.push(pin)
243
+ kept++
244
+ continue
245
+ }
199
246
  try {
200
247
  await args.unpin(pin.chatId, pin.messageId)
201
248
  cleared++
202
249
  } catch (err) {
250
+ const attempts = (pin.attempts ?? 0) + 1
203
251
  log(
204
252
  `status-pin-store: boot cleanup unpin failed ` +
205
- `(chat=${pin.chatId} msg=${pin.messageId}): ${(err as Error).message}\n`,
253
+ `(chat=${pin.chatId} msg=${pin.messageId} attempt=${attempts}): ` +
254
+ `${(err as Error).message}\n`,
206
255
  )
256
+ if (attempts < BOOT_UNPIN_MAX_ATTEMPTS) {
257
+ // Retain for a retry on the next boot instead of forfeiting the
258
+ // orphan permanently (retry-safe boot sweep, #3001).
259
+ next.push({ ...pin, attempts })
260
+ retained++
261
+ } else {
262
+ log(
263
+ `status-pin-store: boot cleanup FORFEITING pin after ` +
264
+ `${attempts} failed unpin attempts ` +
265
+ `(key=${pin.pinKey} chat=${pin.chatId} msg=${pin.messageId}) — ` +
266
+ `will not retry again\n`,
267
+ )
268
+ }
207
269
  }
208
270
  }
209
- // Empty the store regardless — these claims belong to a dead session; leaving
210
- // them would re-attempt the same (already-tried) unpins on every future boot.
211
- persistStatusPins(args.path, args.fs, [], log)
212
- return { cleared, total: persisted.length }
271
+ persistStatusPins(args.path, args.fs, next, log)
272
+ return { cleared, retained, kept, total: persisted.length }
213
273
  }
214
274
 
215
275
  /**
@@ -25,6 +25,15 @@ export interface WorkerFeedDispatch {
25
25
  * result returns to its dispatching worker as the Task tool result).
26
26
  */
27
27
  isNested: boolean
28
+ /**
29
+ * Dispatch-time / last-persisted model for the worker, from the registry
30
+ * row's `model` column (seeded by the pretool hook from `tool_input.model`,
31
+ * later updated by the watcher from the worker's transcript). The FIRST-PAINT
32
+ * fallback the worker card renders before the live watcher entry has observed
33
+ * a transcript model. Null when the row is missing or never carried a model —
34
+ * the card then omits the model rather than guessing from config.
35
+ */
36
+ feedModel: string | null
28
37
  }
29
38
 
30
39
  /**
@@ -43,14 +52,28 @@ export interface WorkerFeedDispatch {
43
52
  * decision again: a regression here silently reverts the feed header to
44
53
  * "· sub-agent".
45
54
  */
55
+ /**
56
+ * `entryBackground` (fix #1(+#2)): the in-memory watcher entry's own cached
57
+ * `background` flag (`WorkerEntry.background`), passed by the gateway as a
58
+ * graceful-degradation fallback for when the registry row (`sub`) is
59
+ * missing — most often because `jsonl_agent_id` never linked (unreadable
60
+ * meta.json, or an ambiguous fuzzy backfill — see fix #3). Without this, a
61
+ * missing row hard-defaults `isBackground` to `false`, silently dropping a
62
+ * completed background worker's handback (the gateway's `onFinish` treats
63
+ * `false` as "nothing to deliver — it returns inline"). Ignored entirely
64
+ * when `sub` resolves — the registry row is always the authoritative
65
+ * source once it links.
66
+ */
46
67
  export function resolveWorkerFeedDispatch(
47
68
  sub: Subagent | null,
48
69
  watcherDescription: string,
70
+ entryBackground?: boolean,
49
71
  ): WorkerFeedDispatch {
50
72
  return {
51
- isBackground: sub?.background ?? false,
73
+ isBackground: sub?.background ?? entryBackground ?? false,
52
74
  feedDescription: (sub?.description ?? '') || watcherDescription,
53
75
  hasRow: sub != null,
54
76
  isNested: sub?.parent_agent_id != null,
77
+ feedModel: sub?.model ?? null,
55
78
  }
56
79
  }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * worker-pin-reaper.ts — pure decision for the mid-session `wk:` pin sweep
3
+ * (#3001).
4
+ *
5
+ * Why this exists: the background-worker pin (`wk:<agentId>`, pinned on the
6
+ * `🛠 Worker` feed message while the worker runs) is normally unpinned by the
7
+ * worker's completion handler (`reconcileWorkerPin(agentId, null, false)` on
8
+ * the watcher's onFinish). But that event can be MISSED — watcher crash, SDK
9
+ * subprocess SIGKILL, a dropped JSONL tail — and then nothing ever unpins the
10
+ * worker's message until the next gateway boot. Log evidence on one agent:
11
+ * ~1120 pinChatMessage vs ~1014 unpinChatMessage with zero logged failures —
12
+ * a long tail of stale pins glued to the top of the chat.
13
+ *
14
+ * This module is the pure half (mirrors `runActivityCardMidSessionReaper`'s
15
+ * decide-over-injected-seams shape): given the currently-claimed `wk:` pins,
16
+ * a terminality predicate over the sub-agent registry, and a TTL, it returns
17
+ * the pins that should be unpinned NOW. The gateway executes each reap via
18
+ * `reconcileStatusPin(key, chat, { pinned: false })` so the in-memory claim
19
+ * AND the durable store row clear together.
20
+ *
21
+ * A pin is reaped when EITHER:
22
+ * - `terminal` — the registry says the worker reached a terminal status
23
+ * (completed | failed): its work is finished, the pin must go, however
24
+ * young it is. (A missed onFinish is exactly this case.)
25
+ * - `ttl` — the pin has been held past `ttlMs` AND the registry cannot
26
+ * vouch for the worker (no row / never linked / `stalled` / lookup
27
+ * error). A registry-confirmed RUNNING row exempts the pin from the TTL
28
+ * entirely: a healthy 7h worker keeps its pin for the whole run instead
29
+ * of churning unpin→re-pin every TTL. The stall detector demotes a dead
30
+ * worker's row out of 'running' within ~60s, so the TTL still catches
31
+ * true zombies.
32
+ *
33
+ * A worker the registry can't vouch for is still never touched before the
34
+ * TTL — the sweep can only ever shorten a stale pin's life, not a live one's.
35
+ */
36
+
37
+ /** Default TTL for a held worker pin: 6 hours. Rationale: worker turns are
38
+ * expected to run minutes-to-a-couple-of-hours (the watcher's own stall
39
+ * detection fires after ~60s of JSONL inactivity, and the longest sanctioned
40
+ * background dispatches are bounded by a single Claude session's lifetime).
41
+ * 6h comfortably exceeds any legitimate worker turn while bounding the
42
+ * stale-pin window to the same day instead of "until the next restart". */
43
+ export const WORKER_PIN_TTL_MS_DEFAULT = 6 * 60 * 60_000
44
+
45
+ export const WORKER_PIN_KEY_PREFIX = 'wk:'
46
+
47
+ /** One currently-claimed worker pin, flattened from the gateway's Maps. */
48
+ export interface WorkerPinCandidate {
49
+ /** Full pin key, `wk:<agentId>` shape. */
50
+ pinKey: string
51
+ /** Chat the pin lives in (from the gateway's pinKey → chatId registry). */
52
+ chatId: string
53
+ /** Wall-clock ms the claim was first taken (gateway's pinnedAt registry). */
54
+ pinnedAt: number
55
+ }
56
+
57
+ export interface WorkerPinReap extends WorkerPinCandidate {
58
+ reason: 'terminal' | 'ttl'
59
+ }
60
+
61
+ /** Extract the agentId from a `wk:<agentId>` pin key, or null for any other
62
+ * key shape (fg:/banner:/tool: keys are never worker-reaped). */
63
+ export function workerAgentIdOfPinKey(pinKey: string): string | null {
64
+ if (!pinKey.startsWith(WORKER_PIN_KEY_PREFIX)) return null
65
+ const agentId = pinKey.slice(WORKER_PIN_KEY_PREFIX.length)
66
+ return agentId.length > 0 ? agentId : null
67
+ }
68
+
69
+ /** The registry's view of a worker, distilled for the reap decision.
70
+ * - 'terminal' — row exists in completed | failed: reap now.
71
+ * - 'running' — row exists and is still 'running': NEVER reap, not even
72
+ * past the TTL. A healthy 7h worker must not get unpinned mid-run only
73
+ * for the next feed edit to re-pin it (pin/unpin churn every TTL). The
74
+ * subagent-watcher's stall detection demotes a dead worker's row out of
75
+ * 'running' within ~60s of its JSONL going quiet, so a true zombie can
76
+ * only hold 'running' briefly — the TTL still catches everything the
77
+ * registry has lost track of.
78
+ * - 'unknown' — no row / never linked / 'stalled' / lookup error: the
79
+ * TTL gate applies (the registry can't vouch for it). */
80
+ export type WorkerRegistryStatus = 'terminal' | 'running' | 'unknown'
81
+
82
+ /**
83
+ * Decide which claimed worker pins to unpin now. Pure: the registry lookup is
84
+ * an injected predicate (`statusOf` must return 'terminal' ONLY for a row in
85
+ * completed | failed, 'running' only for a live 'running' row, and 'unknown'
86
+ * for stalled / missing / lookup-error; a DB hiccup must degrade to 'unknown'
87
+ * — kept until the TTL — never to a spurious 'terminal' unpin).
88
+ */
89
+ export function decideWorkerPinReaps(args: {
90
+ pins: Iterable<WorkerPinCandidate>
91
+ statusOf: (agentId: string) => WorkerRegistryStatus
92
+ ttlMs: number
93
+ now: number
94
+ }): WorkerPinReap[] {
95
+ const reaps: WorkerPinReap[] = []
96
+ for (const pin of args.pins) {
97
+ const agentId = workerAgentIdOfPinKey(pin.pinKey)
98
+ if (agentId == null) continue // not a worker pin — never ours to reap
99
+ if (pin.chatId.length === 0) continue // can't unpin without a chat
100
+ const status = args.statusOf(agentId)
101
+ if (status === 'terminal') {
102
+ reaps.push({ ...pin, reason: 'terminal' })
103
+ continue
104
+ }
105
+ // A registry-confirmed RUNNING worker keeps its pin regardless of age —
106
+ // the TTL only reaps pins the registry can't vouch for (see
107
+ // WorkerRegistryStatus doc).
108
+ if (status === 'running') continue
109
+ if (args.now - pin.pinnedAt >= args.ttlMs) {
110
+ reaps.push({ ...pin, reason: 'ttl' })
111
+ }
112
+ }
113
+ return reaps
114
+ }
@@ -5,7 +5,7 @@
5
5
  "hooks": [
6
6
  {
7
7
  "type": "command",
8
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-guard-pretool.mjs\"",
8
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-guard-pretool.mjs\"",
9
9
  "timeout": 10
10
10
  }
11
11
  ]
@@ -14,7 +14,7 @@
14
14
  "hooks": [
15
15
  {
16
16
  "type": "command",
17
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/sentinel-reply-guard-pretool.mjs\"",
17
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/sentinel-reply-guard-pretool.mjs\"",
18
18
  "timeout": 5
19
19
  }
20
20
  ]
@@ -24,7 +24,7 @@
24
24
  "hooks": [
25
25
  {
26
26
  "type": "command",
27
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-pretool.mjs\"",
27
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-pretool.mjs\"",
28
28
  "timeout": 10
29
29
  }
30
30
  ]
@@ -33,7 +33,7 @@
33
33
  "hooks": [
34
34
  {
35
35
  "type": "command",
36
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-pretool.mjs\"",
36
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-pretool.mjs\"",
37
37
  "timeout": 5
38
38
  }
39
39
  ]
@@ -43,7 +43,7 @@
43
43
  "hooks": [
44
44
  {
45
45
  "type": "command",
46
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/repo-context-pretool.mjs\"",
46
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/repo-context-pretool.mjs\"",
47
47
  "timeout": 5
48
48
  }
49
49
  ]
@@ -55,7 +55,7 @@
55
55
  "hooks": [
56
56
  {
57
57
  "type": "command",
58
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-posttool.mjs\"",
58
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-posttool.mjs\"",
59
59
  "timeout": 10
60
60
  }
61
61
  ]
@@ -65,7 +65,7 @@
65
65
  "hooks": [
66
66
  {
67
67
  "type": "command",
68
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/sandbox-hint-posttool.mjs\"",
68
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/sandbox-hint-posttool.mjs\"",
69
69
  "timeout": 3
70
70
  }
71
71
  ]
@@ -76,7 +76,7 @@
76
76
  "hooks": [
77
77
  {
78
78
  "type": "command",
79
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-scrub-stop.mjs\"",
79
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-scrub-stop.mjs\"",
80
80
  "timeout": 15,
81
81
  "async": true
82
82
  }
@@ -86,7 +86,7 @@
86
86
  "hooks": [
87
87
  {
88
88
  "type": "command",
89
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-end-interrupt-stop.mjs\"",
89
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-end-interrupt-stop.mjs\"",
90
90
  "timeout": 5
91
91
  }
92
92
  ]
@@ -95,7 +95,7 @@
95
95
  "hooks": [
96
96
  {
97
97
  "type": "command",
98
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-stop.mjs\"",
98
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-stop.mjs\"",
99
99
  "timeout": 5,
100
100
  "async": true
101
101
  }
@@ -0,0 +1,84 @@
1
+ #!/bin/sh
2
+ # run-hook.sh — resilient launcher for Claude Code Node hooks (issue #2555).
3
+ #
4
+ # Under cgroup memory-ceiling pressure (the cgroup pinned at memory.max with
5
+ # reclaim lagging, page cache ~= cap), a freshly-spawned Node process can abort
6
+ # at STARTUP with exit 134 (SIGABRT) inside the libuv threadpool constructor —
7
+ # `Assertion failed: (0) == (uv_thread_create(...))` — BEFORE any hook code
8
+ # runs. It is a transient allocation failure, not a real hook error, but it
9
+ # surfaces a 🔴 issues card and skips the hook's work on that one tool call.
10
+ #
11
+ # This wrapper makes the invocation tolerant:
12
+ # 1. Shrink the libuv threadpool to 1 so Node needs the fewest possible
13
+ # thread-stack mmaps at startup (minimises the failure window).
14
+ # 2. Capture the hook payload from stdin ONCE and replay it on each attempt.
15
+ # The abort can land AFTER Node started draining the pipe, so a naive
16
+ # retry would feed the second attempt EMPTY stdin — a secret scanner would
17
+ # then scan nothing. Replaying the captured payload keeps the retry
18
+ # faithful.
19
+ # 3. Retry ONCE on a 134 abort after a brief backoff.
20
+ # 4. If it STILL aborts:
21
+ # - for SECURITY-CRITICAL hooks (secret-guard / secret-scrub) FAIL
22
+ # CLOSED — propagate 134 so the runtime cards it. 134 is a generic
23
+ # SIGABRT (assertion / OOM / any abort), not memory-pressure-specific,
24
+ # so silently returning 0 for a genuinely broken scanner would be a
25
+ # silent security BYPASS. Those hooks must never fail open.
26
+ # - for all other hooks, SKIP CLEANLY (exit 0) with a single stderr
27
+ # warn. A skipped label/context hook on one call is the documented,
28
+ # accepted degradation; a crash-card storm under memory pressure is
29
+ # not.
30
+ #
31
+ # Any non-134 exit status is passed through unchanged — real hook decisions
32
+ # (block/allow/non-zero) are never masked. `sleep 0.15` uses a fractional
33
+ # second (supported by GNU/BusyBox sleep, both present in the agent image).
34
+ #
35
+ # Usage (from hooks.json):
36
+ # sh "${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh" node "${CLAUDE_PLUGIN_ROOT}/hooks/<name>.mjs"
37
+
38
+ export UV_THREADPOOL_SIZE="${UV_THREADPOOL_SIZE:-1}"
39
+
40
+ # Identify the hook script (the .mjs argument) to decide fail-open vs closed.
41
+ hook_path=""
42
+ for a in "$@"; do
43
+ case "$a" in
44
+ *.mjs) hook_path="$a" ;;
45
+ esac
46
+ done
47
+ hook_name=$(basename "$hook_path" 2>/dev/null)
48
+
49
+ # Security-critical hooks MUST fail closed on a persistent abort.
50
+ fail_closed=0
51
+ case "$hook_name" in
52
+ secret-guard-pretool.mjs | secret-scrub-stop.mjs) fail_closed=1 ;;
53
+ esac
54
+
55
+ # Capture stdin once so both attempts see the SAME payload. Claude Code feeds
56
+ # the hook a single JSON object on stdin and closes it, so `cat` returns at
57
+ # EOF. (Command substitution strips trailing newlines, which JSON parsing does
58
+ # not care about.)
59
+ payload=$(cat)
60
+
61
+ run_hook() {
62
+ printf '%s' "$payload" | "$@"
63
+ }
64
+
65
+ run_hook "$@"
66
+ status=$?
67
+ if [ "$status" -ne 134 ]; then
68
+ exit "$status"
69
+ fi
70
+
71
+ # Transient thread-create abort — brief backoff, then retry once with the
72
+ # faithfully-replayed payload.
73
+ sleep 0.15
74
+ run_hook "$@"
75
+ status=$?
76
+ if [ "$status" -eq 134 ]; then
77
+ if [ "$fail_closed" -eq 1 ]; then
78
+ echo "run-hook: security hook '$hook_name' aborted twice with exit 134 — FAILING CLOSED (not skipping) (#2555)" >&2
79
+ exit 134
80
+ fi
81
+ echo "run-hook: '$hook_name' aborted twice with exit 134 (uv_thread_create under memory pressure) — skipping hook cleanly (#2555)" >&2
82
+ exit 0
83
+ fi
84
+ exit "$status"
@@ -55,7 +55,8 @@ const SCHEMA_SQL = `
55
55
  status TEXT NOT NULL,
56
56
  result_summary TEXT,
57
57
  jsonl_agent_id TEXT,
58
- parent_agent_id TEXT
58
+ parent_agent_id TEXT,
59
+ model TEXT
59
60
  );
60
61
  CREATE INDEX IF NOT EXISTS subagents_turn ON subagents(parent_turn_key);
61
62
  CREATE INDEX IF NOT EXISTS subagents_status ON subagents(status);
@@ -157,14 +158,14 @@ function spawnSql(dbPath, sql, cb) {
157
158
  // DB write
158
159
  // ---------------------------------------------------------------------------
159
160
 
160
- function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, description, background, now }, done) {
161
+ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, description, background, model, now }, done) {
161
162
  const INSERT_SQL = `
162
163
  INSERT OR IGNORE INTO subagents
163
164
  (id, parent_session_id, parent_turn_key, agent_type, description,
164
- background, started_at, last_activity_at, status)
165
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running')
165
+ background, started_at, last_activity_at, status, model)
166
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', ?)
166
167
  `
167
- const params = [id, parentSessionId, parentTurnKey, agentType, description, background, now, now]
168
+ const params = [id, parentSessionId, parentTurnKey, agentType, description, background, now, now, model ?? null]
168
169
 
169
170
  // Resolve a synchronous SQLite binding. Try in order:
170
171
  // 1. node:sqlite (Node 22+, production path) — exposes DatabaseSync
@@ -207,6 +208,13 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
207
208
  if (hasParentAgentCol == null) {
208
209
  db.exec('ALTER TABLE subagents ADD COLUMN parent_agent_id TEXT')
209
210
  }
211
+ // Migrate older DBs that pre-date the live-model column.
212
+ const hasModelCol = db
213
+ .prepare("SELECT name FROM pragma_table_info('subagents') WHERE name = 'model'")
214
+ .get()
215
+ if (hasModelCol == null) {
216
+ db.exec('ALTER TABLE subagents ADD COLUMN model TEXT')
217
+ }
210
218
  // Verify the marker-derived parent_turn_key (snapParams[2]) actually has
211
219
  // a row in the turns table before trusting it. The gateway writes the
212
220
  // turn-active marker even when recordTurnStart's INSERT failed (the two
@@ -235,7 +243,7 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
235
243
  return
236
244
  }
237
245
 
238
- // sqlite3 CLI fallback — two non-blocking spawns sequenced via callbacks.
246
+ // sqlite3 CLI fallback — non-blocking spawns sequenced via callbacks.
239
247
  // This legacy path (neither node:sqlite nor bun:sqlite available) can't
240
248
  // cheaply verify the marker's turn_key against the turns table, so drop
241
249
  // parent_turn_key and let the gateway's window backfill attribute it.
@@ -244,7 +252,15 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
244
252
  params[2] = null
245
253
  spawnSql(dbPath, SCHEMA_SQL.replace(/\n\s+/g, ' '), (err) => {
246
254
  if (err) { done(err); return }
247
- spawnSql(dbPath, fillPlaceholders(INSERT_SQL.trim(), params), done)
255
+ // Best-effort model-column migration for a legacy DB: the INSERT below
256
+ // references the model column, which a pre-model table lacks (CREATE TABLE
257
+ // IF NOT EXISTS is a no-op there, so the schema exec doesn't add it). The
258
+ // ALTER fails with "duplicate column name" when the column already exists —
259
+ // that error is EXPECTED and deliberately ignored; on any failure we still
260
+ // proceed to the INSERT, which surfaces a real problem via `done`.
261
+ spawnSql(dbPath, 'ALTER TABLE subagents ADD COLUMN model TEXT', () => {
262
+ spawnSql(dbPath, fillPlaceholders(INSERT_SQL.trim(), params), done)
263
+ })
248
264
  })
249
265
  }
250
266
 
@@ -363,6 +379,13 @@ function main() {
363
379
  agentType: input.subagent_type ?? null,
364
380
  description: input.description ?? null,
365
381
  background: input.run_in_background === true ? 1 : 0,
382
+ // First-paint model for the worker card: the Agent tool payload carries
383
+ // the model the sub-agent will run under (`tool_input.model`), available
384
+ // BEFORE the sub-agent writes its first assistant line. Persisted so the
385
+ // card can render the model from dispatch; the watcher later overwrites it
386
+ // from the worker's own transcript (transcript wins). Only a non-empty
387
+ // string is stored — never guess from config.
388
+ model: typeof input.model === 'string' && input.model.length > 0 ? input.model : null,
366
389
  now: Date.now(),
367
390
  },
368
391
  (err) => {