switchroom 0.19.38 → 0.19.39

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.
@@ -133,6 +133,21 @@ import * as silencePoke from '../silence-poke.js'
133
133
  const PARKED_TURN_START_MAX = 16
134
134
  const PARKED_TURN_START_TTL_MS = 30 * 60_000
135
135
 
136
+ // ─── Queued-turn card (#3927 follow-up) ──────────────────────────────────────
137
+ // Since #3927 a genuinely-queued mid-turn message is parked with NO surface at
138
+ // all until it dequeues — the user gets silence between sending and the turn
139
+ // starting. We post an immediate, clearly-marked "queued" card at park time,
140
+ // reply-anchored to the parked message, and store its id on the envelope. On
141
+ // dequeue → `beginTurn` seeds it as the turn's `activityMessageId` (mirroring
142
+ // the handback-card adoption) so the SAME card is EDITED IN PLACE into the live
143
+ // progress card — one lifecycle, finalized by the normal turn end. A `remove`
144
+ // (folded into the running turn) or a TTL expiry finalizes it so it never
145
+ // freezes. Kill switch: SWITCHROOM_QUEUED_CARD=0.
146
+ const QUEUED_CARD_ENABLED = process.env.SWITCHROOM_QUEUED_CARD !== '0'
147
+ const QUEUED_CARD_HTML = '⏳ Queued — waiting for the current task to finish…'
148
+ const QUEUED_CARD_FOLDED_HTML = '✅ Folded into the current task.'
149
+ const QUEUED_CARD_EXPIRED_HTML = '⚠️ This queued message timed out before it could start.'
150
+
136
151
  /** The `enqueue` envelope fields `beginTurn` needs — the SessionEvent minus its
137
152
  * discriminant. A parked entry additionally carries `parkedAt` for the TTL. */
