switchroom 0.18.19 → 0.18.20
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/ms-365-write-pretool.mjs +92 -20
- package/dist/cli/switchroom.js +36 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1073 -182
- package/telegram-plugin/format.ts +179 -20
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +775 -105
- package/telegram-plugin/gateway/idle-clear.ts +170 -0
- package/telegram-plugin/gateway/inject-handler.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
- package/telegram-plugin/gateway/turn-record-status.ts +134 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
- package/telegram-plugin/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -5
- package/telegram-plugin/subagent-watcher.ts +244 -81
- package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
- package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
- package/telegram-plugin/tests/format-consistency.test.ts +54 -34
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
- package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
- package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
- package/telegram-plugin/tests/silent-end.test.ts +296 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +218 -0
- package/telegram-plugin/tests/telegram-format.test.ts +36 -23
- package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
- package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
- package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +125 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +4 -4
- package/telegram-plugin/worker-activity-feed.ts +181 -30
|
@@ -16,31 +16,89 @@ var GATEWAY_SOCKET = process.env.SWITCHROOM_GATEWAY_SOCKET ?? (process.env.TELEG
|
|
|
16
16
|
var KERNEL_SOCKET = process.env.SWITCHROOM_KERNEL_SOCKET ?? "/run/switchroom/kernel/sock";
|
|
17
17
|
var TOOL_PREFIX = "mcp__ms-365__";
|
|
18
18
|
var GATED_MS365_WRITE_TOOLS = new Set([
|
|
19
|
+
"create-calendar",
|
|
20
|
+
"update-calendar",
|
|
21
|
+
"delete-calendar",
|
|
22
|
+
"create-calendar-event",
|
|
23
|
+
"create-specific-calendar-event",
|
|
24
|
+
"update-calendar-event",
|
|
25
|
+
"update-specific-calendar-event",
|
|
26
|
+
"delete-calendar-event",
|
|
27
|
+
"delete-specific-calendar-event",
|
|
28
|
+
"accept-calendar-event",
|
|
29
|
+
"decline-calendar-event",
|
|
30
|
+
"tentatively-accept-calendar-event",
|
|
31
|
+
"cancel-calendar-event",
|
|
32
|
+
"forward-calendar-event",
|
|
33
|
+
"dismiss-calendar-event-reminder",
|
|
34
|
+
"snooze-calendar-event-reminder",
|
|
35
|
+
"create-my-calendar-permission",
|
|
36
|
+
"update-my-calendar-permission",
|
|
37
|
+
"delete-my-calendar-permission",
|
|
38
|
+
"send-mail",
|
|
39
|
+
"send",
|
|
40
|
+
"update-mail-message",
|
|
41
|
+
"delete-mail-message",
|
|
42
|
+
"move-mail-message",
|
|
43
|
+
"copy-mail-message",
|
|
44
|
+
"reply-mail-message",
|
|
45
|
+
"reply-all-mail-message",
|
|
46
|
+
"forward-mail-message",
|
|
47
|
+
"create-mail-attachment-upload-session",
|
|
48
|
+
"add-mail-attachment",
|
|
49
|
+
"delete-mail-attachment",
|
|
50
|
+
"create-mail-folder",
|
|
51
|
+
"create-mail-child-folder",
|
|
52
|
+
"update-mail-folder",
|
|
53
|
+
"delete-mail-folder",
|
|
54
|
+
"create-mail-rule",
|
|
55
|
+
"update-mail-rule",
|
|
56
|
+
"delete-mail-rule",
|
|
57
|
+
"update-mailbox-settings",
|
|
19
58
|
"upload-file-content",
|
|
20
59
|
"create-upload-session",
|
|
21
|
-
"create-
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
60
|
+
"create-onedrive-folder",
|
|
61
|
+
"delete-onedrive-file",
|
|
62
|
+
"move-rename-onedrive-item",
|
|
63
|
+
"copy-drive-item",
|
|
64
|
+
"share-drive-item",
|
|
65
|
+
"create-drive-item-share-link",
|
|
66
|
+
"delete-drive-item-permission"
|
|
26
67
|
]);
|
|
27
68
|
var KNOWN_SAFE_MS365_READ_TOOLS = new Set([
|
|
28
|
-
"list-files",
|
|
29
|
-
"list-drive-items",
|
|
30
|
-
"get-drive-item",
|
|
31
|
-
"download-bytes",
|
|
32
|
-
"search-files",
|
|
33
|
-
"list-events",
|
|
34
|
-
"get-event",
|
|
35
69
|
"list-calendars",
|
|
36
|
-
"get-calendar",
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
70
|
+
"get-calendar-view",
|
|
71
|
+
"get-specific-calendar-view",
|
|
72
|
+
"list-calendar-events",
|
|
73
|
+
"list-specific-calendar-events",
|
|
74
|
+
"get-calendar-event",
|
|
75
|
+
"get-specific-calendar-event",
|
|
76
|
+
"list-calendar-event-instances",
|
|
77
|
+
"list-calendar-events-delta",
|
|
78
|
+
"list-calendar-view-delta",
|
|
79
|
+
"list-my-calendar-permissions",
|
|
80
|
+
"list-mail-messages",
|
|
81
|
+
"get-mail-message",
|
|
82
|
+
"get-mail-message-mime",
|
|
40
83
|
"list-mail-folders",
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
84
|
+
"list-mail-child-folders",
|
|
85
|
+
"list-mail-folder-messages",
|
|
86
|
+
"list-mail-folder-messages-delta",
|
|
87
|
+
"list-mail-attachments",
|
|
88
|
+
"list-mail-rules",
|
|
89
|
+
"get-mail-tips",
|
|
90
|
+
"get-mailbox-settings",
|
|
91
|
+
"create-draft-email",
|
|
92
|
+
"get-drive-item",
|
|
93
|
+
"get-drive-root-item",
|
|
94
|
+
"download-bytes",
|
|
95
|
+
"list-drives",
|
|
96
|
+
"list-folder-files",
|
|
97
|
+
"search-onedrive-files",
|
|
98
|
+
"get-drive-delta",
|
|
99
|
+
"login",
|
|
100
|
+
"verify-login",
|
|
101
|
+
"list-accounts"
|
|
44
102
|
]);
|
|
45
103
|
function isGatedMs365Tool(toolName) {
|
|
46
104
|
if (!toolName.startsWith(TOOL_PREFIX))
|
|
@@ -52,6 +110,18 @@ function isGatedMs365Tool(toolName) {
|
|
|
52
110
|
return false;
|
|
53
111
|
return true;
|
|
54
112
|
}
|
|
113
|
+
function loadAllowFrom() {
|
|
114
|
+
const stateDir = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), ".claude", "channels", "telegram");
|
|
115
|
+
const accessPath = join(stateDir, "access.json");
|
|
116
|
+
try {
|
|
117
|
+
const raw = readFileSync(accessPath, "utf8");
|
|
118
|
+
const j = JSON.parse(raw);
|
|
119
|
+
if (Array.isArray(j.allowFrom)) {
|
|
120
|
+
return j.allowFrom.filter((s) => typeof s === "string");
|
|
121
|
+
}
|
|
122
|
+
} catch {}
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
55
125
|
function readStdin() {
|
|
56
126
|
try {
|
|
57
127
|
return readFileSync(0, "utf8");
|
|
@@ -254,9 +324,10 @@ async function main() {
|
|
|
254
324
|
}
|
|
255
325
|
const requestId = response.requestId;
|
|
256
326
|
const deadline = response.expiresAtMs ?? Date.now() + HOOK_TIMEOUT_MS;
|
|
327
|
+
const approverSet = loadAllowFrom();
|
|
257
328
|
while (Date.now() < deadline) {
|
|
258
329
|
await new Promise((r) => setTimeout(r, KERNEL_POLL_INTERVAL_MS));
|
|
259
|
-
const lookup = await approvalLookupByRequest(agentName, requestId,
|
|
330
|
+
const lookup = await approvalLookupByRequest(agentName, requestId, approverSet);
|
|
260
331
|
if (!lookup)
|
|
261
332
|
continue;
|
|
262
333
|
const state = lookup.state;
|
|
@@ -275,6 +346,7 @@ if (__require.main == __require.module) {
|
|
|
275
346
|
});
|
|
276
347
|
}
|
|
277
348
|
export {
|
|
349
|
+
loadAllowFrom,
|
|
278
350
|
isGatedMs365Tool,
|
|
279
351
|
extractMs365Preview,
|
|
280
352
|
KNOWN_SAFE_MS365_READ_TOOLS,
|
package/dist/cli/switchroom.js
CHANGED
|
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
|
|
|
2120
2120
|
});
|
|
2121
2121
|
|
|
2122
2122
|
// src/build-info.ts
|
|
2123
|
-
var VERSION = "0.18.
|
|
2123
|
+
var VERSION = "0.18.20", COMMIT_SHA = "f82e440f";
|
|
2124
2124
|
|
|
2125
2125
|
// src/cli/resolve-version.ts
|
|
2126
2126
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -15464,11 +15464,12 @@ async function injectSlashCommand(agentName, command, opts = {}) {
|
|
|
15464
15464
|
session,
|
|
15465
15465
|
command: command.trim(),
|
|
15466
15466
|
settleMs,
|
|
15467
|
-
timeoutMs
|
|
15467
|
+
timeoutMs,
|
|
15468
|
+
precondition: opts.precondition
|
|
15468
15469
|
}));
|
|
15469
15470
|
}
|
|
15470
15471
|
async function injectSlashCommandWith(runner, args) {
|
|
15471
|
-
const { socket, session, command, settleMs, timeoutMs } = args;
|
|
15472
|
+
const { socket, session, command, settleMs, timeoutMs, precondition } = args;
|
|
15472
15473
|
let bareVerb;
|
|
15473
15474
|
try {
|
|
15474
15475
|
bareVerb = validateInjectCommand(command);
|
|
@@ -15498,6 +15499,17 @@ async function injectSlashCommandWith(runner, args) {
|
|
|
15498
15499
|
errorMessage: `tmux session "${session}" on socket "${socket}" not found. ` + `Is the agent running under the tmux supervisor (the default)? ` + `If experimental.legacy_pty=true is set, inject is unsupported.`
|
|
15499
15500
|
};
|
|
15500
15501
|
}
|
|
15502
|
+
if (precondition && !precondition()) {
|
|
15503
|
+
return {
|
|
15504
|
+
outcome: "skipped",
|
|
15505
|
+
output: "",
|
|
15506
|
+
truncated: false,
|
|
15507
|
+
command: bareVerb,
|
|
15508
|
+
meta,
|
|
15509
|
+
errorCode: "precondition_failed",
|
|
15510
|
+
errorMessage: "inject precondition returned false at write time; send aborted " + "(no keys sent)."
|
|
15511
|
+
};
|
|
15512
|
+
}
|
|
15501
15513
|
const before = runner.capture(socket, session) ?? "";
|
|
15502
15514
|
try {
|
|
15503
15515
|
runner.send(socket, session, ["send-keys", "-l", command]);
|
|
@@ -63192,7 +63204,15 @@ function detectTurnFindings(agent, turns) {
|
|
|
63192
63204
|
const dur = typeof t.duration_ms === "number" ? t.duration_ms : 0;
|
|
63193
63205
|
const synthetic = tid.includes("synthetic-");
|
|
63194
63206
|
const ts = isoFromTs(t.ts);
|
|
63195
|
-
if (st
|
|
63207
|
+
if (st === "send_failed") {
|
|
63208
|
+
findings.push({
|
|
63209
|
+
signal: "send-failed-delivery",
|
|
63210
|
+
agent,
|
|
63211
|
+
turn_id: tid,
|
|
63212
|
+
log_pointer: `turns.jsonl:${tid} status=send_failed`,
|
|
63213
|
+
ts
|
|
63214
|
+
});
|
|
63215
|
+
} else if (st !== "complete" && st !== "no_reply") {
|
|
63196
63216
|
findings.push({
|
|
63197
63217
|
signal: "killed-incomplete-turn",
|
|
63198
63218
|
agent,
|
|
@@ -63268,7 +63288,7 @@ function scanAgent(agent, turnsText, gatewayText) {
|
|
|
63268
63288
|
const turnFindings = detectTurnFindings(agent, turns);
|
|
63269
63289
|
const { findings: gwFindings, gw_hits } = detectGatewayFindings(agent, gatewayText);
|
|
63270
63290
|
const findings = [...turnFindings, ...gwFindings];
|
|
63271
|
-
const escalate = turnFindings.some((f) => f.signal === "killed-incomplete-turn" || f.signal === "hang-long-stalled" || f.signal === "silent-no-op-candidate") || gw_hits["duplicate-delivery-represent"] > 0 || gw_hits["reply-delivery-failure"] > 0;
|
|
63291
|
+
const escalate = turnFindings.some((f) => f.signal === "killed-incomplete-turn" || f.signal === "hang-long-stalled" || f.signal === "silent-no-op-candidate" || f.signal === "send-failed-delivery") || gw_hits["duplicate-delivery-represent"] > 0 || gw_hits["reply-delivery-failure"] > 0;
|
|
63272
63292
|
return { agent, turns: turns.length, status_mix, findings, gw_hits, escalate };
|
|
63273
63293
|
}
|
|
63274
63294
|
var HANG_MS = 360000, HANG_MAXTOOLS = 2, GATEWAY_SIGNATURES;
|
|
@@ -63346,6 +63366,12 @@ var init_mapping = __esm(() => {
|
|
|
63346
63366
|
job_spec: "steer-or-queue-mid-flight",
|
|
63347
63367
|
signature: "killed:incomplete-turn"
|
|
63348
63368
|
},
|
|
63369
|
+
"send-failed-delivery": {
|
|
63370
|
+
failure_mode: "success-theater",
|
|
63371
|
+
severity: 3,
|
|
63372
|
+
job_spec: "talk-to-agents-from-anywhere",
|
|
63373
|
+
signature: "send-failed:turn-flush-backstop"
|
|
63374
|
+
},
|
|
63349
63375
|
"represent-escalation": {
|
|
63350
63376
|
failure_mode: "drift",
|
|
63351
63377
|
severity: 1,
|
|
@@ -66775,6 +66801,10 @@ Scaffolding agent: ${name}
|
|
|
66775
66801
|
}
|
|
66776
66802
|
return;
|
|
66777
66803
|
}
|
|
66804
|
+
if (result.outcome === "skipped") {
|
|
66805
|
+
console.log(source_default.yellow(`\u21b7 ${result.command} \u2014 skipped (precondition not met at send time)`));
|
|
66806
|
+
return;
|
|
66807
|
+
}
|
|
66778
66808
|
const code = result.errorCode ?? "tmux_failed";
|
|
66779
66809
|
const msg = result.errorMessage ?? "unknown error";
|
|
66780
66810
|
console.error(source_default.red(`inject failed (${code}): ${msg}`));
|
|
@@ -81332,7 +81362,7 @@ async function onHermesMessage(ctx, raw) {
|
|
|
81332
81362
|
sendResponse(ctx, rpcErr(id, -32603, msg));
|
|
81333
81363
|
break;
|
|
81334
81364
|
}
|
|
81335
|
-
const output = injectResult.outcome === "ok" ? injectResult.output ?? "" : `*(${fullCommand} sent)*`;
|
|
81365
|
+
const output = injectResult.outcome === "ok" ? injectResult.output ?? "" : injectResult.outcome === "skipped" ? `*(${fullCommand} skipped \u2014 precondition not met)*` : `*(${fullCommand} sent)*`;
|
|
81336
81366
|
sendResponse(ctx, rpcOk(id, { ok: true, output }));
|
|
81337
81367
|
break;
|
|
81338
81368
|
}
|
|
@@ -26605,7 +26605,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
|
|
|
26605
26605
|
import { dirname as dirname4, join as join7 } from "node:path";
|
|
26606
26606
|
|
|
26607
26607
|
// src/build-info.ts
|
|
26608
|
-
var VERSION = "0.18.
|
|
26608
|
+
var VERSION = "0.18.20";
|
|
26609
26609
|
|
|
26610
26610
|
// src/cli/resolve-version.ts
|
|
26611
26611
|
function readPackageVersion() {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
3
|
"//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
|
|
4
|
-
"version": "0.18.
|
|
4
|
+
"version": "0.18.20",
|
|
5
5
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answer-ready quiescence flush (PR A — "late-delivery" fix).
|
|
3
|
+
*
|
|
4
|
+
* A "silent no-op" turn ends by emitting its final answer as plain transcript
|
|
5
|
+
* `text` (never calling the reply tool). Today that answer is only delivered
|
|
6
|
+
* when claude's `turn_duration`/`turn_end` signal lands — a signal that is
|
|
7
|
+
* KNOWN-UNRELIABLE for terminal-text turns (gateway.ts:13616-13618,
|
|
8
|
+
* 12992-12993). When it doesn't land, delivery falls to the orphaned-reply
|
|
9
|
+
* backstop, whose fuse is itself re-armed by the very answer text it is waiting
|
|
10
|
+
* on (the terminal text stamps `lastStreamEventAt`, so `recentlyStreaming`
|
|
11
|
+
* keeps deferring the ~30 s fuse across the full ~120 s window). Net: a fully
|
|
12
|
+
* composed answer sits ~150-196 s of dead wait AFTER it was ready.
|
|
13
|
+
*
|
|
14
|
+
* This module provides the DETERMINISTIC positive signal that closes that gap:
|
|
15
|
+
* once a turn has a genuine composed terminal answer AND goes quiescent (no
|
|
16
|
+
* in-flight tool, no pending async/background dispatch, no new stream event)
|
|
17
|
+
* for a short debounce (~1 s), a per-turn timer fires and routes the answer
|
|
18
|
+
* into the SAME existing turn-flush send path immediately — instead of waiting
|
|
19
|
+
* on the unreliable turn_end or the multi-minute backstop.
|
|
20
|
+
*
|
|
21
|
+
* The arm/fire decision is factored here as a pure, injectable predicate so the
|
|
22
|
+
* oracle asserts real behavior (mirrors `turn-record-status.ts` /
|
|
23
|
+
* `context-exhaustion.ts`). The gateway feeds it the SAME `decideTurnFlush`
|
|
24
|
+
* classifier the turn-flush branch uses, so a working-preamble, an ack, a tool
|
|
25
|
+
* preamble, or a genuine NO_REPLY turn never arms a spurious flush.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { decideTurnFlush, type FlushDecisionInput } from './turn-flush-safety.js'
|
|
29
|
+
|
|
30
|
+
/** Default answer-ready quiescence debounce (ms). Ken approved ~1 s as
|
|
31
|
+
* "immediate". Env-tunable via SWITCHROOM_ANSWER_READY_FLUSH_MS; 0 (or any
|
|
32
|
+
* non-positive value) is the kill-switch that disables the flush entirely,
|
|
33
|
+
* matching the repo's env-flag convention. */
|
|
34
|
+
export const ANSWER_READY_FLUSH_MS = 1000
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the debounce window from the environment. Returns a positive integer
|
|
38
|
+
* ms, or 0 when disabled (kill-switch) / unparseable / non-positive. The
|
|
39
|
+
* gateway treats 0 as "never arm", so a bad env value fails safe to the default
|
|
40
|
+
* rather than to a hot-loop.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveAnswerReadyFlushMs(
|
|
43
|
+
env: Record<string, string | undefined>,
|
|
44
|
+
): number {
|
|
45
|
+
const raw = env.SWITCHROOM_ANSWER_READY_FLUSH_MS
|
|
46
|
+
if (raw == null || raw.trim() === '') return ANSWER_READY_FLUSH_MS
|
|
47
|
+
const n = Number(raw)
|
|
48
|
+
if (!Number.isFinite(n)) return ANSWER_READY_FLUSH_MS
|
|
49
|
+
// Explicit 0 (or negative) = operator kill-switch → disabled.
|
|
50
|
+
if (n <= 0) return 0
|
|
51
|
+
return Math.floor(n)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AnswerReadyArmInput {
|
|
55
|
+
/** The exact `decideTurnFlush` inputs the turn-flush branch resolves at
|
|
56
|
+
* turn_end. Reusing the classifier is what guarantees the debounce only
|
|
57
|
+
* fires on a genuine composed final answer — never a silent marker, an
|
|
58
|
+
* empty turn, a reply-already-served turn, or a sub-agent turn. */
|
|
59
|
+
flush: FlushDecisionInput
|
|
60
|
+
/** `toolFlightTracker.inFlightCount()` — a live surface tool means the model
|
|
61
|
+
* is still working, not quiescent. */
|
|
62
|
+
inFlightToolCount: number
|
|
63
|
+
/** `pendingProgress.hasPendingAsyncDispatch(key)` — a detached background
|
|
64
|
+
* dispatch (Bash run_in_background, async tool) means work is still pending
|
|
65
|
+
* even though `inFlightCount` is 0. */
|
|
66
|
+
hasPendingAsyncDispatch: boolean
|
|
67
|
+
/** The resolved debounce window; 0 disables (kill-switch). */
|
|
68
|
+
flushWindowMs: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Should the answer-ready quiescence timer be armed / fired right now?
|
|
73
|
+
*
|
|
74
|
+
* True iff the kill-switch is off AND the turn has a genuine flushable answer
|
|
75
|
+
* (`decideTurnFlush` → `flush`) AND the turn is genuinely quiescent (no
|
|
76
|
+
* in-flight surface tool, no pending async/background dispatch). This same
|
|
77
|
+
* predicate gates BOTH the arm (in the `text` handler) AND the fire-time
|
|
78
|
+
* re-verification (in the timer callback) — so a tool that started after the
|
|
79
|
+
* arm, or a reply that landed in the interim, deterministically cancels the
|
|
80
|
+
* flush at fire time even if the explicit disarm was somehow missed.
|
|
81
|
+
*/
|
|
82
|
+
export function shouldArmAnswerReadyFlush(input: AnswerReadyArmInput): boolean {
|
|
83
|
+
if (input.flushWindowMs <= 0) return false
|
|
84
|
+
if (input.inFlightToolCount > 0) return false
|
|
85
|
+
if (input.hasPendingAsyncDispatch) return false
|
|
86
|
+
return decideTurnFlush(input.flush).kind === 'flush'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Opaque timer handle. Real code passes Node's `setTimeout` return; tests can
|
|
90
|
+
* inject fake-timer handles. */
|
|
91
|
+
export type FlushTimerHandle = ReturnType<typeof setTimeout>
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Injectable dependencies for {@link AnswerReadyFlushController}. Everything the
|
|
95
|
+
* orchestration needs from the gateway is threaded through here so the REAL
|
|
96
|
+
* arm / debounce / rollover-guard / fire-time-re-verify / disarm logic is
|
|
97
|
+
* unit-testable without importing the 30k-line gateway module (which has a
|
|
98
|
+
* top-level startup IIFE and cannot be imported). `Turn` is the gateway's
|
|
99
|
+
* `CurrentTurn`; the controller only touches it through these accessors.
|
|
100
|
+
*/
|
|
101
|
+
export interface AnswerReadyFlushDeps<Turn> {
|
|
102
|
+
/** The live turn atom (gateway `currentTurn`). Read at arm time and re-read at
|
|
103
|
+
* fire time so a superseded turn's timer never fires against a fresh atom. */
|
|
104
|
+
getCurrentTurn(): Turn | null
|
|
105
|
+
/** Resolve the `shouldArmAnswerReadyFlush` inputs for a turn (chat/reply/
|
|
106
|
+
* captured-text + live tool-flight + pending-async + the window). */
|
|
107
|
+
getArmInput(turn: Turn): AnswerReadyArmInput
|
|
108
|
+
/** Read / write the per-turn timer handle (stored on the `CurrentTurn`). */
|
|
109
|
+
getTimerHandle(turn: Turn): FlushTimerHandle | null
|
|
110
|
+
setTimerHandle(turn: Turn, handle: FlushTimerHandle | null): void
|
|
111
|
+
/**
|
|
112
|
+
* Deliver the composed answer — dispatch the positive `answer-ready-quiescence`
|
|
113
|
+
* synthetic turn_end that routes through the EXISTING turn-flush send path
|
|
114
|
+
* (endCurrentTurnAtomic → send-gated IIFE → PR-B honest record). The controller
|
|
115
|
+
* calls this AT MOST ONCE per turn (guarded by the rollover + timer-cleared
|
|
116
|
+
* checks). endCurrentTurnAtomic then nulls the atom, so a later real turn_end
|
|
117
|
+
* short-circuits — the exactly-once guarantee.
|
|
118
|
+
*/
|
|
119
|
+
onFlush(turn: Turn): void
|
|
120
|
+
/** Injectable timer primitives (default to the globals). */
|
|
121
|
+
setTimeoutFn?: (fn: () => void, ms: number) => FlushTimerHandle
|
|
122
|
+
clearTimeoutFn?: (handle: FlushTimerHandle) => void
|
|
123
|
+
log?: (msg: string) => void
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The deterministic answer-ready quiescence flush orchestration, extracted from
|
|
128
|
+
* the gateway so it is testable as a unit (mirrors `withTurnEndGateBackstop`).
|
|
129
|
+
*
|
|
130
|
+
* - `reset()` — called from `case 'text'`: clear any pending timer, then (re)arm
|
|
131
|
+
* iff the turn currently classifies as a genuine flushable answer AND is
|
|
132
|
+
* quiescent. Each text chunk re-arms → the debounce.
|
|
133
|
+
* - `clear(turn)` — the DISARM: called on any tool activity and from
|
|
134
|
+
* `endCurrentTurnAtomic`. A real turn_end that lands first cancels a pending
|
|
135
|
+
* flush (exactly-once), and a resumed turn (tool started) cancels a stale one.
|
|
136
|
+
*
|
|
137
|
+
* On fire the controller re-pins `getCurrentTurn() === turn` (rollover guard),
|
|
138
|
+
* clears the handle, RE-VERIFIES quiescence (a tool that started / a reply that
|
|
139
|
+
* landed since the arm cancels the flush deterministically), then dispatches
|
|
140
|
+
* exactly one `onFlush`.
|
|
141
|
+
*/
|
|
142
|
+
export class AnswerReadyFlushController<Turn> {
|
|
143
|
+
constructor(private readonly deps: AnswerReadyFlushDeps<Turn>) {}
|
|
144
|
+
|
|
145
|
+
private get setTimeoutFn(): (fn: () => void, ms: number) => FlushTimerHandle {
|
|
146
|
+
return this.deps.setTimeoutFn ?? ((fn, ms) => setTimeout(fn, ms))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private get clearTimeoutFn(): (handle: FlushTimerHandle) => void {
|
|
150
|
+
return this.deps.clearTimeoutFn ?? ((h) => clearTimeout(h))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Disarm the flush timer for a turn (idempotent). */
|
|
154
|
+
clear(turn: Turn | null): void {
|
|
155
|
+
if (turn == null) return
|
|
156
|
+
const handle = this.deps.getTimerHandle(turn)
|
|
157
|
+
if (handle != null) {
|
|
158
|
+
this.clearTimeoutFn(handle)
|
|
159
|
+
this.deps.setTimerHandle(turn, null)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** (Re)arm the flush timer for the current turn — the debounce. */
|
|
164
|
+
reset(): void {
|
|
165
|
+
const turn = this.deps.getCurrentTurn()
|
|
166
|
+
this.clear(turn)
|
|
167
|
+
if (turn == null) return
|
|
168
|
+
const armInput = this.deps.getArmInput(turn)
|
|
169
|
+
if (!shouldArmAnswerReadyFlush(armInput)) return
|
|
170
|
+
const handle = this.setTimeoutFn(() => this.onExpiry(turn), armInput.flushWindowMs)
|
|
171
|
+
this.deps.setTimerHandle(turn, handle)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Timer-expiry callback. Re-pins the turn, re-verifies quiescence, fires once. */
|
|
175
|
+
private onExpiry(armedTurn: Turn): void {
|
|
176
|
+
const live = this.deps.getCurrentTurn()
|
|
177
|
+
// Rollover guard: a superseded turn's timer must not fire against a fresh atom.
|
|
178
|
+
if (live == null || live !== armedTurn) return
|
|
179
|
+
this.deps.setTimerHandle(live, null)
|
|
180
|
+
// Fire-time re-verification: a tool that started (or a reply that landed)
|
|
181
|
+
// since the arm deterministically cancels the flush even if the explicit
|
|
182
|
+
// disarm was somehow missed.
|
|
183
|
+
if (!shouldArmAnswerReadyFlush(this.deps.getArmInput(live))) return
|
|
184
|
+
this.deps.log?.('answer-ready quiescence flush — delivering composed terminal answer')
|
|
185
|
+
this.deps.onFlush(live)
|
|
186
|
+
}
|
|
187
|
+
}
|