switchroom 0.19.13 → 0.19.15
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 +4 -2
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +1027 -509
- package/telegram-plugin/dist/server.js +1 -1
- package/telegram-plugin/gateway/forward-origin.ts +6 -1
- package/telegram-plugin/gateway/gateway.ts +4 -0
- package/telegram-plugin/gateway/narrative-lane.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -3
- package/telegram-plugin/gateway/outbox-sweep.ts +73 -5
- package/telegram-plugin/gateway/rich-message-handler.ts +235 -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-interrupt-stop.mjs +33 -7
- package/telegram-plugin/hooks/silent-end-scan.mjs +171 -85
- package/telegram-plugin/narrative-flush.ts +35 -0
- package/telegram-plugin/outbox.ts +87 -0
- package/telegram-plugin/shown-ledger.ts +145 -0
- package/telegram-plugin/silent-end.ts +42 -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/forward-origin.test.ts +20 -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-reply-then-recap-e2e.test.ts +600 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +19 -11
- 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/turn-flush-safety.test.ts +35 -3
- package/telegram-plugin/turn-flush-safety.ts +66 -53
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import type { Bot, Context } from 'grammy'
|
|
25
|
+
import { extractRichMessageText } from './rich-message-handler.js'
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Top-level `message` envelope fields — identity/routing metadata, excluded
|
|
@@ -36,6 +37,14 @@ export const MESSAGE_ENVELOPE_KEYS: ReadonlySet<string> = new Set<string>([
|
|
|
36
37
|
'business_connection_id', 'effect_id', 'has_protected_content',
|
|
37
38
|
'is_from_offline', 'link_preview_options', 'show_caption_above_media',
|
|
38
39
|
'entities', 'caption_entities', 'paid_star_count',
|
|
40
|
+
// Legacy (pre-Bot-API-7.0 spelling) forward metadata. Some wire payloads
|
|
41
|
+
// carry these ALONGSIDE `forward_origin` (observed live: carrie
|
|
42
|
+
// update_id=417526125, content_keys=[forward_from,forward_date,rich_message]).
|
|
43
|
+
// They are provenance metadata, not content — without this exclusion a
|
|
44
|
+
// forwarded unhandled message gets mislabeled `(unhandled message content:
|
|
45
|
+
// forward_from)` instead of naming its actual content type.
|
|
46
|
+
'forward_from', 'forward_from_chat', 'forward_from_message_id',
|
|
47
|
+
'forward_signature', 'forward_sender_name', 'forward_date',
|
|
39
48
|
])
|
|
40
49
|
|
|
41
50
|
/**
|
|
@@ -88,6 +97,11 @@ export function planUnhandledMessage(msg: Record<string, unknown>): UnhandledMes
|
|
|
88
97
|
const text =
|
|
89
98
|
(typeof msg.text === 'string' ? msg.text : undefined) ??
|
|
90
99
|
(typeof msg.caption === 'string' ? msg.caption : undefined) ??
|
|
100
|
+
// Belt-and-braces: `message:rich_message` has its own registered handler
|
|
101
|
+
// (rich-message-handler.ts), so a rich message normally never reaches the
|
|
102
|
+
// catch-all — but if one arrives ALONGSIDE unknown future content, its
|
|
103
|
+
// real body still beats a placeholder.
|
|
104
|
+
extractRichMessageText(msg.rich_message) ??
|
|
91
105
|
`(unhandled message content: ${contentType})`
|
|
92
106
|
return { action: 'turn', text, contentKeys }
|
|
93
107
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for the shared trailing-text narration classifier
|
|
3
|
+
* (switchroom#3513) so the bundled TypeScript surfaces — `turn-flush-safety.ts`,
|
|
4
|
+
* `shown-ledger.ts`, `narrative-flush.ts` — can import the ONE classifier from
|
|
5
|
+
* `narration-classify.mjs` without tsc resolution errors. The runtime module is
|
|
6
|
+
* plain ESM (it is also imported by the unbundled Stop-hook `.mjs`, which can
|
|
7
|
+
* only import sibling `.mjs` + node builtins), so it cannot be authored in TS.
|
|
8
|
+
*/
|
|
9
|
+
export const SUBSTANTIVE_MIN_CHARS: number
|
|
10
|
+
export const NARRATION_OPENER: RegExp
|
|
11
|
+
export const NARRATION_TRAILER: RegExp
|
|
12
|
+
export function isTrailingNarrationLine(block: string): boolean
|
|
13
|
+
export function isNarrationBlock(block: string): boolean
|
|
14
|
+
export function isStructuralNarration(
|
|
15
|
+
text: string,
|
|
16
|
+
followedByToolUse: boolean | undefined,
|
|
17
|
+
): boolean
|
|
18
|
+
export const EPHEMERAL_TOOLS: Set<string>
|
|
19
|
+
export function isEphemeralTool(name: string | null | undefined): boolean
|
|
20
|
+
export function selectBackstopDelivery(
|
|
21
|
+
blocks: ReadonlyArray<{ text: string; followedByToolUse?: boolean }>,
|
|
22
|
+
): { text: string } | null
|
|
23
|
+
export function ledgerHashHex(text: string): string
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* narration-classify.mjs — the ONE shared trailing-text narration classifier
|
|
3
|
+
* (switchroom#3513).
|
|
4
|
+
*
|
|
5
|
+
* ## Why this file exists (correction 2)
|
|
6
|
+
* Before #3513 the narration heuristic existed as TWO hand-synced copies — the
|
|
7
|
+
* TS gateway side (`turn-flush-safety.ts` `NARRATION_OPENER` / `isNarrationBlock`
|
|
8
|
+
* / `selectFlushDeliveryText`) and the unbundled Stop-hook `.mjs` side
|
|
9
|
+
* (`silent-end-scan.mjs`, with a "MUST stay in sync" comment). Two independent
|
|
10
|
+
* classifiers judging the SAME trailing assistant text is exactly the drift
|
|
11
|
+
* hazard that let intent-narration ("Now checking the gateway logs…") leak into
|
|
12
|
+
* a delivered Telegram message. This module is the single source of truth,
|
|
13
|
+
* authored as a raw `.mjs` (JS + JSDoc types) so it can be imported by BOTH:
|
|
14
|
+
* - the unbundled Stop-hook `.mjs` (which can only import sibling `.mjs` +
|
|
15
|
+
* node builtins — see `silent-end-scan.mjs`), and
|
|
16
|
+
* - the bundled gateway TS (`turn-flush-safety.ts`) via `.mjs` interop (the
|
|
17
|
+
* same pattern `tool-activity-summary.ts` already uses to import
|
|
18
|
+
* `hooks/tool-label-pretool.mjs`).
|
|
19
|
+
*
|
|
20
|
+
* ## The primary rule — STRUCTURAL, substance-gated (corrections 3 + 4)
|
|
21
|
+
* The wording heuristic (`isNarrationBlock`) is provably incomplete (this bug is
|
|
22
|
+
* its counterexample). The deterministic, wording-independent discriminator is
|
|
23
|
+
* STRUCTURAL: did a `tool_use` follow this text block in the model's message?
|
|
24
|
+
* A narration preamble is drafted, then the model ACTS (a tool follows it); a
|
|
25
|
+
* terminal answer is not followed by a tool. `followedByToolUse === true` is a
|
|
26
|
+
* DEMOTION signal, NOT an unconditional narration verdict — it is gated by the
|
|
27
|
+
* substantive floor / opener heuristic so a SUBSTANTIVE real answer that merely
|
|
28
|
+
* precedes a tool call (the model answers, then reacts / pins / edits) still
|
|
29
|
+
* delivers. Only a short OR narration-shaped block that a tool followed is
|
|
30
|
+
* treated as non-deliverable. `followedByToolUse !== true` (false or absent) is
|
|
31
|
+
* never structural narration here — the caller applies the opener heuristic as a
|
|
32
|
+
* residual tie-breaker where it has no provenance.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { createHash } from 'node:crypto'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Substantive-answer floor (chars, trimmed). The same bar the codebase uses
|
|
39
|
+
* everywhere (`final-answer-detect.ts` `FINAL_ANSWER_MIN_CHARS`,
|
|
40
|
+
* `turn-flush-safety.ts` `FLUSH_SUBSTANTIVE_MIN_CHARS`) to recognise "this block
|
|
41
|
+
* is a real answer, not a short narration/closer".
|
|
42
|
+
*/
|
|
43
|
+
export const SUBSTANTIVE_MIN_CHARS = 200
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Narration heuristic: a block that OPENS with a first-person "about to do X"
|
|
47
|
+
* phrase the model emits BEFORE composing its real answer ("Let me check…",
|
|
48
|
+
* "I'll look it up…", "Now let me…"). Deliberately NOT length-based.
|
|
49
|
+
*/
|
|
50
|
+
export const NARRATION_OPENER =
|
|
51
|
+
/^(let me\b|lemme\b|i'?ll\b|i will\b|i am going to\b|i'?m going to\b|i'?m about to\b|going to\b|first,?\s+(?:let me|i'?ll|i will)\b|now,?\s+(?:let me|i'?ll|i will)\b|next,?\s+(?:let me|i'?ll|i will)\b|let'?s\b)/i
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A NON-terminal progress line that opens with a gerund and trails into an
|
|
55
|
+
* ellipsis/colon: "Checking now…", "Pulling the numbers:". A single short line
|
|
56
|
+
* (under the substantive floor, no internal paragraph) ending in `…`/`...`/`:`.
|
|
57
|
+
*/
|
|
58
|
+
export const NARRATION_TRAILER = /(?:\.{3}|…|:)\s*$/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} block
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
export function isTrailingNarrationLine(block) {
|
|
65
|
+
const t = typeof block === 'string' ? block.trim() : ''
|
|
66
|
+
if (t.length === 0 || t.length >= SUBSTANTIVE_MIN_CHARS) return false
|
|
67
|
+
if (t.includes('\n')) return false
|
|
68
|
+
return NARRATION_TRAILER.test(t)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The wording heuristic (residual tie-breaker for provenance-less callers).
|
|
73
|
+
* @param {string} block
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
export function isNarrationBlock(block) {
|
|
77
|
+
const s = typeof block === 'string' ? block : ''
|
|
78
|
+
return NARRATION_OPENER.test(s.trimStart()) || isTrailingNarrationLine(s)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The #3513 PRIMARY rule: is this block structural intra-turn narration?
|
|
83
|
+
*
|
|
84
|
+
* TRUE only when a `tool_use` followed the block in the model's message
|
|
85
|
+
* (`followedByToolUse === true`) AND the block also reads as narration OR falls
|
|
86
|
+
* below the substantive floor. This preserves the #3237 asymmetry:
|
|
87
|
+
* - `followedByToolUse === true` → demotion, GATED by substance/heuristic
|
|
88
|
+
* (a substantive non-narration block followed by react/pin/typing/edit is
|
|
89
|
+
* NOT structural narration → still delivers).
|
|
90
|
+
* - `followedByToolUse === false` → never structural narration (a terminal
|
|
91
|
+
* block; nothing followed it).
|
|
92
|
+
* - `followedByToolUse` absent → never structural narration here; the
|
|
93
|
+
* caller applies `isNarrationBlock` as a residual tie-breaker.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} text
|
|
96
|
+
* @param {boolean | undefined} followedByToolUse
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
export function isStructuralNarration(text, followedByToolUse) {
|
|
100
|
+
if (followedByToolUse !== true) return false
|
|
101
|
+
const t = typeof text === 'string' ? text.trim() : ''
|
|
102
|
+
if (t.length === 0) return true
|
|
103
|
+
return isNarrationBlock(t) || t.length < SUBSTANTIVE_MIN_CHARS
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Ephemeral (non-turn-continuing) Telegram surface tools (switchroom#3513
|
|
108
|
+
* follow-up, MF4). A text block followed ONLY by tools in this set is still the
|
|
109
|
+
* TERMINAL answer for backstop-coalescing purposes — these tools carry no
|
|
110
|
+
* model-authored answer text and are the only ones plausibly fired AFTER a
|
|
111
|
+
* terminal answer (answer, then react / pin / typing / edit). Everything NOT in
|
|
112
|
+
* this set — work / deliverable tools (download_attachment, get_recent_messages,
|
|
113
|
+
* send_checklist, ask_user, …) and every non-telegram tool (retain / read /
|
|
114
|
+
* bash / web / …) — is turn-CONTINUING: a text block a real tool followed is
|
|
115
|
+
* intra-turn narration, never the terminal answer.
|
|
116
|
+
*
|
|
117
|
+
* Authoritative source: `bridge/bridge.ts` TOOL_SCHEMAS. `reply` / `stream_reply`
|
|
118
|
+
* are deliberately absent — they are handled by the `replyCalled` gate, not this
|
|
119
|
+
* list. `progress_update` is NOT a bridge tool (it is a runtime-metrics /
|
|
120
|
+
* sub-agent parent-card send site) and never appears as a turn `tool_use`, so it
|
|
121
|
+
* is intentionally excluded.
|
|
122
|
+
*/
|
|
123
|
+
export const EPHEMERAL_TOOLS = new Set([
|
|
124
|
+
'react',
|
|
125
|
+
'send_typing',
|
|
126
|
+
'pin_message',
|
|
127
|
+
'delete_message',
|
|
128
|
+
'edit_message',
|
|
129
|
+
])
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* True iff `name` is an ephemeral Telegram surface tool (see EPHEMERAL_TOOLS).
|
|
133
|
+
* Strips the host-chosen `mcp__<key>__` MCP prefix (matching `tool-names.ts`'s
|
|
134
|
+
* `stripPrefix`) so it works regardless of the registration key
|
|
135
|
+
* (`switchroom-telegram`, `clerk-telegram`, a fork's custom key).
|
|
136
|
+
*
|
|
137
|
+
* @param {string | null | undefined} name
|
|
138
|
+
* @returns {boolean}
|
|
139
|
+
*/
|
|
140
|
+
export function isEphemeralTool(name) {
|
|
141
|
+
if (typeof name !== 'string') return false
|
|
142
|
+
const suffix = name.replace(/^mcp__[^_].*?telegram__/, '')
|
|
143
|
+
return EPHEMERAL_TOOLS.has(suffix)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The deterministic, model-discipline-free backstop coalescer (switchroom#3513
|
|
148
|
+
* follow-up). Selects the ONE text a per-turn backstop (turn-flush E1/E2,
|
|
149
|
+
* captured-prose bridge E3, outbox sweep E4) should deliver from all captured /
|
|
150
|
+
* scanned assistant text blocks of a turn.
|
|
151
|
+
*
|
|
152
|
+
* `blocks` is in source order; each `{ text, followedByToolUse }` carries the
|
|
153
|
+
* per-TURN structural provenance (`followedByToolUse === true` ⇔ a
|
|
154
|
+
* turn-CONTINUING tool_use — a non-ephemeral, non-reply tool — arrived after
|
|
155
|
+
* this block, ANYWHERE later in the turn, not only later in its own message).
|
|
156
|
+
* `undefined` = provenance unknown → treated as NOT followed (fail OPEN: deliver,
|
|
157
|
+
* never drop).
|
|
158
|
+
*
|
|
159
|
+
* Rules:
|
|
160
|
+
* 1. TERMINAL RUN — the maximal SUFFIX of non-empty blocks with
|
|
161
|
+
* `followedByToolUse !== true`. Non-empty → return its `\n\n` join. A
|
|
162
|
+
* legitimate multi-paragraph answer written as several consecutive terminal
|
|
163
|
+
* blocks stays whole (no truncation; #3237/#2798 parity).
|
|
164
|
+
* 2. UNCONDITIONAL structural suppression — every block with
|
|
165
|
+
* `followedByToolUse === true` is excluded from the terminal run, with NO
|
|
166
|
+
* length gate and NO opener/wording heuristic. "A real tool followed" is a
|
|
167
|
+
* hard structural fact the turn continued past this block.
|
|
168
|
+
* 3. EMPTY-TERMINAL corner — if the terminal run is empty (the LAST non-empty
|
|
169
|
+
* block was itself tool-followed), return that last block IFF its trimmed
|
|
170
|
+
* length ≥ SUBSTANTIVE_MIN_CHARS, else null. A short tool-followed fragment
|
|
171
|
+
* is narration/a closer → null routes to the re-prompt ladder, never silence.
|
|
172
|
+
*
|
|
173
|
+
* @param {ReadonlyArray<{ text: string, followedByToolUse?: boolean }>} blocks
|
|
174
|
+
* @returns {{ text: string } | null} null = nothing to deliver.
|
|
175
|
+
*/
|
|
176
|
+
export function selectBackstopDelivery(blocks) {
|
|
177
|
+
const nonEmpty = (Array.isArray(blocks) ? blocks : [])
|
|
178
|
+
.map((b) => ({
|
|
179
|
+
text: typeof b?.text === 'string' ? b.text.trim() : '',
|
|
180
|
+
followedByToolUse: b?.followedByToolUse,
|
|
181
|
+
}))
|
|
182
|
+
.filter((b) => b.text.length > 0)
|
|
183
|
+
if (nonEmpty.length === 0) return null
|
|
184
|
+
|
|
185
|
+
// Rule 1 + 2: the terminal SUFFIX run of non-tool-followed blocks.
|
|
186
|
+
const run = []
|
|
187
|
+
for (let i = nonEmpty.length - 1; i >= 0; i--) {
|
|
188
|
+
if (nonEmpty[i].followedByToolUse === true) break
|
|
189
|
+
run.unshift(nonEmpty[i].text)
|
|
190
|
+
}
|
|
191
|
+
if (run.length > 0) return { text: run.join('\n\n') }
|
|
192
|
+
|
|
193
|
+
// Rule 3: empty terminal — the last block was tool-followed. Deliver it only
|
|
194
|
+
// if it clears the substantive floor; otherwise it is a narration fragment.
|
|
195
|
+
const last = nonEmpty[nonEmpty.length - 1]
|
|
196
|
+
if (last.text.length >= SUBSTANTIVE_MIN_CHARS) return { text: last.text }
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The durable shown-ledger key for a block: sha256 of its trimmed text. Matches
|
|
202
|
+
* the hash both the ephemeral-paint writer and the backstop-delivery readers
|
|
203
|
+
* compute, so a marked block is recognised across processes (#3513 §4).
|
|
204
|
+
*
|
|
205
|
+
* @param {string} text
|
|
206
|
+
* @returns {string}
|
|
207
|
+
*/
|
|
208
|
+
export function ledgerHashHex(text) {
|
|
209
|
+
return createHash('sha256').update(String(text ?? '').trim(), 'utf8').digest('hex')
|
|
210
|
+
}
|
|
@@ -182,6 +182,11 @@ function writeOutboxRecord(stateDir, capture) {
|
|
|
182
182
|
// F2: per-session origin chat for envelope-less routing (fail-closed).
|
|
183
183
|
originChatId: capture.chatId == null ? (capture.originChatId ?? null) : undefined,
|
|
184
184
|
originThreadId: capture.chatId == null ? (capture.originThreadId ?? null) : undefined,
|
|
185
|
+
// #3510 instrumentation: carried into every delivered.jsonl entry the
|
|
186
|
+
// sweep writes for this record, so a double-send is provable from the
|
|
187
|
+
// journal alone. Driven by the SAME boolean that gates capture-vs-election
|
|
188
|
+
// in main() — fix and telemetry cannot drift.
|
|
189
|
+
replyAlreadyDeliveredThisTurn: capture.replyAlreadyDeliveredThisTurn === true,
|
|
185
190
|
}
|
|
186
191
|
const tmpPath = join(outboxDir, `.${capture.turnNonce}.${process.pid}.tmp`)
|
|
187
192
|
writeFileSync(tmpPath, JSON.stringify(record), 'utf8')
|
|
@@ -242,13 +247,34 @@ function main() {
|
|
|
242
247
|
if (process.env.SWITCHROOM_TG_OUTBOX_DELIVERY !== '0') try {
|
|
243
248
|
const capture = scanForOutboxCapture(jsonl)
|
|
244
249
|
if (capture.capture === true) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
250
|
+
if (capture.replyAlreadyDeliveredThisTurn === true) {
|
|
251
|
+
// #3510: a qualifying reply ALREADY delivered through the gateway this
|
|
252
|
+
// turn, so a gateway anchor provably exists and the single-writer
|
|
253
|
+
// election below ('trailing-text-after-reply') is reachable. Writing an
|
|
254
|
+
// outbox record + self-exiting here would create a third, uncoordinated
|
|
255
|
+
// delivery path that bypasses the #3469 election and re-sends a
|
|
256
|
+
// trailing recap of the reply as a second (unformatted) message. Do NOT
|
|
257
|
+
// capture; fall through so the election is the actual single writer.
|
|
258
|
+
// The gateway-blind backstop (#3502) is untouched: it is the
|
|
259
|
+
// replyAlreadyDeliveredThisTurn === false branch below.
|
|
260
|
+
// Log both hashes so a double-send is provable from logs alone.
|
|
261
|
+
process.stderr.write(
|
|
262
|
+
`[silent-end-interrupt] capture found trailing prose after a delivered reply ` +
|
|
263
|
+
`(nonce=${capture.turnNonce} source=${capture.source} chars=${capture.text.length} ` +
|
|
264
|
+
`replyAlreadyDeliveredThisTurn=true ` +
|
|
265
|
+
`capturedTextSha256=${createHash('sha256').update(capture.text, 'utf8').digest('hex')} ` +
|
|
266
|
+
`deliveredReplySha256=${capture.deliveredReplySha256 ?? 'unknown'}) — ` +
|
|
267
|
+
`deferring to single-writer election (#3510)\n`,
|
|
268
|
+
)
|
|
269
|
+
} else {
|
|
270
|
+
writeOutboxRecord(stateDir, capture)
|
|
271
|
+
process.stderr.write(
|
|
272
|
+
`[silent-end-interrupt] captured undelivered final answer to outbox ` +
|
|
273
|
+
`(nonce=${capture.turnNonce} source=${capture.source} chars=${capture.text.length} ` +
|
|
274
|
+
`replyAlreadyDeliveredThisTurn=false) — sweep will deliver; allowing stop\n`,
|
|
275
|
+
)
|
|
276
|
+
process.exit(0)
|
|
277
|
+
}
|
|
252
278
|
}
|
|
253
279
|
} catch (err) {
|
|
254
280
|
process.stderr.write(`[silent-end-interrupt] outbox capture error (fail-open): ${err.message}\n`)
|