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.
- package/dist/agent-scheduler/index.js +10 -1
- package/dist/auth-broker/index.js +15 -6
- package/dist/cli/notion-write-pretool.mjs +10 -1
- package/dist/cli/switchroom.js +1141 -566
- package/dist/host-control/main.js +220 -8
- package/dist/vault/approvals/kernel-server.js +15 -6
- package/dist/vault/broker/server.js +15 -6
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +10 -0
- package/telegram-plugin/dist/bridge/bridge.js +8 -0
- package/telegram-plugin/dist/gateway/gateway.js +450 -206
- package/telegram-plugin/dist/server.js +8 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
- package/telegram-plugin/gateway/compaction-marker.ts +84 -0
- package/telegram-plugin/gateway/gateway.ts +3 -3
- package/telegram-plugin/gateway/handback-preturn-signal.ts +16 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
- package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +277 -18
- package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
- package/telegram-plugin/hooks/hooks.json +11 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +119 -1
- package/telegram-plugin/session-tail.ts +20 -0
- package/telegram-plugin/silence-poke.ts +28 -0
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +19 -12
- package/telegram-plugin/tests/fixtures/pretool-main-2.1.219.json +15 -0
- package/telegram-plugin/tests/fixtures/pretool-subagent-2.1.219.json +16 -0
- package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
- package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
- package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
- package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
- package/telegram-plugin/tests/queued-card-surface.test.ts +262 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
- package/telegram-plugin/tests/sidechain-label-filter-pretool.test.ts +272 -0
- package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +7 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +151 -0
- package/vendor/hindsight-memory/scripts/retain.py +19 -15
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +14 -1
- package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +192 -3
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +18 -2
- 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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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):
|
|
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
|
|
218
|
+
const [dropped] = parkedTurnStarts.splice(i, 1)
|
|
219
|
+
return dropped
|
|
189
220
|
}
|
|
190
221
|
}
|
|
191
|
-
return
|
|
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.
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
//
|
|
925
|
+
// PARKED. No `promoteQueuedStatus` ("On it — replying 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
|
-
|
|
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
|
-
|
|
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)
|