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.
Files changed (47) hide show
  1. package/dist/cli/ms-365-write-pretool.mjs +92 -20
  2. package/dist/cli/switchroom.js +36 -6
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/telegram-plugin/answer-ready-flush.ts +187 -0
  6. package/telegram-plugin/dist/gateway/gateway.js +1073 -182
  7. package/telegram-plugin/format.ts +179 -20
  8. package/telegram-plugin/gateway/cron-session.ts +32 -0
  9. package/telegram-plugin/gateway/gateway.ts +775 -105
  10. package/telegram-plugin/gateway/idle-clear.ts +170 -0
  11. package/telegram-plugin/gateway/inject-handler.ts +11 -0
  12. package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
  13. package/telegram-plugin/gateway/turn-record-status.ts +134 -0
  14. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
  15. package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
  16. package/telegram-plugin/narrative-flush.ts +181 -0
  17. package/telegram-plugin/pending-work-progress.ts +65 -1
  18. package/telegram-plugin/session-tail.ts +6 -1
  19. package/telegram-plugin/silent-end.ts +182 -0
  20. package/telegram-plugin/stream-reply-handler.ts +14 -5
  21. package/telegram-plugin/subagent-watcher.ts +244 -81
  22. package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
  23. package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
  24. package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
  25. package/telegram-plugin/tests/format-consistency.test.ts +54 -34
  26. package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
  27. package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
  28. package/telegram-plugin/tests/idle-clear.test.ts +315 -37
  29. package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
  30. package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
  31. package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
  32. package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
  33. package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
  34. package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
  35. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
  36. package/telegram-plugin/tests/silent-end.test.ts +296 -0
  37. package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
  38. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +218 -0
  39. package/telegram-plugin/tests/telegram-format.test.ts +36 -23
  40. package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
  41. package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
  42. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
  43. package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
  44. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +125 -0
  45. package/telegram-plugin/tool-activity-summary.ts +78 -16
  46. package/telegram-plugin/turn-flush-safety.ts +4 -4
  47. 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-event",
22
- "update-event",
23
- "delete-event",
24
- "update-message",
25
- "delete-message"
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
- "list-messages",
38
- "get-message",
39
- "search-mail",
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
- "get-mail-folder",
42
- "whoami",
43
- "get-current-user"
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,
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.18.19", COMMIT_SHA = "34c72776";
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 !== "complete" && st !== "no_reply") {
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.19";
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.19",
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
+ }