switchroom 0.19.38 → 0.19.40

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 (45) hide show
  1. package/dist/agent-scheduler/index.js +10 -1
  2. package/dist/auth-broker/index.js +15 -6
  3. package/dist/cli/notion-write-pretool.mjs +10 -1
  4. package/dist/cli/switchroom.js +1141 -566
  5. package/dist/host-control/main.js +220 -8
  6. package/dist/vault/approvals/kernel-server.js +15 -6
  7. package/dist/vault/broker/server.js +15 -6
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +10 -0
  10. package/telegram-plugin/dist/bridge/bridge.js +8 -0
  11. package/telegram-plugin/dist/gateway/gateway.js +450 -206
  12. package/telegram-plugin/dist/server.js +8 -0
  13. package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
  14. package/telegram-plugin/gateway/compaction-marker.ts +84 -0
  15. package/telegram-plugin/gateway/gateway.ts +3 -3
  16. package/telegram-plugin/gateway/handback-preturn-signal.ts +16 -0
  17. package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
  18. package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
  19. package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
  20. package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
  21. package/telegram-plugin/gateway/stream-render.ts +277 -18
  22. package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
  23. package/telegram-plugin/hooks/hooks.json +11 -0
  24. package/telegram-plugin/hooks/tool-label-pretool.mjs +119 -1
  25. package/telegram-plugin/session-tail.ts +20 -0
  26. package/telegram-plugin/silence-poke.ts +28 -0
  27. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +19 -12
  28. package/telegram-plugin/tests/fixtures/pretool-main-2.1.219.json +15 -0
  29. package/telegram-plugin/tests/fixtures/pretool-subagent-2.1.219.json +16 -0
  30. package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
  31. package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
  32. package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
  33. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
  34. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
  35. package/telegram-plugin/tests/queued-card-surface.test.ts +262 -0
  36. package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
  37. package/telegram-plugin/tests/sidechain-label-filter-pretool.test.ts +272 -0
  38. package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
  39. package/vendor/hindsight-memory/scripts/lib/client.py +7 -4
  40. package/vendor/hindsight-memory/scripts/lib/config.py +151 -0
  41. package/vendor/hindsight-memory/scripts/retain.py +19 -15
  42. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +14 -1
  43. package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +192 -3
  44. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +18 -2
  45. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +57 -4
@@ -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': {
@@ -2091,6 +2312,10 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2091
2312
  let sentIds: number[] = []
2092
2313
  let chunkCount = 0
2093
2314
  let delivered = false
2315
+ // Set by the send-ack claim callback below once the durable
2316
+ // delivered-keys journal has been written at ACK time — so the finally
2317
+ // block does not journal the same nonce a second time.
2318
+ let earlyJournaled = false
2094
2319
  try {
2095
2320
  // #3276 — the ONE delivery primitive. deliverAnswer routes through
2096
2321
  // `sendReplyChunks` (the same send core executeReply uses) and posts
@@ -2109,6 +2334,33 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2109
2334
  // S4 — anchor the flushed answer to the inbound it answers
2110
2335
  // (null for synthesized turns, which send bare as before).
2111
2336
  replyToMessageId: turn.sourceMessageId,
2337
+ // Duplicate-message race fix — write the DURABLE delivered-keys
2338
+ // journal at SEND-ACK, BEFORE deliverAnswer's read-back probe (which
2339
+ // the cosmetic-edit flood-fuse can defer ~30s). Without this the
2340
+ // journal write happened only in the finally below, AFTER `await
2341
+ // deliverAnswer` returned, so the outbox sweep — which waits just
2342
+ // OUTBOX_QUIET_MS (5s) before checking `deliveredNonces` — saw no
2343
+ // entry and sent a SECOND copy of this answer. Journaling under the
2344
+ // SAME nonce (turn.turnId == the Stop-hook record's turnNonce) makes
2345
+ // the sweep skip-journaled and clears any captured record.
2346
+ onAckClaim: (ackIds) => {
2347
+ try {
2348
+ journalExternalDelivery(
2349
+ {
2350
+ turnNonce: turn.turnId,
2351
+ text: capturedText,
2352
+ tgMessageId: ackIds.length > 0 ? ackIds[0] : undefined,
2353
+ deliverySource: 'flush',
2354
+ },
2355
+ STATE_DIR,
2356
+ )
2357
+ earlyJournaled = true
2358
+ } catch (err) {
2359
+ process.stderr.write(
2360
+ `telegram gateway: turn-flush send-ack journal write failed (non-fatal): ${(err as Error).message}\n`,
2361
+ )
2362
+ }
2363
+ },
2112
2364
  })
