switchroom 0.19.14 → 0.19.16
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/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +31 -2
- package/telegram-plugin/dist/gateway/gateway.js +1690 -932
- package/telegram-plugin/dist/server.js +31 -2
- package/telegram-plugin/gateway/background-shell-liveness.ts +65 -0
- package/telegram-plugin/gateway/forward-origin.ts +6 -1
- package/telegram-plugin/gateway/gateway.ts +10 -57
- package/telegram-plugin/gateway/narrative-lane.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +25 -23
- package/telegram-plugin/gateway/outbox-listen-markup.ts +67 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +124 -20
- package/telegram-plugin/gateway/rich-message-handler.ts +241 -0
- package/telegram-plugin/gateway/silence-poke-session-event.ts +89 -0
- package/telegram-plugin/gateway/stream-render.ts +107 -15
- package/telegram-plugin/gateway/unhandled-message.ts +14 -0
- package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
- package/telegram-plugin/hooks/narration-classify.mjs +210 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +136 -82
- package/telegram-plugin/narrative-flush.ts +35 -0
- package/telegram-plugin/outbox.ts +73 -3
- package/telegram-plugin/session-tail.ts +88 -1
- package/telegram-plugin/shown-ledger.ts +145 -0
- package/telegram-plugin/silence-poke.ts +118 -1
- package/telegram-plugin/silent-end.ts +42 -0
- package/telegram-plugin/tests/background-shell-liveness.test.ts +72 -0
- package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
- package/telegram-plugin/tests/feed-survival.test.ts +7 -1
- package/telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl +3 -0
- package/telegram-plugin/tests/forward-origin.test.ts +20 -0
- package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +290 -0
- package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
- package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
- package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +253 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -1
- package/telegram-plugin/tests/silence-poke.test.ts +280 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
- package/telegram-plugin/tests/silent-end.test.ts +7 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +66 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
- package/telegram-plugin/tests/voice-normalize-text.test.ts +82 -1
- package/telegram-plugin/tts-normalize.ts +12 -0
- package/telegram-plugin/turn-flush-safety.ts +66 -53
- package/telegram-plugin/voice-normalize-text.ts +100 -0
- package/telegram-plugin/voice-ondemand.ts +71 -0
|
@@ -122,11 +122,13 @@ export interface DeliveredEntry {
|
|
|
122
122
|
tgMessageId?: number
|
|
123
123
|
ts: number
|
|
124
124
|
/**
|
|
125
|
-
* #3510 instrumentation: which machine delivered — the outbox sweep,
|
|
125
|
+
* #3510 instrumentation: which machine delivered — the outbox sweep, the
|
|
126
126
|
* gateway reply-path machinery (reply/stream_reply send, silent-anchor edit,
|
|
127
|
-
* captured-prose bridge)
|
|
127
|
+
* captured-prose bridge), or the turn-end / answer-ready quiescence flush
|
|
128
|
+
* (`'flush'`, added in the #3513 exactly-once-among-backstops follow-up).
|
|
129
|
+
* Absent on pre-#3510 journal lines.
|
|
128
130
|
*/
|
|
129
|
-
deliverySource?: 'sweep' | 'reply-tool'
|
|
131
|
+
deliverySource?: 'sweep' | 'reply-tool' | 'flush'
|
|
130
132
|
/** #3510 instrumentation: see `OutboxRecord.replyAlreadyDeliveredThisTurn`. */
|
|
131
133
|
replyAlreadyDeliveredThisTurn?: boolean
|
|
132
134
|
}
|
|
@@ -307,6 +309,59 @@ export function outboxAlreadyDelivered(nonce: string, stateDir?: string): boolea
|
|
|
307
309
|
return readDeliveredNonces(stateDir).has(nonce)
|
|
308
310
|
}
|
|
309
311
|
|
|
312
|
+
/**
|
|
313
|
+
* True iff a journal entry is a prior BACKSTOP delivery (turn-flush E1/E2,
|
|
314
|
+
* captured-prose bridge E3, outbox sweep E4) — NOT an explicit E0 `reply` /
|
|
315
|
+
* `stream_reply` send (#3513 follow-up, MF2).
|
|
316
|
+
*
|
|
317
|
+
* - `deliverySource === 'sweep'` → E4 backstop.
|
|
318
|
+
* - `deliverySource === 'flush'` → E1/E2 backstop.
|
|
319
|
+
* - `deliverySource === 'reply-tool'` AND `replyAlreadyDeliveredThisTurn ===
|
|
320
|
+
* false` → E3 captured-prose bridge (a backstop; the bridge only fires when
|
|
321
|
+
* NO genuine final answer was delivered this turn).
|
|
322
|
+
* - `deliverySource === 'reply-tool'` AND `replyAlreadyDeliveredThisTurn ===
|
|
323
|
+
* true` → an explicit E0 reply send — NOT a backstop. E0 replies are
|
|
324
|
+
* ungated by design (a turn may send N of them), so they must NOT satisfy a
|
|
325
|
+
* backstop's exactly-once guard (else the guard would eat a legitimate
|
|
326
|
+
* #3510 trailing recap or a multi-reply turn's later bridge).
|
|
327
|
+
*
|
|
328
|
+
* A pre-#3510 line with no `deliverySource` is treated conservatively as NOT a
|
|
329
|
+
* backstop (fail open — never suppress a backstop on ambiguous provenance).
|
|
330
|
+
*/
|
|
331
|
+
export function isBackstopDeliveredEntry(e: DeliveredEntry): boolean {
|
|
332
|
+
if (e.deliverySource === 'sweep' || e.deliverySource === 'flush') return true
|
|
333
|
+
if (e.deliverySource === 'reply-tool' && e.replyAlreadyDeliveredThisTurn === false) return true
|
|
334
|
+
return false
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* True iff `nonce` already has a prior BACKSTOP delivery journaled (see
|
|
339
|
+
* `isBackstopDeliveredEntry`). This is the BACKSTOP-SCOPED exactly-once read the
|
|
340
|
+
* turn-flush (E1/E2) and captured-prose bridge (E3) consult before delivering —
|
|
341
|
+
* unlike `outboxAlreadyDelivered` (any journal line) it does NOT count an
|
|
342
|
+
* explicit E0 reply, so it can never suppress a legitimate second explicit
|
|
343
|
+
* message (#3513 follow-up, MF2).
|
|
344
|
+
*/
|
|
345
|
+
export function backstopAlreadyDelivered(nonce: string, stateDir?: string): boolean {
|
|
346
|
+
if (nonce == null || nonce === '') return false
|
|
347
|
+
const path = join(resolveOutboxDir(stateDir), JOURNAL_FILE)
|
|
348
|
+
if (!existsSync(path)) return false
|
|
349
|
+
try {
|
|
350
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
351
|
+
if (!line) continue
|
|
352
|
+
try {
|
|
353
|
+
const e = JSON.parse(line) as DeliveredEntry
|
|
354
|
+
if (e.turnNonce === nonce && isBackstopDeliveredEntry(e)) return true
|
|
355
|
+
} catch {
|
|
356
|
+
/* skip corrupt line */
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
} catch {
|
|
360
|
+
/* best-effort */
|
|
361
|
+
}
|
|
362
|
+
return false
|
|
363
|
+
}
|
|
364
|
+
|
|
310
365
|
/**
|
|
311
366
|
* Max delivered-keys kept in the journal. On exceeding `JOURNAL_ROTATE_AT` lines
|
|
312
367
|
* the journal is compacted down to the newest `JOURNAL_KEEP` entries — bounding
|
|
@@ -378,6 +433,7 @@ export type OutboxSweepAction =
|
|
|
378
433
|
| 'skip-quiet'
|
|
379
434
|
| 'skip-dedup'
|
|
380
435
|
| 'skip-unroutable'
|
|
436
|
+
| 'skip-ephemeral-shown'
|
|
381
437
|
|
|
382
438
|
export interface OutboxSweepDecision {
|
|
383
439
|
action: OutboxSweepAction
|
|
@@ -408,6 +464,18 @@ export function decideOutboxSweep(input: {
|
|
|
408
464
|
routePrefix?: string
|
|
409
465
|
quietMs?: number
|
|
410
466
|
maxAgeMs?: number
|
|
467
|
+
/**
|
|
468
|
+
* #3513 (correction 1): was this record's text marked ephemeral-shown on the
|
|
469
|
+
* progress card for its turnNonce (the durable shown-ledger)? A hit means the
|
|
470
|
+
* block was already assigned to the ephemeral surface — the single-surface
|
|
471
|
+
* invariant forbids ANY delivery machine, including this out-of-process late
|
|
472
|
+
* sweep, from ALSO delivering it to chat. The ledger only ever contains
|
|
473
|
+
* STRUCTURAL narration (correction 4: never a possibly-terminal answer), so
|
|
474
|
+
* this can never suppress a genuine answer. Checked here (in the backstop
|
|
475
|
+
* decision itself) rather than only at a send seam, because the sweep bypasses
|
|
476
|
+
* `normalizeOutboundBody` (it sends via `bot.api.sendMessage` directly).
|
|
477
|
+
*/
|
|
478
|
+
shownLedgerHit?: boolean
|
|
411
479
|
}): OutboxSweepDecision {
|
|
412
480
|
const {
|
|
413
481
|
record,
|
|
@@ -418,8 +486,10 @@ export function decideOutboxSweep(input: {
|
|
|
418
486
|
routePrefix = '',
|
|
419
487
|
quietMs = OUTBOX_QUIET_MS,
|
|
420
488
|
maxAgeMs = OUTBOX_MAX_AGE_MS,
|
|
489
|
+
shownLedgerHit = false,
|
|
421
490
|
} = input
|
|
422
491
|
if (deliveredNonces.has(record.turnNonce)) return { action: 'skip-journaled' }
|
|
492
|
+
if (shownLedgerHit) return { action: 'skip-ephemeral-shown' }
|
|
423
493
|
const age = now - record.createdAt
|
|
424
494
|
if (age < quietMs) return { action: 'skip-quiet' }
|
|
425
495
|
if (textAlreadyDelivered) return { action: 'skip-dedup' }
|
|
@@ -134,7 +134,23 @@ export type SessionEvent =
|
|
|
134
134
|
// (naive summing across lines over-counts). Null messageId → un-dedupable,
|
|
135
135
|
// counted as-is. Mirrors `sub_agent_usage` but for the parent's OWN tokens.
|
|
136
136
|
| { kind: 'usage'; messageId: string | null; totalTokens: number }
|
|
137
|
-
| { kind: 'tool_result'; toolUseId: string; toolName: string | null; isError?: boolean; errorText?: string
|
|
137
|
+
| { kind: 'tool_result'; toolUseId: string; toolName: string | null; isError?: boolean; errorText?: string
|
|
138
|
+
/**
|
|
139
|
+
* #3519 sharpen: the claude-CLI background-task id when THIS tool_result
|
|
140
|
+
* is the launch acknowledgement of a shell moved to the background (a
|
|
141
|
+
* foreground Bash that exceeded the CLI foreground window, or an explicit
|
|
142
|
+
* run_in_background:true). Sourced PRIMARILY from the structured
|
|
143
|
+
* top-level `toolUseResult.backgroundTaskId` field (version-robust), with
|
|
144
|
+
* a regex on the `content` string as a secondary. Absent on ordinary
|
|
145
|
+
* (completed-in-foreground) tool_results. Marks the shell ALIVE. */
|
|
146
|
+
backgroundTaskId?: string }
|
|
147
|
+
/**
|
|
148
|
+
* #3519 sharpen: a claude-CLI `<task-notification>` — the proactive
|
|
149
|
+
* completion signal the CLI enqueues when a backgrounded shell finishes
|
|
150
|
+
* (`<status>completed</status>`) or errors. Marks the shell DEAD, restoring
|
|
151
|
+
* ~300s wedge recovery once the launching bash is no longer running.
|
|
152
|
+
*/
|
|
153
|
+
| { kind: 'task_notification'; taskId: string; status: string }
|
|
138
154
|
// `reason` is set ONLY by an internal gateway-synthesized turn_end (never by
|
|
139
155
|
// the JSONL projection). `answer-ready-quiescence` (PR A) marks the positive
|
|
140
156
|
// deterministic quiescence-flush signal, which — unlike the orphaned-reply
|
|
@@ -259,6 +275,60 @@ function extractToolResultErrorText(content: unknown): string {
|
|
|
259
275
|
return ''
|
|
260
276
|
}
|
|
261
277
|
|
|
278
|
+
/**
|
|
279
|
+
* #3519 sharpen — ALIVE marker (primary): read the claude-CLI background-task
|
|
280
|
+
* id off the structured, sibling top-level `toolUseResult.backgroundTaskId`
|
|
281
|
+
* field of a `type:"user"` transcript line. This is the version-robust source
|
|
282
|
+
* (a named JSON field, not prose). Real shape (carrie session
|
|
283
|
+
* a6d2d33a-…, v2.1.197, line 109):
|
|
284
|
+
* "toolUseResult":{…,"backgroundTaskId":"bxa4sv3dq"}
|
|
285
|
+
* Returns the id, or null when the line carries no backgrounded shell.
|
|
286
|
+
*/
|
|
287
|
+
export function parseBackgroundTaskId(obj: Record<string, unknown>): string | null {
|
|
288
|
+
const tur = obj.toolUseResult
|
|
289
|
+
if (typeof tur === 'object' && tur != null) {
|
|
290
|
+
const id = (tur as Record<string, unknown>).backgroundTaskId
|
|
291
|
+
if (typeof id === 'string' && id.length > 0) return id
|
|
292
|
+
}
|
|
293
|
+
return null
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* #3519 sharpen — ALIVE marker (secondary): match the launch STRING in the
|
|
298
|
+
* tool_result `content` when the structured field is absent (older CLI, or a
|
|
299
|
+
* shape change that keeps the human string). Real bytes (same line 109):
|
|
300
|
+
* "Command running in background with ID: bxa4sv3dq. Output is being written…"
|
|
301
|
+
* DELIBERATELY the fallback, not the primary — if BOTH miss (CLI changed the
|
|
302
|
+
* string too) the caller degrades to the 900s-bounded sawBash guard. Accepts
|
|
303
|
+
* the same string|content-block shapes as extractToolResultErrorText.
|
|
304
|
+
*/
|
|
305
|
+
export function parseBackgroundLaunchString(content: unknown): string | null {
|
|
306
|
+
const text = typeof content === 'string'
|
|
307
|
+
? content
|
|
308
|
+
: extractToolResultErrorText(content)
|
|
309
|
+
const m = text.match(/Command running in background with ID: (\w+)/)
|
|
310
|
+
return m != null ? m[1] : null
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* #3519 sharpen — DEAD marker: parse a claude-CLI `<task-notification>` block.
|
|
315
|
+
* The CLI enqueues this proactively when a backgrounded shell finishes. Real
|
|
316
|
+
* bytes (carrie session a6d2d33a-…, v2.1.197, line 175 queue-operation enqueue
|
|
317
|
+
* content, and mirrored line 180 attachment):
|
|
318
|
+
* "<task-notification>\n<task-id>bxa4sv3dq</task-id>\n…\n<status>completed</status>\n…"
|
|
319
|
+
* Returns {taskId,status} when both tags are present, else null (so an
|
|
320
|
+
* ordinary inbound enqueue falls through to the normal user-turn path).
|
|
321
|
+
*/
|
|
322
|
+
export function parseTaskNotification(
|
|
323
|
+
content: string,
|
|
324
|
+
): { taskId: string; status: string } | null {
|
|
325
|
+
if (!content.includes('<task-notification>')) return null
|
|
326
|
+
const idM = content.match(/<task-id>([^<]+)<\/task-id>/)
|
|
327
|
+
const stM = content.match(/<status>([^<]+)<\/status>/)
|
|
328
|
+
if (idM == null || stM == null) return null
|
|
329
|
+
return { taskId: idM[1].trim(), status: stM[1].trim() }
|
|
330
|
+
}
|
|
331
|
+
|
|
262
332
|
/**
|
|
263
333
|
* THE single text→narrative projection primitive. Both projectTranscriptLine
|
|
264
334
|
* and projectSubagentLine derive their text events through this helper so
|
|
@@ -481,6 +551,15 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
|
|
|
481
551
|
const op = obj.operation as string | undefined
|
|
482
552
|
if (op === 'enqueue') {
|
|
483
553
|
const content = (obj.content as string | undefined) ?? ''
|
|
554
|
+
// #3519 sharpen: a `<task-notification>` is NOT a real inbound user
|
|
555
|
+
// turn — it is the claude CLI's proactive background-shell completion
|
|
556
|
+
// signal, enqueued as a synthetic command. Project it as the DEAD
|
|
557
|
+
// marker so the liveness registry can drop the shell (restoring ~300s
|
|
558
|
+
// wedge recovery) rather than mis-reading it as a user message.
|
|
559
|
+
const notif = parseTaskNotification(content)
|
|
560
|
+
if (notif != null) {
|
|
561
|
+
return [{ kind: 'task_notification', taskId: notif.taskId, status: notif.status }]
|
|
562
|
+
}
|
|
484
563
|
const { chatId, messageId, threadId } = parseChannelMeta(content)
|
|
485
564
|
return [{ kind: 'enqueue', chatId, messageId, threadId, rawContent: content }]
|
|
486
565
|
}
|
|
@@ -578,6 +657,11 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
|
|
|
578
657
|
const message = obj.message as Record<string, unknown> | undefined
|
|
579
658
|
const content = message?.content as Array<Record<string, unknown>> | undefined
|
|
580
659
|
if (!Array.isArray(content)) return []
|
|
660
|
+
// #3519 sharpen: the background-launch id is a per-LINE fact carried on
|
|
661
|
+
// the sibling top-level `toolUseResult.backgroundTaskId` (version-robust
|
|
662
|
+
// structured field), with the launch STRING as a secondary. Parsed once
|
|
663
|
+
// and attached to this line's tool_result event to mark the shell ALIVE.
|
|
664
|
+
const backgroundTaskId = parseBackgroundTaskId(obj)
|
|
581
665
|
const events: SessionEvent[] = []
|
|
582
666
|
for (const c of content) {
|
|
583
667
|
if (c.type === 'tool_result') {
|
|
@@ -588,6 +672,9 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
|
|
|
588
672
|
toolName: null,
|
|
589
673
|
isError,
|
|
590
674
|
errorText: isError ? extractToolResultErrorText(c.content) : undefined,
|
|
675
|
+
backgroundTaskId: backgroundTaskId
|
|
676
|
+
?? parseBackgroundLaunchString(c.content)
|
|
677
|
+
?? undefined,
|
|
591
678
|
})
|
|
592
679
|
}
|
|
593
680
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shown-ledger.ts — the durable "this block was surfaced on the ephemeral
|
|
3
|
+
* progress card, never deliver it to chat" mark (switchroom#3513 §4).
|
|
4
|
+
*
|
|
5
|
+
* ## Role in the single-surface invariant
|
|
6
|
+
* Every trailing plain-text block a turn produces is assigned to exactly one
|
|
7
|
+
* surface: the delivered chat answer, the ephemeral progress card, or
|
|
8
|
+
* suppression. In-process (E1/E2 turn-flush, the card) that assignment is made
|
|
9
|
+
* once by the shared structural classifier (`hooks/narration-classify.mjs`).
|
|
10
|
+
* But the out-of-process backstops — the Stop-hook captured-prose bridge (E3)
|
|
11
|
+
* and the durable outbox heartbeat sweep (E4) — run in a separate process / on
|
|
12
|
+
* a later tick and cannot share an in-memory decision. So the component that
|
|
13
|
+
* paints a block as ephemeral narration appends `{turnNonce, hash}` here, and
|
|
14
|
+
* E3/E4 treat a ledger hit exactly like a silent marker: not deliverable.
|
|
15
|
+
*
|
|
16
|
+
* ## Correction 4 — only structural narration is ever marked
|
|
17
|
+
* The writer (the mid-turn narrative paint in `gateway/narrative-lane.ts`) marks
|
|
18
|
+
* ONLY blocks that are provably followed by more turn activity (a new narrative
|
|
19
|
+
* block or a tool call), i.e. structural narration — NEVER a possibly-terminal
|
|
20
|
+
* block (the timer-paint / turn-end paint that could turn out to be the real
|
|
21
|
+
* answer). This keeps the invariant fail-open for answers: a genuine unsent
|
|
22
|
+
* answer is never in the ledger, so a ledger check can never suppress it (a drop
|
|
23
|
+
* is worse than a duplicate). The structural rule, not the ledger, is the sole
|
|
24
|
+
* guard for the answer path.
|
|
25
|
+
*
|
|
26
|
+
* ## Envelope-bearing-only (documented should-fix, #3513 §8)
|
|
27
|
+
* The ledger is keyed by the turnNonce (`deriveTurnId` → `${chatKey}#${msgId}`),
|
|
28
|
+
* which is reachable-matching ONLY for envelope-bearing turns — `deriveTurnId`
|
|
29
|
+
* returns null without a message_id (`gateway/derive-turn-id.ts`), and the
|
|
30
|
+
* outbox falls back to a sha nonce. So the ledger is a best-effort belt for
|
|
31
|
+
* envelope-bearing turns (the common Telegram-inbound shape); for envelope-less
|
|
32
|
+
* turns (handback / background / cron) the structural classifier is the sole
|
|
33
|
+
* guard. The writer skips a null turnId; the readers simply miss and fall
|
|
34
|
+
* through to the structural rule — never a false suppression.
|
|
35
|
+
*
|
|
36
|
+
* Best-effort throughout: a missing / unwritable ledger degrades to the
|
|
37
|
+
* structural-rule-only behaviour (leak possible, drop impossible).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
existsSync,
|
|
42
|
+
mkdirSync,
|
|
43
|
+
readFileSync,
|
|
44
|
+
renameSync,
|
|
45
|
+
writeFileSync,
|
|
46
|
+
appendFileSync,
|
|
47
|
+
} from 'node:fs'
|
|
48
|
+
import { join } from 'node:path'
|
|
49
|
+
import { resolveOutboxDir } from './outbox.js'
|
|
50
|
+
import { ledgerHashHex } from './hooks/narration-classify.mjs'
|
|
51
|
+
|
|
52
|
+
const SHOWN_LEDGER_FILE = 'shown-ledger.jsonl'
|
|
53
|
+
|
|
54
|
+
/** Bounded growth — compact to the newest KEEP entries past ROTATE_AT lines. */
|
|
55
|
+
export const SHOWN_LEDGER_KEEP = 2_000
|
|
56
|
+
export const SHOWN_LEDGER_ROTATE_AT = 4_000
|
|
57
|
+
|
|
58
|
+
/** One appended shown-block entry. */
|
|
59
|
+
export interface ShownLedgerEntry {
|
|
60
|
+
turnNonce: string
|
|
61
|
+
hash: string
|
|
62
|
+
ts: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function shownLedgerPath(stateDir?: string): string {
|
|
66
|
+
return join(resolveOutboxDir(stateDir), SHOWN_LEDGER_FILE)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Mark a block as ephemeral-shown for `turnNonce`. Correction 4: callers MUST
|
|
71
|
+
* only pass blocks that are structural narration (followed by more turn
|
|
72
|
+
* activity), never a possibly-terminal block. A null/empty turnNonce is ignored
|
|
73
|
+
* (envelope-less turn — the structural rule is the sole guard there).
|
|
74
|
+
* Best-effort; never throws.
|
|
75
|
+
*/
|
|
76
|
+
export function appendShownBlock(
|
|
77
|
+
turnNonce: string | null,
|
|
78
|
+
text: string,
|
|
79
|
+
stateDir?: string,
|
|
80
|
+
now: number = Date.now(),
|
|
81
|
+
): void {
|
|
82
|
+
if (turnNonce == null || turnNonce === '') return
|
|
83
|
+
const trimmed = typeof text === 'string' ? text.trim() : ''
|
|
84
|
+
if (trimmed.length === 0) return
|
|
85
|
+
const dir = resolveOutboxDir(stateDir)
|
|
86
|
+
const path = shownLedgerPath(stateDir)
|
|
87
|
+
try {
|
|
88
|
+
mkdirSync(dir, { recursive: true })
|
|
89
|
+
const entry: ShownLedgerEntry = { turnNonce, hash: ledgerHashHex(trimmed), ts: now }
|
|
90
|
+
appendFileSync(path, JSON.stringify(entry) + '\n', { mode: 0o600 })
|
|
91
|
+
compactIfLarge(path)
|
|
92
|
+
} catch {
|
|
93
|
+
/* best-effort */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The set of block hashes marked ephemeral-shown for `turnNonce`. */
|
|
98
|
+
export function readShownHashes(turnNonce: string | null, stateDir?: string): Set<string> {
|
|
99
|
+
const set = new Set<string>()
|
|
100
|
+
if (turnNonce == null || turnNonce === '') return set
|
|
101
|
+
const path = shownLedgerPath(stateDir)
|
|
102
|
+
if (!existsSync(path)) return set
|
|
103
|
+
try {
|
|
104
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
105
|
+
if (!line) continue
|
|
106
|
+
try {
|
|
107
|
+
const e = JSON.parse(line) as ShownLedgerEntry
|
|
108
|
+
if (e.turnNonce === turnNonce && typeof e.hash === 'string') set.add(e.hash)
|
|
109
|
+
} catch {
|
|
110
|
+
/* skip corrupt line */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
/* best-effort */
|
|
115
|
+
}
|
|
116
|
+
return set
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Was `text` marked ephemeral-shown for `turnNonce`? The out-of-process
|
|
121
|
+
* suppression check consumed by the E3 captured-prose decision and the E4 sweep.
|
|
122
|
+
*/
|
|
123
|
+
export function isShownBlock(
|
|
124
|
+
turnNonce: string | null,
|
|
125
|
+
text: string,
|
|
126
|
+
stateDir?: string,
|
|
127
|
+
): boolean {
|
|
128
|
+
if (turnNonce == null || turnNonce === '') return false
|
|
129
|
+
const trimmed = typeof text === 'string' ? text.trim() : ''
|
|
130
|
+
if (trimmed.length === 0) return false
|
|
131
|
+
return readShownHashes(turnNonce, stateDir).has(ledgerHashHex(trimmed))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function compactIfLarge(path: string): void {
|
|
135
|
+
try {
|
|
136
|
+
const lines = readFileSync(path, 'utf8').split('\n').filter((l) => l.length > 0)
|
|
137
|
+
if (lines.length <= SHOWN_LEDGER_ROTATE_AT) return
|
|
138
|
+
const kept = lines.slice(lines.length - SHOWN_LEDGER_KEEP)
|
|
139
|
+
const tmp = `${path}.${process.pid}.compact`
|
|
140
|
+
writeFileSync(tmp, kept.join('\n') + '\n', { mode: 0o600 })
|
|
141
|
+
renameSync(tmp, path)
|
|
142
|
+
} catch {
|
|
143
|
+
/* best-effort */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -93,6 +93,34 @@ export interface SilencePokeState {
|
|
|
93
93
|
* clock — the design choice in this module's header is preserved.
|
|
94
94
|
* We only enrich the fallback TEXT, not the timing. */
|
|
95
95
|
inFlightTools: Map<string, { name: string; startedAt: number; label: string | null }>
|
|
96
|
+
/**
|
|
97
|
+
* #3519: true once ANY `Bash` tool_use has been observed in this turn.
|
|
98
|
+
* A foreground `Bash` that exceeds the claude-CLI foreground window is
|
|
99
|
+
* auto-moved to the background: its `tool_result` returns to the model
|
|
100
|
+
* (so `inFlightTools` empties and `isLegitimatelyWorking()`'s foreground
|
|
101
|
+
* / async-dispatch checks all go false), yet the process keeps running
|
|
102
|
+
* and the model sits silent waiting on it. That silent gap is invisible
|
|
103
|
+
* to every existing "still working" signal, so the 300s fallback fired
|
|
104
|
+
* mid-work — nulling `currentTurn`, tearing down the pinned progress
|
|
105
|
+
* card, and letting the next tool burst mint a BRAND-NEW card (the
|
|
106
|
+
* stacked-cards bug). Arming this flag on the first Bash of the turn lets
|
|
107
|
+
* the fallback defer such gaps. Turn-scoped (set here, only cleared by a
|
|
108
|
+
* fresh `startTurn`); bounded by `fallbackHardCeiling` so a genuinely
|
|
109
|
+
* wedged bash-turn still unwedges at the ceiling. Does NOT reset the
|
|
110
|
+
* silence clock — a real reply / feed edit still does that; this only
|
|
111
|
+
* gates the terminal teardown. */
|
|
112
|
+
sawBashThisTurn: boolean
|
|
113
|
+
/**
|
|
114
|
+
* #3519 sharpen: claude-CLI background shells PROVEN alive right now. A
|
|
115
|
+
* shell is added on its launch marker (structured `backgroundTaskId`, via
|
|
116
|
+
* `noteBackgroundShellAlive`) and removed when the CLI proactively reports
|
|
117
|
+
* it done (`<task-notification>` completed/failed) or the model `KillShell`s
|
|
118
|
+
* it (via `noteBackgroundShellDead`). Non-empty ⇒ a process is running, so
|
|
119
|
+
* defer the 300s teardown. Empty ⇒ nothing running, so a FINISHED bash no
|
|
120
|
+
* longer defers — restoring ~300s wedge recovery that the coarse
|
|
121
|
+
* `sawBashThisTurn` guard held to 900s. Turn-scoped (cleared by startTurn),
|
|
122
|
+
* bounded by `fallbackHardCeiling` like every other defer. */
|
|
123
|
+
aliveShells: Set<string>
|
|
96
124
|
}
|
|
97
125
|
|
|
98
126
|
export interface ThresholdsMs {
|
|
@@ -217,6 +245,21 @@ const state = new Map<string, SilencePokeState>()
|
|
|
217
245
|
let timer: ReturnType<typeof setInterval> | null = null
|
|
218
246
|
let activeDeps: SilencePokeDeps | null = null
|
|
219
247
|
|
|
248
|
+
/**
|
|
249
|
+
* #3519 sharpen — deterministic SAFE-DEGRADATION latch (process-scoped).
|
|
250
|
+
* Flipped true the first time `noteBackgroundShellAlive` fires, i.e. the
|
|
251
|
+
* moment the session-tail marker parser resolves a real `backgroundTaskId`
|
|
252
|
+
* against the live claude CLI. Its purpose is to distinguish two look-alike
|
|
253
|
+
* states that both present as "a Bash ran but no shell is registered alive":
|
|
254
|
+
* • CLI markers work, the bash simply FINISHED → trust the empty alive-set,
|
|
255
|
+
* let the 300s fallback fire (fast wedge recovery restored); and
|
|
256
|
+
* • CLI changed its markers so the parser never matches → we CANNOT tell a
|
|
257
|
+
* live auto-backgrounded bash from a finished one, so fall back to the
|
|
258
|
+
* coarse 900s-bounded `sawBashThisTurn` guard (never stack, never hang).
|
|
259
|
+
* Once ANY marker has parsed, the CLI is proven compatible and the alive-set
|
|
260
|
+
* is authoritative. Never seen ⇒ stay conservative. Reset by tests only. */
|
|
261
|
+
let bgMarkerParserConfirmed = false
|
|
262
|
+
|
|
220
263
|
/**
|
|
221
264
|
* True iff the kill switch is OFF. Re-read every call so tests can
|
|
222
265
|
* toggle process.env without reloading the module.
|
|
@@ -238,9 +281,40 @@ export function startTurn(key: string, now: number): void {
|
|
|
238
281
|
fallbackFired: false,
|
|
239
282
|
floorFired: false,
|
|
240
283
|
inFlightTools: new Map(),
|
|
284
|
+
sawBashThisTurn: false,
|
|
285
|
+
aliveShells: new Set(),
|
|
241
286
|
})
|
|
242
287
|
}
|
|
243
288
|
|
|
289
|
+
/**
|
|
290
|
+
* #3519 sharpen: register a claude-CLI background shell as ALIVE for `key`.
|
|
291
|
+
* Called by the gateway when session-tail resolves a `backgroundTaskId` on a
|
|
292
|
+
* tool_result (a foreground Bash auto-moved to the background, or an explicit
|
|
293
|
+
* run_in_background:true). Flips the process-scoped parser-confirmed latch so
|
|
294
|
+
* the safe-degradation path knows the CLI markers are compatible. No-op when
|
|
295
|
+
* the key has no live turn (the launch outlived its turn — the cross-turn
|
|
296
|
+
* ambient owns that case, not the 300s teardown).
|
|
297
|
+
*/
|
|
298
|
+
export function noteBackgroundShellAlive(key: string, shellId: string): void {
|
|
299
|
+
bgMarkerParserConfirmed = true
|
|
300
|
+
const s = state.get(key)
|
|
301
|
+
if (s == null) return
|
|
302
|
+
s.aliveShells.add(shellId)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* #3519 sharpen: mark a background shell DEAD for `key`. Called by the gateway
|
|
307
|
+
* on a `<task-notification>` (completed/failed) or a `KillShell`. Idempotent —
|
|
308
|
+
* removing an unknown id (already cleared, or launched in a prior turn) is a
|
|
309
|
+
* no-op. Once the set empties, a subsequent >300s silence is a real wedge and
|
|
310
|
+
* the fallback fires at ~300s.
|
|
311
|
+
*/
|
|
312
|
+
export function noteBackgroundShellDead(key: string, shellId: string): void {
|
|
313
|
+
const s = state.get(key)
|
|
314
|
+
if (s == null) return
|
|
315
|
+
s.aliveShells.delete(shellId)
|
|
316
|
+
}
|
|
317
|
+
|
|
244
318
|
/**
|
|
245
319
|
* Record a fresh user-visible outbound message (reply or stream_reply
|
|
246
320
|
* first-emit). Resets the silence clock so the 300s fallback is measured
|
|
@@ -313,6 +387,14 @@ export function noteToolStart(
|
|
|
313
387
|
const s = state.get(key)
|
|
314
388
|
if (s == null) return
|
|
315
389
|
s.inFlightTools.set(toolUseId, { name, startedAt: now, label })
|
|
390
|
+
// #3519: arm the background-bash defer on the first Bash of the turn.
|
|
391
|
+
// A foreground Bash can be auto-moved to the background by the claude CLI
|
|
392
|
+
// once it crosses the foreground window; its tool_result then returns
|
|
393
|
+
// (draining inFlightTools) while the process keeps running and the model
|
|
394
|
+
// goes silent waiting on it. That gap is invisible to every other "still
|
|
395
|
+
// working" signal, so without this the 300s fallback tore down the pinned
|
|
396
|
+
// card mid-work and the next burst minted a fresh one (stacked cards).
|
|
397
|
+
if (name === 'Bash') s.sawBashThisTurn = true
|
|
316
398
|
}
|
|
317
399
|
|
|
318
400
|
/**
|
|
@@ -541,8 +623,13 @@ function tick(now: number): void {
|
|
|
541
623
|
// 2. Legacy `deferFallbackWhileToolInFlight` boolean — covers only
|
|
542
624
|
// `inFlightTools.size > 0`; kept for test fixtures that set it
|
|
543
625
|
// directly without wiring the callback.
|
|
626
|
+
// 3. #3519 `sawBashThisTurn` — covers the claude-CLI-side background
|
|
627
|
+
// bash gap the two paths above are blind to (foreground Bash moved
|
|
628
|
+
// to background: tool_result returned, process still running, model
|
|
629
|
+
// silent). Independent of the callback so it holds even when
|
|
630
|
+
// `isLegitimatelyWorking()` returns false.
|
|
544
631
|
//
|
|
545
|
-
// In
|
|
632
|
+
// In all cases: `continue` WITHOUT setting fallbackFired so the next
|
|
546
633
|
// tick re-checks. Once the work signal clears and the turn stays silent
|
|
547
634
|
// past the base threshold, or the ceiling is crossed, the fallback fires.
|
|
548
635
|
const ceiling = thresholds.fallbackHardCeiling ?? Number.POSITIVE_INFINITY
|
|
@@ -551,6 +638,30 @@ function tick(now: number): void {
|
|
|
551
638
|
const forceDisable = process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS === '0'
|
|
552
639
|
if (!forceDisable && activeDeps.isLegitimatelyWorking != null) {
|
|
553
640
|
if (activeDeps.isLegitimatelyWorking(key)) continue
|
|
641
|
+
// #3519 sharpen: even when the callback reports "not working", a
|
|
642
|
+
// `Bash` earlier this turn may have been auto-moved to the CLI-side
|
|
643
|
+
// background (its tool_result returned, so every foreground /
|
|
644
|
+
// async-dispatch signal is false, yet the process is alive and the
|
|
645
|
+
// model sits silent on it) — the gap `isLegitimatelyWorking` is
|
|
646
|
+
// blind to by construction. Two-layer defer, sharp then safe:
|
|
647
|
+
//
|
|
648
|
+
// (1) PROVEN-alive — a background shell registered from its
|
|
649
|
+
// structured `backgroundTaskId` launch marker and not yet
|
|
650
|
+
// reported dead (`<task-notification>` completed / KillShell).
|
|
651
|
+
// A process is running RIGHT NOW, so defer. When it finishes,
|
|
652
|
+
// the alive-set empties and the fallback fires at ~300s —
|
|
653
|
+
// restoring fast wedge recovery the coarse guard held to 900s.
|
|
654
|
+
if (s.aliveShells.size > 0) continue
|
|
655
|
+
// (2) SAFE DEGRADATION — only while the CLI markers have NEVER
|
|
656
|
+
// parsed (`!bgMarkerParserConfirmed`): we cannot then tell a
|
|
657
|
+
// live auto-backgrounded bash from a finished one, so fall
|
|
658
|
+
// back to the coarse turn-scoped `sawBashThisTurn` guard —
|
|
659
|
+
// 900s-bounded by `fallbackHardCeiling` (never stacks, never
|
|
660
|
+
// hangs). Once ANY marker has parsed, the CLI is proven
|
|
661
|
+
// compatible, this layer switches off, and layer (1) alone
|
|
662
|
+
// governs. Scoped to the modern callback-wired path so the
|
|
663
|
+
// legacy defer-off / defer-bool fixtures keep their semantics.
|
|
664
|
+
if (!bgMarkerParserConfirmed && s.sawBashThisTurn) continue
|
|
554
665
|
} else if (!forceDisable && activeDeps.deferFallbackWhileToolInFlight === true && s.inFlightTools.size > 0) {
|
|
555
666
|
continue
|
|
556
667
|
}
|
|
@@ -657,4 +768,10 @@ export function __getStateForTests(key: string): SilencePokeState | undefined {
|
|
|
657
768
|
export function __resetAllForTests(): void {
|
|
658
769
|
state.clear()
|
|
659
770
|
stopTimer()
|
|
771
|
+
bgMarkerParserConfirmed = false
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Test-only: peek at the process-scoped #3519 safe-degradation latch. */
|
|
775
|
+
export function __bgMarkerParserConfirmedForTests(): boolean {
|
|
776
|
+
return bgMarkerParserConfirmed
|
|
660
777
|
}
|
|
@@ -86,6 +86,29 @@ export interface SilentEndDeps {
|
|
|
86
86
|
hasOutboundDeliveredSince?: (chatId: string, sinceMs: number, threadId?: number | null) => boolean
|
|
87
87
|
/** Wall-clock now, ms. Defaults to `Date.now`; injectable for tests. */
|
|
88
88
|
now?: () => number
|
|
89
|
+
/**
|
|
90
|
+
* #3513 (correction 1): was `text` marked ephemeral-shown on the progress card
|
|
91
|
+
* for `turnNonce` (the durable shown-ledger)? Injected so the captured-prose
|
|
92
|
+
* bridge (E3) — which sends via `bot.api.sendMessage` directly, bypassing
|
|
93
|
+
* `normalizeOutboundBody` — refuses to deliver a block that was already
|
|
94
|
+
* assigned to the ephemeral surface. The ledger only ever holds STRUCTURAL
|
|
95
|
+
* narration (correction 4: never a possibly-terminal answer), so this can
|
|
96
|
+
* never suppress a genuine answer. Omitted → the check is skipped (the
|
|
97
|
+
* structural classifier remains the guard).
|
|
98
|
+
*/
|
|
99
|
+
isBlockShown?: (turnNonce: string | null | undefined, text: string) => boolean
|
|
100
|
+
/**
|
|
101
|
+
* #3513 follow-up (MF2): has a prior BACKSTOP already delivered this turn's
|
|
102
|
+
* answer, per the durable delivered-keys journal? Injected so the captured-
|
|
103
|
+
* prose bridge (E3) enforces exactly-once-among-backstops DURABLY — if the
|
|
104
|
+
* turn-flush backstop (E1/E2, `deliverySource:'flush'`) or the outbox sweep
|
|
105
|
+
* (E4, `'sweep'`) already delivered this nonce, the bridge must NOT deliver a
|
|
106
|
+
* second copy, even across a process boundary the in-memory ledger can't see.
|
|
107
|
+
* This counts ONLY backstop deliveries (via `backstopAlreadyDelivered`), never
|
|
108
|
+
* an explicit E0 reply (#3510 recap), so a legitimate later explicit reply is
|
|
109
|
+
* unaffected. Omitted → the check is skipped (never suppress on doubt).
|
|
110
|
+
*/
|
|
111
|
+
backstopDeliveredNonceHit?: (turnNonce: string | null | undefined) => boolean
|
|
89
112
|
}
|
|
90
113
|
|
|
91
114
|
/**
|
|
@@ -306,6 +329,8 @@ export interface CapturedProseDecision {
|
|
|
306
329
|
| 'turnkey-mismatch'
|
|
307
330
|
| 'turnid-mismatch'
|
|
308
331
|
| 'no-substantive-prose'
|
|
332
|
+
| 'ephemeral-shown'
|
|
333
|
+
| 'already-delivered'
|
|
309
334
|
}
|
|
310
335
|
|
|
311
336
|
/**
|
|
@@ -352,6 +377,23 @@ export function decideCapturedProseDelivery(
|
|
|
352
377
|
}
|
|
353
378
|
const text = typeof state.pendingText === 'string' ? state.pendingText : ''
|
|
354
379
|
if (text.trim().length < minChars) return { deliver: false, reason: 'no-substantive-prose' }
|
|
380
|
+
// #3513: the persisted prose was already surfaced on the ephemeral progress
|
|
381
|
+
// card for this turn (durable shown-ledger) — the single-surface invariant
|
|
382
|
+
// forbids the bridge from ALSO delivering it to chat. The ledger only holds
|
|
383
|
+
// structural narration (never a possibly-terminal answer), so this can never
|
|
384
|
+
// suppress a genuine answer.
|
|
385
|
+
if (deps?.isBlockShown?.(state.turnId, text) === true) {
|
|
386
|
+
return { deliver: false, reason: 'ephemeral-shown' }
|
|
387
|
+
}
|
|
388
|
+
// #3513 follow-up (MF2): exactly-once-among-backstops. If a prior backstop
|
|
389
|
+
// (turn-flush E1/E2 or the outbox sweep E4) already delivered THIS turn's
|
|
390
|
+
// answer per the durable journal, the bridge must not deliver a duplicate —
|
|
391
|
+
// even across the process boundary the in-memory dedup can't see. Counts only
|
|
392
|
+
// backstop deliveries, never an explicit E0 reply, so a genuine later explicit
|
|
393
|
+
// reply is never blocked here.
|
|
394
|
+
if (deps?.backstopDeliveredNonceHit?.(state.turnId) === true) {
|
|
395
|
+
return { deliver: false, reason: 'already-delivered' }
|
|
396
|
+
}
|
|
355
397
|
return { deliver: true, text, reason: 'captured-prose' }
|
|
356
398
|
}
|
|
357
399
|
|