138
153
  export interface TurnStartEnvelope {
@@ -140,6 +155,10 @@ export interface TurnStartEnvelope {
140
155
  messageId: string | null
141
156
  threadId: string | null
142
157
  rawContent: string
158
+ /** Message id of the "queued" card posted at park time (Part B). Seeded as the
159
+ * turn's `activityMessageId` on dequeue so the card is edited in place into
160
+ * the live progress card. Absent on the idle-mint path (no parking, no card). */
161
+ queuedCardMessageId?: number | null
143
162
  }
144
163
  interface ParkedTurnStart extends TurnStartEnvelope {
145
164
  parkedAt: number
@@ -149,7 +168,9 @@ interface ParkedTurnStart extends TurnStartEnvelope {
149
168
  * claude CLI session's ONE queue, exactly like the `currentTurn` mirror. */
150
169
  const parkedTurnStarts: ParkedTurnStart[] = []
151
170
 
152
- function pruneParkedTurnStarts(now: number): void {
171
+ /** `onDrop` (Part B) fires for every removed entry so the caller can finalize a
172
+ * posted queued card. Best-effort — never throws into the prune loop. */
173
+ function pruneParkedTurnStarts(now: number, onDrop?: (entry: ParkedTurnStart) => void): void {
153
174
  for (let i = parkedTurnStarts.length - 1; i >= 0; i--) {
154
175
  if (now - parkedTurnStarts[i].parkedAt > PARKED_TURN_START_TTL_MS) {
155
176
  const [dropped] = parkedTurnStarts.splice(i, 1)
@@ -157,19 +178,29 @@ function pruneParkedTurnStarts(now: number): void {
157
178
  `telegram gateway: parked-turn-start expired chat=${dropped.chatId ?? '-'} ` +
158
179
  `msg=${dropped.messageId ?? '-'} age_ms=${now - dropped.parkedAt}\n`,
159
180
  )
181
+ try { onDrop?.(dropped) } catch { /* never let card cleanup break the prune */ }
160
182
  }
161
183
  }
162
184
  }
163
185
 
164
- function parkTurnStart(env: TurnStartEnvelope, now: number): void {
165
- parkedTurnStarts.push({ ...env, parkedAt: now })
186
+ /** Returns the freshly-parked entry so the caller can attach a queued-card id to
187
+ * it asynchronously. `onEvict` (Part B) fires for a cap-evicted entry. */
188
+ function parkTurnStart(
189
+ env: TurnStartEnvelope,
190
+ now: number,
191
+ onEvict?: (entry: ParkedTurnStart) => void,
192
+ ): ParkedTurnStart {
193
+ const entry: ParkedTurnStart = { ...env, parkedAt: now }
194
+ parkedTurnStarts.push(entry)
166
195
  while (parkedTurnStarts.length > PARKED_TURN_START_MAX) {
167
196
  const [dropped] = parkedTurnStarts.splice(0, 1)
168
197
  process.stderr.write(
169
198
  `telegram gateway: parked-turn-start evicted (cap ${PARKED_TURN_START_MAX}) ` +
170
199
  `chat=${dropped.chatId ?? '-'} msg=${dropped.messageId ?? '-'}\n`,
171
200
  )
201
+ try { onEvict?.(dropped) } catch { /* never let card cleanup break parking */ }
172
202
  }
203
+ return entry
173
204
  }
174
205
 
175
206
  /** Pop the MOST RECENTLY parked envelope — the one a `dequeue` pairs with. */
@@ -181,14 +212,14 @@ function takeParkedTurnStart(): ParkedTurnStart | null {
181
212
  * match first. A `remove` means "folded into the running turn" — that message
182
213
  * will never get a turn of its own, so leaving it parked would let a LATER
183
214
  * `dequeue` mint a spurious turn for an already-answered message. */
184
- function discardParkedTurnStart(rawContent: string): boolean {
215
+ function discardParkedTurnStart(rawContent: string): ParkedTurnStart | null {
185
216
  for (let i = parkedTurnStarts.length - 1; i >= 0; i--) {
186
217
  if (parkedTurnStarts[i].rawContent === rawContent) {
187
- parkedTurnStarts.splice(i, 1)
188
- return true
218
+ const [dropped] = parkedTurnStarts.splice(i, 1)
219
+ return dropped
189
220
  }
190
221
  }
191
- return false
222
+ return null
192
223
  }
193
224
 
194
225
  /** Test seam: the parked store is module-scope (one CLI session, one queue), so
@@ -201,6 +232,111 @@ export function __parkedTurnStartCountForTest(): number {
201
232
  return parkedTurnStarts.length
202
233
  }
203
234
 
235
+ // ─── Queued-turn card send / finalize (Part B) ───────────────────────────────
236
+ // These use the ALREADY-injected `bot` + `robustApiCall` deps, so the whole
237
+ // feature lives in stream-render.ts with zero new wiring in gateway.ts. No
238
+ // streaming is structurally possible before dequeue (no CurrentTurn exists),
239
+ // so this card is a single static line until the turn adopts + edits it.
240
+
241
+ /** Minimal deps the queued-card helpers need. */
242
+ type QueuedCardDeps = Pick<StreamRenderDeps, 'bot' | 'robustApiCall'>
243
+
244
+ /** Post the "queued" card, reply-anchored to the parked message. Resolves to the
245
+ * sent message id, or null when the send failed / was suppressed (best-effort —
246
+ * a null just means no card, so nothing to adopt or finalize). Forum topics use
247
+ * the envelope's OWN thread id. */
248
+ async function openQueuedCard(
249
+ deps: QueuedCardDeps,
250
+ chatId: string,
251
+ threadId: number | null,
252
+ replyToMessageId: number | null,
253
+ ): Promise<number | null> {
254
+ try {
255
+ const sent = await deps.robustApiCall(
256
+ // allow-raw-bot-api: sendRichMessage routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
257
+ () =>
258
+ deps.bot.api.sendRichMessage(chatId, richMessage(QUEUED_CARD_HTML), {
259
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
260
+ ...(replyToMessageId != null
261
+ ? { reply_parameters: { message_id: replyToMessageId, allow_sending_without_reply: true } }
262
+ : {}),
263
+ // Status surface, not the user's answer — never ping the device.
264
+ disable_notification: true,
265
+ }),
266
+ {
267
+ chat_id: chatId,
268
+ ...(threadId != null ? { threadId } : {}),
269
+ verb: 'queued-card.send',
270
+ },
271
+ )
272
+ return sent?.message_id ?? null
273
+ } catch (err) {
274
+ process.stderr.write(
275
+ `telegram gateway: queued card send failed: ${(err as Error).message}\n`,
276
+ )
277
+ return null
278
+ }
279
+ }
280
+
281
+ /** Finalize (single honest edit) a queued card that will NOT become a live turn:
282
+ * folded into the running turn (`remove`) or timed out (TTL). Best-effort. */
283
+ function finalizeQueuedCard(
284
+ deps: QueuedCardDeps,
285
+ chatId: string,
286
+ threadId: number | null,
287
+ messageId: number,
288
+ html: string,
289
+ ): void {
290
+ void deps
291
+ .robustApiCall(
292
+ // allow-raw-bot-api: editMessageText routed through robustApiCall
293
+ () => deps.bot.api.editMessageText(chatId, messageId, richMessage(html), {}),
294
+ {
295
+ chat_id: chatId,
296
+ ...(threadId != null ? { threadId } : {}),
297
+ verb: 'queued-card.finalize',
298
+ },
299
+ )
300
+ .catch(() => undefined)
301
+ }
302
+
303
+ /** Delete a queued card that lost its race (the entry left the store before its
304
+ * card id was stored, so `beginTurn` couldn't adopt it — a fresh progress card
305
+ * will open instead). Best-effort. */
306
+ function deleteQueuedCard(
307
+ deps: QueuedCardDeps,
308
+ chatId: string,
309
+ messageId: number,
310
+ ): void {
311
+ void deps
312
+ .robustApiCall(
313
+ // allow-raw-bot-api: deleteMessage routed through robustApiCall
314
+ () => deps.bot.api.deleteMessage(chatId, messageId),
315
+ { chat_id: chatId, verb: 'queued-card.delete-orphan' },
316
+ )
317
+ .catch(() => undefined)
318
+ }
319
+
320
+ /** Numeric thread id from an envelope's string thread id (null/invalid → null). */
321
+ function envThreadIdNum(threadId: string | null): number | null {
322
+ if (threadId == null) return null
323
+ const n = Number(threadId)
324
+ return Number.isFinite(n) ? n : null
325
+ }
326
+
327
+ /** Finalize a queued card carried by a parked entry that is being dropped
328
+ * (folded via `remove`, cap-evicted, or TTL-expired). No-op when the entry
329
+ * never got a card. */
330
+ function finalizeParkedEntryCard(
331
+ deps: QueuedCardDeps,
332
+ entry: ParkedTurnStart,
333
+ html: string,
334
+ ): void {
335
+ if (!QUEUED_CARD_ENABLED) return
336
+ if (entry.queuedCardMessageId == null || entry.chatId == null) return
337
+ finalizeQueuedCard(deps, entry.chatId, envThreadIdNum(entry.threadId), entry.queuedCardMessageId, html)
338
+ }
339
+
204
340
  /**
205
341
  * Mint the turn atom and open its surfaces. This is the ENTIRE pre-#3927
206
342
  * `case 'enqueue'` body, relocated verbatim except for (a) the hoisted
@@ -436,6 +572,41 @@ function beginTurn(deps: StreamRenderDeps, ev: TurnStartEnvelope): void {
436
572
  )
437
573
  }
438
574
  }
575
+ // Part B — ADOPT the queued card. A parked envelope reaching `beginTurn`
576
+ // (via `dequeue`) carries the id of the "queued" card posted at park time.
577
+ // Seed it exactly as the handback adoption does so `renderActivityFeed`
578
+ // EDITS that same message into the live progress card instead of opening a
579
+ // second one, and the turn's own end-of-turn `clearActivitySummary`
580
+ // finalizes it — one continuous lifecycle. Guarded on `activityMessageId`
581
+ // still being null so a card-bearing handback adoption (mutually exclusive
582
+ // in practice) is never clobbered.
583
+ if (QUEUED_CARD_ENABLED && ev.queuedCardMessageId != null && next.activityMessageId == null) {
584
+ next.activityMessageId = ev.queuedCardMessageId
585
+ next.activityEverOpened = true
586
+ process.stderr.write(
587
+ `telegram gateway: queued card adopted turnId=${turnId} card=${ev.queuedCardMessageId}\n`,
588
+ )
589
+ } else if (
590
+ QUEUED_CARD_ENABLED &&
591
+ ev.queuedCardMessageId != null &&
592
+ ev.chatId != null &&
593
+ next.activityMessageId != null &&
594
+ next.activityMessageId !== ev.queuedCardMessageId
595
+ ) {
596
+ // Part B safety net — the handback adoption above WON the surface (its card
597
+ // is now `activityMessageId`) yet this envelope ALSO carries a queued card:
598
+ // the park raced ahead of `noteHandbackRelease`, so the park-time
599
+ // suppression missed and a queued card got posted. It is now a pure
600
+ // duplicate that would otherwise FREEZE on "⏳ Queued" beneath the adopted
601
+ // handback card (the MAJOR). Delete it — the handback card is the one
602
+ // surface; a delete (not a "folded" edit) is right because there is no
603
+ // separate message to explain, just a stray duplicate to remove.
604
+ deleteQueuedCard(deps, ev.chatId, ev.queuedCardMessageId)
605
+ process.stderr.write(
606
+ `telegram gateway: queued card superseded by handback adoption turnId=${turnId} ` +
607
+ `queued=${ev.queuedCardMessageId} adopted=${next.activityMessageId}\n`,
608
+ )
609
+ }
439
610
  // #3544 — arm the turn-long `typing…` loop for EVERY minted turn,
440
611
  // unconditionally. It used to hang off the handback ADOPTION above,
441
612
  // which misses whenever the pre-turn entry was deduped (parallel
@@ -731,7 +902,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
731
902
  )
732
903
  }
733
904
  }
734
- pruneParkedTurnStarts(now)
905
+ pruneParkedTurnStarts(now, (e) => finalizeParkedEntryCard(deps, e, QUEUED_CARD_EXPIRED_HTML))
735
906
  // `enqueue` with no chat can never mint a turn (the whole mint block is
736
907
  // `if (ev.chatId)`-gated); parking it would only pollute the store, so
737
908
  // run the pre-turn drain path verbatim and stop.
@@ -751,11 +922,13 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
751
922
  beginTurn(deps, ev)
752
923
  return
753
924
  }
754
- // PARKED. Deliberately NO surface work here no `promoteQueuedStatus`
755
- // ("On it — replying now" is a lie until the turn actually starts), no
756
- // `typingWrapper.drainAll()` (that drains the LIVE turn's wraps), no card.
757
- // The queued placeholder Hook A already posted is the honest surface, and
758
- // its lifecycle stays owned by the existing reap machinery.
925
+ // PARKED. No `promoteQueuedStatus` ("On itreplying now" is a lie until
926
+ // the turn actually starts) and no `typingWrapper.drainAll()` (that drains
927
+ // the LIVE turn's wraps). Part B DOES post one honest, clearly-marked
928
+ // "queued" card here: on the machine-authoritative path (the default) the
929
+ // legacy Hook A placeholder never fires it lives in the buffer-until-idle
930
+ // branch that returns BEFORE the machine enqueues — so before Part B a
931
+ // parked envelope had no surface at all until it dequeued.
759
932
  //
760
933
  // UNIFORM ACROSS SOURCES — synthetic enqueues (cron fire, subagent
761
934
  // handback, obligation-represent, vault-grant resume, wake inbound) park
@@ -766,7 +939,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
766
939
  // attachment), never by a `dequeue`. Minting for it invented a turn the
767
940
  // CLI never started. FIX B covers the residual case where a mint DOES
768
941
  // land on top of a live atom.
769
- parkTurnStart(
942
+ const parkedEntry = parkTurnStart(
770
943
  {
771
944
  chatId: ev.chatId,
772
945
  messageId: ev.messageId,
@@ -774,12 +947,57 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
774
947
  rawContent: ev.rawContent,
775
948
  },
776
949
  now,
950
+ // Cap-evicted (oldest lost its slot) → finalize its card as expired so
951
+ // it never freezes on "⏳ Queued".
952
+ (evicted) => finalizeParkedEntryCard(deps, evicted, QUEUED_CARD_EXPIRED_HTML),
777
953
  )
778
954
  process.stderr.write(
779
955
  `telegram gateway: turn-start parked (session busy) chat=${ev.chatId} ` +
780
956
  `thread=${enqThreadIdNum ?? '-'} msg=${ev.messageId ?? '-'} ` +
781
957
  `parked=${parkedTurnStarts.length}\n`,
782
958
  )
959
+ // Post the queued card, reply-anchored to the parked message, and store its
960
+ // id back on the envelope so `beginTurn` can adopt+edit it on dequeue. One
961
+ // card per envelope (multiple queued messages each get their own).
962
+ //
963
+ // SUPPRESS when a handback pre-turn signal already owns this turn's surface
964
+ // (the common worker-handoff-while-busy case): `noteHandbackRelease` fired
965
+ // at buffer drain BEFORE this injected handback inbound reached park, so an
966
+ // entry armed for THIS message's turnId is already live. That entry paints
967
+ // (and the dequeued turn adopts) its OWN card, so a queued card here would
968
+ // be a second card that then freezes on "⏳ Queued" (the MAJOR this fixes).
969
+ // Turn-scoped, not key-scoped: an unrelated user message parked on the same
970
+ // topic derives a different turnId and is NOT suppressed. beginTurn carries
971
+ // a belt-and-suspenders cleanup for the residual race where the queued card
972
+ // was already posted before the handback entry armed.
973
+ const parkTurnId = deriveTurnId(ev.chatId, enqThreadIdNum ?? null, ev.messageId)
974
+ const handbackOwnsSurface =
975
+ HANDBACK_PRETURN_ENABLED &&
976
+ parkTurnId != null &&
977
+ handbackPreturnSignal.hasPendingForTurnId(parkTurnId)
978
+ if (handbackOwnsSurface) {
979
+ process.stderr.write(
980
+ `telegram gateway: queued card suppressed (handback owns surface) ` +
981
+ `turnId=${parkTurnId} msg=${ev.messageId ?? '-'}\n`,
982
+ )
983
+ }
984
+ if (QUEUED_CARD_ENABLED && !handbackOwnsSurface) {
985
+ const cardChatId = ev.chatId
986
+ const replyToRaw = ev.messageId != null ? Number(ev.messageId) : null
987
+ const replyTo = replyToRaw != null && Number.isFinite(replyToRaw) ? replyToRaw : null
988
+ void openQueuedCard(deps, cardChatId, enqThreadIdNum ?? null, replyTo).then((cardId) => {
989
+ if (cardId == null) return
990
+ // Still parked → adopt on the next dequeue. Otherwise the entry already
991
+ // dequeued/removed/expired before the async send resolved (a sub-second
992
+ // race), so `beginTurn` ran without the id — delete the orphan card so
993
+ // no frozen duplicate lingers below the turn's own fresh card.
994
+ if (parkedTurnStarts.includes(parkedEntry)) {
995
+ parkedEntry.queuedCardMessageId = cardId
996
+ } else {
997
+ deleteQueuedCard(deps, cardChatId, cardId)
998
+ }
999
+ })
1000
+ }
783
1001
  return
784
1002
  }
785
1003
  case 'dequeue': {
@@ -789,7 +1007,7 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
789
1007
  // RECENT enqueue, not the oldest. An empty store is the normal idle path
790
1008
  // (the enqueue ms earlier already minted) and a dequeue with no parked
791
1009
  // start at all is a no-op, exactly as before.
792
- pruneParkedTurnStarts(Date.now())
1010
+ pruneParkedTurnStarts(Date.now(), (e) => finalizeParkedEntryCard(deps, e, QUEUED_CARD_EXPIRED_HTML))
793
1011
  const parked = takeParkedTurnStart()
794
1012
  if (parked == null) return
795
1013
  beginTurn(deps, parked)
@@ -801,8 +1019,11 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
801
1019
  // its parked envelope so a later `dequeue` cannot mint a spurious turn
802
1020
  // for it. Content-matched: `remove` replays its enqueue's `content`
803
1021
  // byte-for-byte.
804
- pruneParkedTurnStarts(Date.now())
805
- discardParkedTurnStart(ev.rawContent)
1022
+ pruneParkedTurnStarts(Date.now(), (e) => finalizeParkedEntryCard(deps, e, QUEUED_CARD_EXPIRED_HTML))
1023
+ // Part B — finalize the removed envelope's queued card as "folded into the
1024
+ // current task" so it never freezes on "⏳ Queued".
1025
+ const removed = discardParkedTurnStart(ev.rawContent)
1026
+ if (removed != null) finalizeParkedEntryCard(deps, removed, QUEUED_CARD_FOLDED_HTML)
806
1027
  return
807
1028
  }
808
1029
  case 'model': {
@@ -15,7 +15,9 @@
15
15
  * If $TELEGRAM_STATE_DIR is unset → silent skip (renderer just falls back
16
16
  * to its existing precedence ladder). If session_id or tool_use_id is
17
17
  * missing → skip (the row could never be joined anyway). If the rule
18
- * table doesn't produce a label for the tool → skip.
18
+ * table doesn't produce a label for the tool → skip. If the call is a
19
+ * SIDECHAIN (sub-agent) tool call → skip (see isSubagentToolCall / #cross-
20
+ * contamination below).
19
21
  *
20
22
  * Tools intentionally NOT labeled here (handled by existing description
21
23
  * / TodoWrite / sub-agent panels in the renderer):
@@ -364,6 +366,98 @@ export function computeLabel(toolName, input) {
364
366
  return 'Working…'
365
367
  }
366
368
 
369
+ /**
370
+ * True IFF this PreToolUse payload is for a SIDECHAIN (sub-agent) tool call,
371
+ * i.e. one made inside a Task/worker/Explore/Plan sub-agent rather than the
372
+ * top-level thread.
373
+ *
374
+ * WHY THIS EXISTS — the cross-contamination bug. Claude Code fires PreToolUse
375
+ * hooks for sub-agent tool calls too, and (as of 2.1.x) passes the PARENT
376
+ * `session_id` in that payload. The sidecar JSONL is keyed by `session_id`
377
+ * (`tool-labels-<session_id>.jsonl`), so a still-running background worker's
378
+ * labels were being appended into the PARENT session's sidecar. The gateway's
379
+ * real-time draft-mirror tails that file and emits a main-tier `tool_label`
380
+ * event per line, which the renderer binds to whatever turn is live — so a
381
+ * worker dispatched by turn N streamed its Bash step labels into an unrelated
382
+ * turn N+1's progress card. The worker's OWN activity is already surfaced by
383
+ * the worker-feed (sub_agent_tool_use events tailed from the sub-agent's own
384
+ * transcript), so the correct fix is to never write sub-agent labels to the
385
+ * parent sidecar in the first place — the cheapest, at-source link.
386
+ *
387
+ * DISCRIMINATOR — the payload carries sidechain identity. The PreToolUse hook
388
+ * input is built by the CLI's base `Kf(...)` helper, whose real 2.1.219 field
389
+ * shape (verified against the installed binary — see
390
+ * tests/fixtures/pretool-*.json) is:
391
+ * { session_id, transcript_path, cwd, prompt_id?, permission_mode,
392
+ * agent_id, agent_type, effort?, hook_event_name, tool_name, tool_input,
393
+ * tool_use_id }
394
+ * The CLI's own top-level predicate is `agentType === "main"` (its `hb()`
395
+ * seeds the main thread as `{ agentType: "main", agentId: <sessionId> }`),
396
+ * while every sub-agent carries a concrete non-"main" `agent_type` ("worker",
397
+ * "general-purpose", "Explore", "Plan", a custom agent name, …).
398
+ *
399
+ * AUTHORITY ORDER (this matters for correctness, not just style):
400
+ * 1. `agent_type` is AUTHORITATIVE whenever it is a non-empty string:
401
+ * - "main" ⇒ top-level thread (KEEP), full stop, and
402
+ * - anything else ⇒ concrete sub-agent (DROP).
403
+ * Making "main" short-circuit is what prevents the SYMMETRIC false
404
+ * positive: a (hypothetical future) main-tier payload whose `agent_id`
405
+ * no longer equals `session_id` must STILL be kept — the identity
406
+ * corroboration below must never override an explicit "main".
407
+ * 2. Only when `agent_type` is ABSENT/empty (unknown / a legacy CLI) do we
408
+ * fall back to identity corroboration: a sidechain carries the PARENT
409
+ * `session_id` but its OWN `agent_id`, so `agent_id !== session_id`
410
+ * ⇒ sub-agent (DROP). Equal (or either missing) ⇒ main-tier (KEEP).
411
+ *
412
+ * A genuine sub-agent always carries a concrete agent_type (the Task tool
413
+ * requires a subagent_type), so this never drops a real sub-agent while
414
+ * risking a main-tier drop. See `hasAttributionSignal` for the fail-loud
415
+ * guard that fires if a FUTURE CLI drops BOTH identity fields (which would
416
+ * silently revert this whole fix — the drift the canary + warning defend).
417
+ */
418
+ export function isSubagentToolCall(event) {
419
+ if (!event || typeof event !== 'object') return false
420
+ const at = event.agent_type
421
+ // (1) agent_type is authoritative when present: "main" ⇒ keep, else ⇒ drop.
422
+ if (typeof at === 'string' && at.length > 0) return at !== 'main'
423
+ // (2) agent_type absent/empty — corroborate via identity. A sidechain carries
424
+ // the parent session_id but its own agent_id.
425
+ const aid = event.agent_id
426
+ const sid = event.session_id
427
+ return (
428
+ typeof aid === 'string' && aid.length > 0 &&
429
+ typeof sid === 'string' && sid.length > 0 &&
430
+ aid !== sid
431
+ )
432
+ }
433
+
434
+ /**
435
+ * True IFF the payload carries AT LEAST ONE sidechain-attribution field
436
+ * (`agent_type` or `agent_id`) that `isSubagentToolCall` can key on.
437
+ *
438
+ * DRIFT CANARY (runtime half). On CC 2.1.219 EVERY PreToolUse payload carries
439
+ * both `agent_id` and `agent_type` (the base `Kf` builder). If a future CLI
440
+ * renamed/dropped BOTH (e.g. `agent_type`→`agentType`), `isSubagentToolCall`
441
+ * would silently return false for real sub-agents and the cross-turn
442
+ * contamination bug would return with NO test catching it (a frozen fixture
443
+ * can't see the live CLI change). So `main()` emits a one-line stderr WARNING
444
+ * — captured by plugin-logger — when a real tool call arrives with neither
445
+ * field, making the drift LOUD instead of silent. We warn rather than drop:
446
+ * dropping every label on drift would dark-out the whole live feed (a worse,
447
+ * feed-wide regression), whereas a warning lets an operator re-point the
448
+ * predicate at the renamed field. The committed fixtures
449
+ * (tests/fixtures/pretool-*.json) are the compile-time half of the canary.
450
+ */
451
+ export function hasAttributionSignal(event) {
452
+ if (!event || typeof event !== 'object') return false
453
+ const at = event.agent_type
454
+ const aid = event.agent_id
455
+ return (
456
+ (typeof at === 'string' && at.length > 0) ||
457
+ (typeof aid === 'string' && aid.length > 0)
458
+ )
459
+ }
460
+
367
461
  function main() {
368
462
  const raw = readStdin().trim()
369
463
  if (!raw) process.exit(0)
@@ -383,6 +477,30 @@ function main() {
383
477
  const toolName = event.tool_name
384
478
  if (!sessionId || !toolUseId || !toolName) process.exit(0)
385
479
 
480
+ // DRIFT CANARY (runtime half). On 2.1.219 a real PreToolUse payload ALWAYS
481
+ // carries agent_type + agent_id. If a future CLI dropped/renamed BOTH, the
482
+ // sidechain discriminator below would silently fail open and the cross-turn
483
+ // contamination bug would return unnoticed. Warn LOUDLY (plugin-logger tails
484
+ // stderr) so the drift is visible; we do NOT drop here — dropping every label
485
+ // on drift would dark-out the whole live feed. See hasAttributionSignal.
486
+ if (!hasAttributionSignal(event)) {
487
+ try {
488
+ process.stderr.write(
489
+ `[tool-label-pretool] WARNING: PreToolUse payload carries neither ` +
490
+ `agent_type nor agent_id — the sidechain-attribution invariant may be ` +
491
+ `broken by a Claude Code schema change; sub-agent labels may be ` +
492
+ `mis-attributed to the parent session (tool_use_id=${toolUseId}).\n`,
493
+ )
494
+ } catch { /* never block */ }
495
+ }
496
+
497
+ // Sidechain (sub-agent) tool calls arrive here with the PARENT session_id,
498
+ // so writing their label to `tool-labels-<sessionId>.jsonl` pollutes the
499
+ // parent session's sidecar and cross-contaminates an unrelated turn's card.
500
+ // Drop them: the worker-feed already surfaces sub-agent activity. See
501
+ // isSubagentToolCall for the full rationale + discriminator.
502
+ if (isSubagentToolCall(event)) process.exit(0)
503
+
386
504
  let label
387
505
  try {
388
506
  label = computeLabel(toolName, event.tool_input)
@@ -11,13 +11,17 @@
11
11
  *
12
12
  * Load-bearing constraints:
13
13
  * 1. `activityEverOpened = true` is set only at legitimate feed-OPEN signal
14
- * sites in gateway.ts — the send-message success site in
15
- * drainActivitySummary, AND the sub-agent-handback pre-turn ADOPTION site
16
- * (#3268): an adopted turn inherits an already-open pre-turn card via a
17
- * seeded `activityMessageId`, so it only ever EDITs the feed (never hits
18
- * the open branch), and must stamp the flag itself so the turn-end
19
- * DEGRADED check doesn't false-flag it as "feed never opened". Both are
20
- * set-TRUE (never a reset), preserving the sticky-true invariant.
14
+ * sites — the send-message success site in drainActivitySummary, the
15
+ * sub-agent-handback pre-turn ADOPTION site (#3268), AND the queued-card
16
+ * ADOPTION site (#3927 Part B). The two adoption sites are the same shape:
17
+ * an adopted turn inherits an ALREADY-OPEN, user-visible card via a seeded
18
+ * `activityMessageId`, so it only ever EDITs the feed (never hits the open
19
+ * branch), and must stamp the flag itself so the turn-end DEGRADED check
20
+ * doesn't false-flag it as "feed never opened". A parked message's "⏳
21
+ * Queued" card is a real message the user already sees before the turn
22
+ * mints, so adopting it on dequeue IS a feed-open — same rationale as the
23
+ * handback adoption. All three are set-TRUE (never a reset), preserving the
24
+ * sticky-true invariant.
21
25
  * 2. `turn.activityEverOpened = false` NEVER appears in gateway.ts (it is only
22
26
  * initialised to `false` in the turn-initialiser object literal, never reset
23
27
  * via a standalone assignment).
@@ -49,13 +53,16 @@ const laneSrc = readFileSync(
49
53
  const gatewayAndStreamSrc = gatewaySrc + '\n' + streamSrc + '\n' + laneSrc
50
54
 
51
55
  describe('M-2: activityEverOpened sticky-true invariant', () => {
52
- it('activityEverOpened = true appears only at the two feed-OPEN signal sites', () => {
56
+ it('activityEverOpened = true appears only at the three feed-OPEN signal sites', () => {
53
57
  // Site 1: drainActivitySummary send-message success. Site 2: the #3268
54
- // handback pre-turn ADOPTION seed (an adopted turn only edits, so it stamps
55
- // the flag itself). Both are set-TRUE; the sticky invariant (no reset to
56
- // false) is enforced by the next test.
58
+ // handback pre-turn ADOPTION seed. Site 3: the #3927 Part B queued-card
59
+ // ADOPTION seed (stream-render.ts) a parked message's "⏳ Queued" card is
60
+ // an already-open, user-visible card the dequeued turn adopts and only
61
+ // EDITs, so it stamps the flag itself, exactly like the handback adoption.
62
+ // All three are set-TRUE; the sticky invariant (no reset to false) is
63
+ // enforced by the next test.
57
64
  const setTrueMatches = [...gatewayAndStreamSrc.matchAll(/activityEverOpened\s*=\s*true/g)]
58
- expect(setTrueMatches).toHaveLength(2)
65
+ expect(setTrueMatches).toHaveLength(3)
59
66
  })
60
67
 
61
68
  it('turn.activityEverOpened = false never appears (no standalone reset)', () => {
@@ -0,0 +1,15 @@
1
+ {
2
+ "session_id": "e2c9a4f0-1b3d-4c8a-9f21-parent000000",
3
+ "transcript_path": "/home/agent/.claude/projects/-home-agent-work/e2c9a4f0-1b3d-4c8a-9f21-parent000000.jsonl",
4
+ "cwd": "/home/agent/work",
5
+ "permission_mode": "default",
6
+ "agent_id": "e2c9a4f0-1b3d-4c8a-9f21-parent000000",
7
+ "agent_type": "main",
8
+ "effort": "medium",
9
+ "hook_event_name": "PreToolUse",
10
+ "tool_name": "Read",
11
+ "tool_input": {
12
+ "file_path": "/home/agent/work/src/index.ts"
13
+ },
14
+ "tool_use_id": "toolu_01MainReadCall4z"
15
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "session_id": "e2c9a4f0-1b3d-4c8a-9f21-parent000000",
3
+ "transcript_path": "/home/agent/.claude/projects/-home-agent-work/e2c9a4f0-1b3d-4c8a-9f21-parent000000.jsonl",
4
+ "cwd": "/home/agent/work",
5
+ "permission_mode": "default",
6
+ "agent_id": "7a1b9c3d-4e5f-6071-8293-subagentownid0",
7
+ "agent_type": "general-purpose",
8
+ "effort": "medium",
9
+ "hook_event_name": "PreToolUse",
10
+ "tool_name": "Bash",
11
+ "tool_input": {
12
+ "command": "npm run build",
13
+ "description": "Build the worker output"
14
+ },
15
+ "tool_use_id": "toolu_01SubAgentBashCall9x"
16
+ }