2113
2365
  sentIds = delivery.sentIds
2114
2366
  chunkCount = delivery.chunkCount
@@ -2212,7 +2464,14 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2212
2464
  // between this send and the next process's backstop. Journal ONLY on
2213
2465
  // a receipt-gated `delivered` success. Best-effort: a journal-write
2214
2466
  // failure must never demote the successful delivery.
2215
- if (delivered) {
2467
+ //
2468
+ // Duplicate-message race fix: skip when the send-ack claim
2469
+ // (`onAckClaim`) already journaled this nonce at ACK time. That
2470
+ // path is the primary writer now (it runs before the read-back
2471
+ // probe); this finally write only covers the case where the claim
2472
+ // never fired — e.g. a card-only delivery where every chunk landed
2473
+ // but no fresh receipt gated the claim, yet `delivered` still held.
2474
+ if (delivered && !earlyJournaled) {
2216
2475
  try {
2217
2476
  journalExternalDelivery(
2218
2477
  {
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreCompact hook — marks "compaction in flight" for the gateway (#4058).
4
+ *
5
+ * Claude Code PreCompact protocol:
6
+ * Input: JSON on stdin — { session_id, transcript_path, cwd,
7
+ * hook_event_name: "PreCompact", trigger: "auto"|"manual",
8
+ * custom_instructions }
9
+ * Output: exit 0 + empty stdout → proceed. We NEVER block compaction and
10
+ * NEVER exit non-zero.
11
+ *
12
+ * Side effect: writes (overwrites) ONE small JSON file
13
+ * $TELEGRAM_STATE_DIR/compaction-in-flight.json
14
+ * with shape { ts, session_id, trigger }.
15
+ *
16
+ * Why: during mid-turn auto-compaction the model emits zero output and runs
17
+ * zero tools for minutes, so the gateway's silence-poke saw pure silence and
18
+ * fired its 300s "stalled turn" fallback on a healthy turn. The transcript
19
+ * only records compaction at its END (`system/compact_boundary` carries the
20
+ * compaction's own durationMs), so this hook is the one observable START
21
+ * signal. The gateway reads the marker's mtime (compaction-marker.ts) to
22
+ * DEFER the fallback — bounded by the same hard ceiling as the in-flight-tool
23
+ * defer — and removes it when the compact_boundary lands.
24
+ *
25
+ * If $TELEGRAM_STATE_DIR is unset → silent skip (dev / one-shot contexts;
26
+ * the fallback just keeps its pre-#4058 behaviour).
27
+ */
28
+
29
+ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'
30
+ import { join } from 'node:path'
31
+
32
+ function readStdin() {
33
+ try {
34
+ return readFileSync(0, 'utf8')
35
+ } catch {
36
+ return ''
37
+ }
38
+ }
39
+
40
+ function main() {
41
+ const stateDir = process.env.TELEGRAM_STATE_DIR
42
+ if (!stateDir) return
43
+
44
+ let payload = {}
45
+ try {
46
+ payload = JSON.parse(readStdin() || '{}')
47
+ } catch {
48
+ // Unparseable stdin — still write the marker: the mtime alone carries
49
+ // the load-bearing signal; the body fields are diagnostics.
50
+ }
51
+
52
+ try {
53
+ mkdirSync(stateDir, { recursive: true })
54
+ writeFileSync(
55
+ join(stateDir, 'compaction-in-flight.json'),
56
+ JSON.stringify({
57
+ ts: Date.now(),
58
+ session_id: typeof payload.session_id === 'string' ? payload.session_id : null,
59
+ trigger: typeof payload.trigger === 'string' ? payload.trigger : null,
60
+ }) + '\n',
61
+ { mode: 0o600 },
62
+ )
63
+ } catch {
64
+ // Best-effort: a missed marker just reverts one fallback decision to
65
+ // the pre-#4058 behaviour. Never fail the hook (never block compaction).
66
+ }
67
+ }
68
+
69
+ main()
70
+ process.exit(0)
@@ -71,6 +71,17 @@
71
71
  ]
72
72
  }
73
73
  ],
74
+ "PreCompact": [
75
+ {
76
+ "hooks": [
77
+ {
78
+ "type": "command",
79
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/compaction-marker-precompact.mjs\"",
80
+ "timeout": 5
81
+ }
82
+ ]
83
+ }
84
+ ],
74
85
  "Stop": [
75
86
  {
76
87
  "hooks": [
@@ -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)