switchroom 0.13.35 → 0.13.37
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 +141 -7
- package/dist/host-control/main.js +80 -32
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +158 -26
- package/telegram-plugin/gateway/config-approval-handler.test.ts +188 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +170 -15
- package/telegram-plugin/gateway/diff-preview-card.test.ts +2 -2
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -2
- package/telegram-plugin/gateway/drive-write-approval.test.ts +70 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +51 -2
- package/telegram-plugin/gateway/gateway.ts +42 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +10 -1
- package/telegram-plugin/gateway/oversize-card-body.test.ts +108 -0
- package/telegram-plugin/gateway/oversize-card-body.ts +114 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +118 -41
- package/telegram-plugin/hooks/silent-end-scan.mjs +190 -0
- package/telegram-plugin/pending-work-progress.ts +37 -1
- package/telegram-plugin/tests/pending-work-progress.test.ts +134 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +242 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +314 -0
- package/telegram-plugin/tests/silent-end.test.ts +122 -38
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "render-and-fit" helper for approval cards that wrap
|
|
3
|
+
* user-supplied content in HTML framing. (#1762 / #1767)
|
|
4
|
+
*
|
|
5
|
+
* Telegram's `sendMessage` caps the body at 4096 chars and we render
|
|
6
|
+
* with `parse_mode=HTML`. Worst-case escape inflates raw content up
|
|
7
|
+
* to 5x (`&` → `&`), so a naive raw-input cap is unsafe — the
|
|
8
|
+
* post-escape body can blow past the limit and `sendMessage` then
|
|
9
|
+
* returns a generic 400 that surfaces upstream as a silent
|
|
10
|
+
* `E_DENIED`.
|
|
11
|
+
*
|
|
12
|
+
* This helper binary-searches the largest prefix of the RAW content
|
|
13
|
+
* whose rendered body still fits under `cap`, snaps to the last
|
|
14
|
+
* newline so we don't cut mid-line (and never cut mid-entity like
|
|
15
|
+
* `&am|p;` — raw doesn't contain entities yet), and appends a
|
|
16
|
+
* sentinel pointing at the attached full content (if any).
|
|
17
|
+
*
|
|
18
|
+
* Callers own the framing: pass a `render(slice)` closure that
|
|
19
|
+
* embeds the slice in whatever escaped envelope they want, and the
|
|
20
|
+
* helper guarantees the returned `body` fits.
|
|
21
|
+
*
|
|
22
|
+
* Both `config-approval-handler.ts` (config-edit diffs) and
|
|
23
|
+
* `drive-write-approval.ts` (Drive write preview cards) use this.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface TruncateRawToFitInput {
|
|
27
|
+
/** Raw, un-escaped content to slice. */
|
|
28
|
+
raw: string;
|
|
29
|
+
/**
|
|
30
|
+
* Build the full rendered card body from a (possibly truncated)
|
|
31
|
+
* raw slice. The closure owns HTML escaping + all framing. Called
|
|
32
|
+
* O(log n) times during the binary search; keep it cheap.
|
|
33
|
+
*/
|
|
34
|
+
render: (rawSlice: string) => string;
|
|
35
|
+
/**
|
|
36
|
+
* Maximum rendered length (chars). Should be set below Telegram's
|
|
37
|
+
* 4096 hard limit to leave margin for invisible framing wobble.
|
|
38
|
+
*/
|
|
39
|
+
cap: number;
|
|
40
|
+
/**
|
|
41
|
+
* Marker appended to the truncated slice before re-rendering — e.g.
|
|
42
|
+
* `"\n[… diff continues, see attached file]"`. The render closure
|
|
43
|
+
* receives `rawSlice + sentinel` so the marker is visible inside
|
|
44
|
+
* the same envelope (code block etc.).
|
|
45
|
+
*/
|
|
46
|
+
sentinel: string;
|
|
47
|
+
/** Absolute hard cap for the defensive last-resort raw cut. Default `cap + 196`. */
|
|
48
|
+
hardLimit?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TruncateRawToFitResult {
|
|
52
|
+
/** Rendered body, guaranteed to fit within `cap` (best-effort) or `hardLimit` (defensive). */
|
|
53
|
+
body: string;
|
|
54
|
+
/** True iff the helper had to truncate (raw was sliced or hard-cut). */
|
|
55
|
+
truncated: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Try the full content first; if it fits, return as-is. Otherwise
|
|
60
|
+
* binary-search the largest raw prefix whose rendered body fits,
|
|
61
|
+
* snap to the last newline boundary, append the sentinel, re-render
|
|
62
|
+
* and return.
|
|
63
|
+
*
|
|
64
|
+
* Defensive last resort: if even the empty-slice + sentinel render
|
|
65
|
+
* overflows (means the framing alone exceeds `cap` — caller bug or
|
|
66
|
+
* adversarial reason field that slipped past clipping), we hard-cut
|
|
67
|
+
* the rendered body to `hardLimit` chars. Should be unreachable in
|
|
68
|
+
* production but cheaper than crashing.
|
|
69
|
+
*/
|
|
70
|
+
export function truncateRawToFit(
|
|
71
|
+
input: TruncateRawToFitInput,
|
|
72
|
+
): TruncateRawToFitResult {
|
|
73
|
+
const { raw, render, cap, sentinel } = input;
|
|
74
|
+
const hardLimit = input.hardLimit ?? cap + 196;
|
|
75
|
+
|
|
76
|
+
const fullBody = render(raw);
|
|
77
|
+
if (fullBody.length <= cap) {
|
|
78
|
+
return { body: fullBody, truncated: false };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Binary-search the largest raw prefix length whose rendered body
|
|
82
|
+
// fits (with sentinel suffixed before render). We track the best
|
|
83
|
+
// slice rather than just the length so we can snap after the loop.
|
|
84
|
+
let lo = 0;
|
|
85
|
+
let hi = raw.length;
|
|
86
|
+
let bestSliceLen = 0;
|
|
87
|
+
while (lo <= hi) {
|
|
88
|
+
const mid = (lo + hi) >>> 1;
|
|
89
|
+
const candidate = raw.slice(0, mid) + sentinel;
|
|
90
|
+
if (render(candidate).length <= cap) {
|
|
91
|
+
bestSliceLen = mid;
|
|
92
|
+
lo = mid + 1;
|
|
93
|
+
} else {
|
|
94
|
+
hi = mid - 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Snap to the last newline within the chosen raw prefix so we
|
|
99
|
+
// never cut a line in half. If a single unbroken line exceeds
|
|
100
|
+
// the budget, fall through with the char-truncated slice — the
|
|
101
|
+
// caller's framing (e.g. `<pre>`) handles the visual gracefully.
|
|
102
|
+
let chosenRaw = raw.slice(0, bestSliceLen);
|
|
103
|
+
const lastNl = chosenRaw.lastIndexOf("\n");
|
|
104
|
+
if (lastNl > 0) chosenRaw = chosenRaw.slice(0, lastNl);
|
|
105
|
+
|
|
106
|
+
let body = render(chosenRaw + sentinel);
|
|
107
|
+
|
|
108
|
+
// Defensive: framing-alone overflow. Hard-cut to hardLimit so the
|
|
109
|
+
// outbound sendMessage at least has a chance of succeeding.
|
|
110
|
+
if (body.length > hardLimit) {
|
|
111
|
+
body = body.slice(0, hardLimit - 1);
|
|
112
|
+
}
|
|
113
|
+
return { body, truncated: true };
|
|
114
|
+
}
|
|
@@ -1,45 +1,65 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Stop hook —
|
|
3
|
+
* Stop hook — deterministic guardrail that a turn ended with a final
|
|
4
|
+
* reply tool call.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Closes #1775. The pre-fix hook depended on the gateway's
|
|
7
|
+
* `$TELEGRAM_STATE_DIR/silent-end-pending.json` file as its block/allow
|
|
8
|
+
* signal. That file is written by the gateway's `turn_end` handler,
|
|
9
|
+
* which runs DOWNSTREAM of session-tail processing the `turn_duration`
|
|
10
|
+
* JSONL line — and the JSONL line is itself written AFTER
|
|
11
|
+
* `stop_hook_summary`. Live evidence on clerk (12 correlated samples,
|
|
12
|
+
* 2026-05-25): state file lands ~175ms (range 111-287ms) after the
|
|
13
|
+
* hook fires. The race is structurally always lost. The hook never
|
|
14
|
+
* saw its OWN turn's silent-end signal; the mechanism only worked
|
|
15
|
+
* one-turn-delayed via stale state from prior turns.
|
|
10
16
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
17
|
+
* Fix: the hook now reads `transcript_path` from its event input
|
|
18
|
+
* (Claude Code flushes assistant content to the JSONL before firing
|
|
19
|
+
* Stop hooks — verified empirically because `secret-scrub-stop.mjs`
|
|
20
|
+
* already reads `transcript_path` at Stop time successfully) and
|
|
21
|
+
* scans the CURRENT turn's tool_use entries for a qualifying reply.
|
|
22
|
+
* No race window — the decision is derived from the transcript that
|
|
23
|
+
* is on disk at the moment the hook runs.
|
|
18
24
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
25
|
+
* The gateway's state file is preserved for retry-count
|
|
26
|
+
* bookkeeping (the 1-retry budget + `silent-end.ts` user-facing
|
|
27
|
+
* fallback chain). The SIGNAL changes; the budget mechanism does
|
|
28
|
+
* not.
|
|
29
|
+
*
|
|
30
|
+
* #1664 — "no final answer delivered" covers two cases: (a) the turn
|
|
31
|
+
* ended with zero outbound, and (b) the model sent only an interim
|
|
32
|
+
* ack via reply/stream_reply but left its real answer as plain
|
|
33
|
+
* transcript text. The transcript scan handles BOTH cleanly:
|
|
34
|
+
* - case (a) → no tool_use of reply tools in the turn → block
|
|
35
|
+
* - case (b) → tool_use present but `isFinalAnswerReply` returns
|
|
36
|
+
* false on every call → block
|
|
23
37
|
*
|
|
24
38
|
* Carve-outs preserved:
|
|
25
|
-
* -
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
39
|
+
* - NO_REPLY / HEARTBEAT_OK silent markers (`gateway.ts:6692`) → allow
|
|
40
|
+
* - Sub-agent (`isSidechain:true`) lines → skipped (the parent's
|
|
41
|
+
* reply obligation is not satisfied by a sub-agent's reply tool)
|
|
42
|
+
* - Cron-fired turns DO carry a topic chat and reach the silent-end
|
|
43
|
+
* path (`silent-end.ts:219-224`) — they must emit NO_REPLY
|
|
44
|
+
* explicitly, not be specially exempted here
|
|
29
45
|
*
|
|
30
46
|
* Protocol:
|
|
31
47
|
* Input: JSON on stdin — { session_id, transcript_path, ... }
|
|
32
48
|
* Output: exit 0 + empty stdout → allow stop.
|
|
33
49
|
* exit 0 + JSON stdout { decision: "block", reason: "..." } → re-prompt.
|
|
34
50
|
*
|
|
35
|
-
* Fail-open on
|
|
36
|
-
*
|
|
51
|
+
* Fail-open on every error path (no transcript / unreadable / no
|
|
52
|
+
* turn-start anchor / state-file write failure) — blocking on a
|
|
53
|
+
* malfunction is worse than the original race because it loops
|
|
54
|
+
* every session close.
|
|
37
55
|
*/
|
|
38
56
|
|
|
39
57
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
40
58
|
import { join } from 'node:path'
|
|
41
59
|
import { homedir } from 'node:os'
|
|
42
60
|
|
|
61
|
+
import { scanTurnForFinalReply } from './silent-end-scan.mjs'
|
|
62
|
+
|
|
43
63
|
// MUST stay in sync with SILENT_END_MAX_RETRIES in telegram-plugin/silent-end.ts
|
|
44
64
|
// (this hook is a standalone .mjs and can't import the TS module).
|
|
45
65
|
const MAX_RETRIES = 1
|
|
@@ -60,52 +80,109 @@ function main() {
|
|
|
60
80
|
const raw = readStdin().trim()
|
|
61
81
|
if (!raw) process.exit(0)
|
|
62
82
|
|
|
63
|
-
|
|
64
|
-
let _event
|
|
83
|
+
let event
|
|
65
84
|
try {
|
|
66
|
-
|
|
85
|
+
event = JSON.parse(raw)
|
|
67
86
|
} catch {
|
|
68
87
|
process.exit(0)
|
|
69
88
|
}
|
|
70
89
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
90
|
+
const transcriptPath = event?.transcript_path
|
|
91
|
+
if (!transcriptPath || typeof transcriptPath !== 'string' || !existsSync(transcriptPath)) {
|
|
92
|
+
// No transcript → can't scan → fail-open. Pre-fix the hook fell
|
|
93
|
+
// back to the state-file signal here; we deliberately do NOT do
|
|
94
|
+
// that anymore because the state-file signal is structurally
|
|
95
|
+
// stale (race-loses every time).
|
|
76
96
|
process.exit(0)
|
|
77
97
|
}
|
|
78
98
|
|
|
79
|
-
let
|
|
99
|
+
let jsonl
|
|
80
100
|
try {
|
|
81
|
-
|
|
82
|
-
} catch {
|
|
83
|
-
|
|
101
|
+
jsonl = readFileSync(transcriptPath, 'utf8')
|
|
102
|
+
} catch (err) {
|
|
103
|
+
process.stderr.write(
|
|
104
|
+
`[silent-end-interrupt] failed to read transcript ${transcriptPath}: ${err.message}\n`,
|
|
105
|
+
)
|
|
106
|
+
process.exit(0)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const decision = scanTurnForFinalReply(jsonl)
|
|
110
|
+
|
|
111
|
+
// 'allow' (qualifying reply or silent marker) and 'unknown' (no
|
|
112
|
+
// turn-start anchor in the scanned range — session restart,
|
|
113
|
+
// compaction, etc.) both allow the stop.
|
|
114
|
+
if (decision.decided !== 'block') {
|
|
84
115
|
process.exit(0)
|
|
85
116
|
}
|
|
86
117
|
|
|
118
|
+
// Retry-budget bookkeeping. The state file is read/written here
|
|
119
|
+
// as a counter ONLY — the decision was already made from the
|
|
120
|
+
// transcript above. If a state file exists from a prior turn that
|
|
121
|
+
// never got cleared (clean shutdown not perfect), this read still
|
|
122
|
+
// works; if absent, retryCount defaults to 0.
|
|
123
|
+
const stateDir = getStateDir()
|
|
124
|
+
const statePath = join(stateDir, 'silent-end-pending.json')
|
|
125
|
+
|
|
126
|
+
let state = {}
|
|
127
|
+
if (existsSync(statePath)) {
|
|
128
|
+
try {
|
|
129
|
+
state = JSON.parse(readFileSync(statePath, 'utf8'))
|
|
130
|
+
} catch {
|
|
131
|
+
// Corrupt — treat as fresh.
|
|
132
|
+
state = {}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
87
136
|
const retryCount = typeof state.retryCount === 'number' ? state.retryCount : 0
|
|
88
137
|
|
|
89
138
|
if (retryCount >= MAX_RETRIES) {
|
|
90
|
-
//
|
|
91
|
-
//
|
|
139
|
+
// Budget spent. Let the session end so the gateway's
|
|
140
|
+
// `silent-end.ts:recordUndeliveredTurnEnd` path delivers the
|
|
141
|
+
// user-facing fallback (the gateway sees `silentEnd.exhausted ===
|
|
142
|
+
// true` and posts SILENT_END_FALLBACK_TEXT).
|
|
92
143
|
process.stderr.write(
|
|
93
144
|
`[silent-end-interrupt] retry exhausted (retryCount=${retryCount} >= MAX_RETRIES=${MAX_RETRIES}) — allowing stop\n`,
|
|
94
145
|
)
|
|
95
146
|
process.exit(0)
|
|
96
147
|
}
|
|
97
148
|
|
|
98
|
-
//
|
|
149
|
+
// Persist incremented retry count so a follow-up Stop in the same
|
|
150
|
+
// chat hits the exhaustion branch above. The gateway's existing
|
|
151
|
+
// clearSilentEndState path (`silent-end.ts:155-180`) handles
|
|
152
|
+
// resetting on successful delivery.
|
|
153
|
+
//
|
|
154
|
+
// CRITICAL: include `turnKey` (and the supporting `chatId` / `threadId`)
|
|
155
|
+
// when the scan derived them from the enqueue envelope. The gateway's
|
|
156
|
+
// `recordSilentTurnEnd` (`silent-end.ts:114`) preserves retryCount
|
|
157
|
+
// ONLY when `prev.turnKey === args.turnKey`. Without turnKey here,
|
|
158
|
+
// the gateway's later write (~175ms after the hook) sees `prev.turnKey
|
|
159
|
+
// === undefined`, fails the match, and resets retryCount to 0 — which
|
|
160
|
+
// doubles the effective re-prompt budget vs. the design. With turnKey
|
|
161
|
+
// present (same chatKey shape the gateway uses), the match succeeds
|
|
162
|
+
// and the budget is honored.
|
|
163
|
+
const nextState = {
|
|
164
|
+
...state,
|
|
165
|
+
retryCount: retryCount + 1,
|
|
166
|
+
timestamp: Date.now(),
|
|
167
|
+
}
|
|
168
|
+
if (decision.turnKey) {
|
|
169
|
+
nextState.turnKey = decision.turnKey
|
|
170
|
+
nextState.chatId = decision.chatId
|
|
171
|
+
if (decision.threadId != null) {
|
|
172
|
+
nextState.threadId = decision.threadId
|
|
173
|
+
}
|
|
174
|
+
}
|
|
99
175
|
try {
|
|
100
|
-
writeFileSync(statePath, JSON.stringify(
|
|
176
|
+
writeFileSync(statePath, JSON.stringify(nextState), 'utf8')
|
|
101
177
|
} catch (err) {
|
|
102
178
|
process.stderr.write(`[silent-end-interrupt] failed to update state file: ${err.message}\n`)
|
|
103
|
-
// Fail-open:
|
|
179
|
+
// Fail-open: a retry-count write failure shouldn't loop the
|
|
180
|
+
// session forever.
|
|
104
181
|
process.exit(0)
|
|
105
182
|
}
|
|
106
183
|
|
|
107
184
|
process.stderr.write(
|
|
108
|
-
`[silent-end-interrupt] blocking stop to re-prompt agent (
|
|
185
|
+
`[silent-end-interrupt] blocking stop to re-prompt agent (transcriptScan=${decision.reason} retryCount was ${retryCount})\n`,
|
|
109
186
|
)
|
|
110
187
|
|
|
111
188
|
process.stdout.write(
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the silent-end Stop hook — extracted so unit tests
|
|
3
|
+
* can exercise the scan logic without spawning the .mjs subprocess.
|
|
4
|
+
*
|
|
5
|
+
* Closes the race documented in #1775: the gateway writes
|
|
6
|
+
* `silent-end-pending.json` only AFTER the Stop hook fires (the
|
|
7
|
+
* gateway's `turn_end` handler runs downstream of the `turn_duration`
|
|
8
|
+
* JSONL line, which is itself written AFTER `stop_hook_summary`). The
|
|
9
|
+
* fix: the hook stops depending on the gateway's state file as its
|
|
10
|
+
* SIGNAL and instead scans `transcript_path` directly. Claude Code
|
|
11
|
+
* flushes assistant content to the JSONL before firing Stop hooks
|
|
12
|
+
* (verified empirically: `telegram-plugin/hooks/secret-scrub-stop.mjs`
|
|
13
|
+
* already reads `transcript_path` at Stop time successfully in
|
|
14
|
+
* production), so a transcript scan is race-free.
|
|
15
|
+
*
|
|
16
|
+
* The state file is preserved for retry-count bookkeeping (the
|
|
17
|
+
* 1-retry budget + user-facing fallback chain in `silent-end.ts`),
|
|
18
|
+
* but it is no longer the signal that drives the block/allow
|
|
19
|
+
* decision.
|
|
20
|
+
*
|
|
21
|
+
* Same `isFinalAnswerReply` predicate the gateway applies at every
|
|
22
|
+
* reply callsite (`final-answer-detect.ts:78-83`):
|
|
23
|
+
* done===true OR !disableNotification OR text.length >= 200
|
|
24
|
+
*
|
|
25
|
+
* Plus the `NO_REPLY` / `HEARTBEAT_OK` silent-marker carve-out — if
|
|
26
|
+
* the model explicitly emitted that sentinel through the reply tool,
|
|
27
|
+
* the turn is "intentionally silent" and the hook must allow stop.
|
|
28
|
+
*
|
|
29
|
+
* Sidechain filter: sub-agent (Task) tool_use lines that leak into
|
|
30
|
+
* the parent transcript with `isSidechain:true` are skipped. The
|
|
31
|
+
* sub-agent's OWN replies live in `subagents/agent-<id>.jsonl` (per
|
|
32
|
+
* `session-tail.ts:277-281`) and never count toward the parent's
|
|
33
|
+
* delivery obligation.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const REPLY_TOOLS = new Set([
|
|
37
|
+
'mcp__switchroom-telegram__reply',
|
|
38
|
+
'mcp__switchroom-telegram__stream_reply',
|
|
39
|
+
])
|
|
40
|
+
const FINAL_ANSWER_MIN_CHARS = 200
|
|
41
|
+
// Match the gateway's silent-marker classifier (gateway.ts:6692 — the
|
|
42
|
+
// `isSilentFlushMarker` helper accepts trailing punctuation + case
|
|
43
|
+
// variants like "NO_REPLY." / "no_reply").
|
|
44
|
+
const SILENT_MARKER_RE = /^(NO_REPLY|HEARTBEAT_OK)[\s.!?]*$/i
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Predicate ported from `telegram-plugin/final-answer-detect.ts:78-83`.
|
|
48
|
+
* Kept in this .mjs so the hook is fully self-contained (no TS import).
|
|
49
|
+
* If the TS file ever diverges, the test fixture below (T14) catches it.
|
|
50
|
+
*/
|
|
51
|
+
export function isFinalAnswerReply({ text, disableNotification, done }) {
|
|
52
|
+
if (done === true) return true
|
|
53
|
+
if (!disableNotification) return true
|
|
54
|
+
if ((text ?? '').length >= FINAL_ANSWER_MIN_CHARS) return true
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse a `<channel ...>` envelope's chat_id and message_thread_id
|
|
60
|
+
* attributes. Same shape session-tail.ts:125-140 uses to derive these
|
|
61
|
+
* from the enqueue line's `content` string.
|
|
62
|
+
*
|
|
63
|
+
* Returns `null` if the envelope can't be parsed (caller treats as
|
|
64
|
+
* "no turn key derivable" and writes a turnKey-less state file —
|
|
65
|
+
* still functional, just loses retry-count preservation across the
|
|
66
|
+
* hook→gateway write order).
|
|
67
|
+
*
|
|
68
|
+
* @param {string} content
|
|
69
|
+
* @returns {{ chatId: string | null, threadId: number | null }}
|
|
70
|
+
*/
|
|
71
|
+
function parseChannelEnvelope(content) {
|
|
72
|
+
if (typeof content !== 'string') return { chatId: null, threadId: null }
|
|
73
|
+
const chatMatch = content.match(/chat_id="([^"]+)"/)
|
|
74
|
+
const threadMatch = content.match(/message_thread_id="([^"]+)"/)
|
|
75
|
+
const threadRaw = threadMatch ? Number(threadMatch[1]) : NaN
|
|
76
|
+
return {
|
|
77
|
+
chatId: chatMatch ? chatMatch[1] : null,
|
|
78
|
+
threadId: Number.isFinite(threadRaw) && threadRaw !== 0 ? threadRaw : null,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build the turnKey the gateway will use for `recordSilentTurnEnd`'s
|
|
84
|
+
* write of the state file. Matches `chatKey(chatId, threadId)` shape
|
|
85
|
+
* at `gateway/chat-key.ts:46`: `${chatId}:${threadId || '_'}`.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} chatId
|
|
88
|
+
* @param {number | null} threadId
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
function buildTurnKey(chatId, threadId) {
|
|
92
|
+
return `${chatId}:${threadId == null || threadId === 0 ? '_' : threadId}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Scan a JSONL transcript and decide whether the current turn ended
|
|
97
|
+
* with a final reply delivered.
|
|
98
|
+
*
|
|
99
|
+
* Returns:
|
|
100
|
+
* { decided: 'allow', reason } — qualifying reply OR silent marker found
|
|
101
|
+
* { decided: 'block', reason, turnKey?, chatId?, threadId? }
|
|
102
|
+
* — turn-start found, no qualifying reply,
|
|
103
|
+
* no marker. `turnKey`/`chatId`/`threadId`
|
|
104
|
+
* populated from the enqueue's channel
|
|
105
|
+
* envelope so the hook can write a state
|
|
106
|
+
* file shape that matches what the
|
|
107
|
+
* gateway's `recordSilentTurnEnd` would
|
|
108
|
+
* write — keeping the retry-count
|
|
109
|
+
* preservation gate at
|
|
110
|
+
* `silent-end.ts:114` happy when the
|
|
111
|
+
* gateway's later write reads back the
|
|
112
|
+
* hook's state.
|
|
113
|
+
* { decided: 'unknown', reason } — couldn't locate turn-start; caller fail-open
|
|
114
|
+
*
|
|
115
|
+
* Turn-start anchor: the most recent `queue-operation`/`enqueue` line
|
|
116
|
+
* (the inbound message the gateway pushed onto the session). For
|
|
117
|
+
* queued mid-turn messages (multiple `enqueue` lines per "turn"), we
|
|
118
|
+
* anchor on the LAST enqueue — the model is responsible for at least
|
|
119
|
+
* the most recent message. (Mild over-allow risk on the multi-enqueue
|
|
120
|
+
* edge case where the model replied combined ahead of the second
|
|
121
|
+
* enqueue's append; accepted residual.)
|
|
122
|
+
*
|
|
123
|
+
* @param {string} jsonl
|
|
124
|
+
* @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, chatId?: string, threadId?: number | null }}
|
|
125
|
+
*/
|
|
126
|
+
export function scanTurnForFinalReply(jsonl) {
|
|
127
|
+
const lines = jsonl.split('\n')
|
|
128
|
+
|
|
129
|
+
// 1. Walk backward to most-recent queue-operation/enqueue.
|
|
130
|
+
let startIdx = -1
|
|
131
|
+
let envelope = { chatId: null, threadId: null }
|
|
132
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
133
|
+
const line = lines[i]
|
|
134
|
+
if (!line || line[0] !== '{') continue
|
|
135
|
+
let obj
|
|
136
|
+
try { obj = JSON.parse(line) } catch { continue }
|
|
137
|
+
if (obj?.type === 'queue-operation' && obj.operation === 'enqueue') {
|
|
138
|
+
startIdx = i
|
|
139
|
+
envelope = parseChannelEnvelope(obj.content)
|
|
140
|
+
break
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (startIdx < 0) {
|
|
144
|
+
return { decided: 'unknown', reason: 'no-turn-start' }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 2. Scan forward from the turn start; look for qualifying tool_use
|
|
148
|
+
// or silent-marker text.
|
|
149
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
150
|
+
const line = lines[i]
|
|
151
|
+
if (!line || line[0] !== '{') continue
|
|
152
|
+
let obj
|
|
153
|
+
try { obj = JSON.parse(line) } catch { continue }
|
|
154
|
+
// Skip sub-agent contamination (defensive — sub-agent lines should
|
|
155
|
+
// be in a separate transcript file, but `isSidechain:true` is the
|
|
156
|
+
// documented marker if they leak).
|
|
157
|
+
if (obj?.isSidechain === true) continue
|
|
158
|
+
if (obj?.type !== 'assistant') continue
|
|
159
|
+
const content = obj?.message?.content
|
|
160
|
+
if (!Array.isArray(content)) continue
|
|
161
|
+
for (const c of content) {
|
|
162
|
+
if (c?.type !== 'tool_use') continue
|
|
163
|
+
if (!REPLY_TOOLS.has(c.name)) continue
|
|
164
|
+
const input = c.input ?? {}
|
|
165
|
+
const text = String(input.text ?? '')
|
|
166
|
+
// Silent-marker carve-out: the operator explicitly signaled
|
|
167
|
+
// "intentionally silent" (cron HEARTBEAT_OK, model-driven
|
|
168
|
+
// NO_REPLY). Don't block — same posture as the gateway's
|
|
169
|
+
// silent-marker suppression at gateway.ts:6692.
|
|
170
|
+
if (SILENT_MARKER_RE.test(text.trim())) {
|
|
171
|
+
return { decided: 'allow', reason: 'silent-marker' }
|
|
172
|
+
}
|
|
173
|
+
if (isFinalAnswerReply({
|
|
174
|
+
text,
|
|
175
|
+
disableNotification: input.disable_notification === true,
|
|
176
|
+
done: input.done === true,
|
|
177
|
+
})) {
|
|
178
|
+
return { decided: 'allow', reason: 'final-reply' }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const block = { decided: 'block', reason: 'no-final-reply' }
|
|
184
|
+
if (envelope.chatId) {
|
|
185
|
+
block.chatId = envelope.chatId
|
|
186
|
+
block.threadId = envelope.threadId
|
|
187
|
+
block.turnKey = buildTurnKey(envelope.chatId, envelope.threadId)
|
|
188
|
+
}
|
|
189
|
+
return block
|
|
190
|
+
}
|
|
@@ -112,6 +112,20 @@ export interface PendingProgressDeps {
|
|
|
112
112
|
nowMs?: () => number
|
|
113
113
|
/** Optional poll interval override for tests. */
|
|
114
114
|
pollIntervalMs?: number
|
|
115
|
+
/**
|
|
116
|
+
* Defense-in-depth (#1760). When provided, returns the gateway's
|
|
117
|
+
* `activeTurnStartedAt` epoch ms for this chat key, or undefined if no
|
|
118
|
+
* turn is currently active. The ticker uses this on every fire to detect
|
|
119
|
+
* a stale ambient: if a NEWER turn has started (epoch > our activatedAt)
|
|
120
|
+
* the prior turn's cross-turn pending-progress is by definition orphaned
|
|
121
|
+
* (the turn_end teardown was missed, e.g. SDK event dropped) and the
|
|
122
|
+
* ticker self-terminates instead of editing a stale anchor. Converts the
|
|
123
|
+
* #1760 failure mode from "stuck forever" to "at most one stale tick."
|
|
124
|
+
*
|
|
125
|
+
* Defaults to undefined — preserves prior behaviour for tests that
|
|
126
|
+
* exercise the ticker without a gateway.
|
|
127
|
+
*/
|
|
128
|
+
isActiveTurnNewerThan?: (key: string, activatedAt: number) => boolean
|
|
115
129
|
}
|
|
116
130
|
|
|
117
131
|
interface State {
|
|
@@ -276,7 +290,14 @@ export function noteTurnEnd(key: string): void {
|
|
|
276
290
|
*/
|
|
277
291
|
export function clearPending(
|
|
278
292
|
key: string,
|
|
279
|
-
reason:
|
|
293
|
+
reason:
|
|
294
|
+
| 'inbound'
|
|
295
|
+
| 'handback'
|
|
296
|
+
| 'progress'
|
|
297
|
+
| 'timeout'
|
|
298
|
+
| 'manual'
|
|
299
|
+
| 'reply_finalize'
|
|
300
|
+
| 'stale_turn',
|
|
280
301
|
): void {
|
|
281
302
|
if (!stateByKey.has(key)) return
|
|
282
303
|
const s = stateByKey.get(key)!
|
|
@@ -337,6 +358,21 @@ function tick(now: number): void {
|
|
|
337
358
|
continue
|
|
338
359
|
}
|
|
339
360
|
|
|
361
|
+
// #1760 defense-in-depth: if a newer turn is currently active for
|
|
362
|
+
// this chat, the prior turn's cross-turn pending-progress is stale
|
|
363
|
+
// (the canonical teardown — turn_end or the next turn's reply-
|
|
364
|
+
// finalize — was missed). Drop the timer instead of editing the
|
|
365
|
+
// old anchor; the new turn will manage its own anchor via the
|
|
366
|
+
// regular noteOutbound / noteTurnEnd path. Converts "stuck forever"
|
|
367
|
+
// (the live #1760 evidence) into "at most one stale tick."
|
|
368
|
+
if (
|
|
369
|
+
activeDeps.isActiveTurnNewerThan != null
|
|
370
|
+
&& activeDeps.isActiveTurnNewerThan(key, s.activatedAt)
|
|
371
|
+
) {
|
|
372
|
+
clearPending(key, 'stale_turn')
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
|
|
340
376
|
const sinceEdit = s.lastEditAt == null ? 0 : now - s.lastEditAt
|
|
341
377
|
if (sinceEdit < EDIT_INTERVAL_MS) continue
|
|
342
378
|
|