switchroom 0.18.19 → 0.18.21

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 (57) hide show
  1. package/dist/cli/ms-365-write-pretool.mjs +92 -20
  2. package/dist/cli/switchroom.js +59 -6
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
  6. package/profiles/_shared/dev-protocol.md.hbs +2 -0
  7. package/profiles/_shared/execution-discipline.md.hbs +2 -2
  8. package/profiles/coding/CLAUDE.md.hbs +1 -1
  9. package/telegram-plugin/answer-ready-flush.ts +187 -0
  10. package/telegram-plugin/dist/gateway/gateway.js +1114 -184
  11. package/telegram-plugin/format.ts +179 -20
  12. package/telegram-plugin/gateway/cron-session.ts +32 -0
  13. package/telegram-plugin/gateway/gateway.ts +794 -106
  14. package/telegram-plugin/gateway/idle-clear.ts +170 -0
  15. package/telegram-plugin/gateway/inject-handler.ts +11 -0
  16. package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
  17. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
  18. package/telegram-plugin/gateway/turn-record-status.ts +134 -0
  19. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
  20. package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
  21. package/telegram-plugin/narrative-flush.ts +181 -0
  22. package/telegram-plugin/pending-work-progress.ts +65 -1
  23. package/telegram-plugin/registry/subagents-schema.ts +6 -0
  24. package/telegram-plugin/session-tail.ts +6 -1
  25. package/telegram-plugin/silent-end.ts +182 -0
  26. package/telegram-plugin/stream-reply-handler.ts +14 -5
  27. package/telegram-plugin/subagent-watcher.ts +330 -82
  28. package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
  29. package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
  30. package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
  31. package/telegram-plugin/tests/format-consistency.test.ts +54 -34
  32. package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
  33. package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
  34. package/telegram-plugin/tests/idle-clear.test.ts +315 -37
  35. package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
  36. package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
  37. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
  38. package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
  39. package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
  40. package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
  41. package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
  42. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
  43. package/telegram-plugin/tests/silent-end.test.ts +296 -0
  44. package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
  45. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
  46. package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
  47. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
  48. package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
  49. package/telegram-plugin/tests/telegram-format.test.ts +36 -23
  50. package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
  51. package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
  52. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
  53. package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
  54. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +165 -0
  55. package/telegram-plugin/tool-activity-summary.ts +78 -16
  56. package/telegram-plugin/turn-flush-safety.ts +4 -4
  57. 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.21", COMMIT_SHA = "c237ff59";
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]);
@@ -23787,6 +23799,14 @@ function renderDevProtocolFragment(context = {}, profilesRoot = PROFILES_ROOT) {
23787
23799
  const template = import_handlebars.default.compile(source, { noEscape: true });
23788
23800
  return template(context).trimEnd();
23789
23801
  }
23802
+ function renderDelegationGoldenRuleFragment(context = {}, profilesRoot = PROFILES_ROOT) {
23803
+ const fragPath = join4(resolve4(profilesRoot, "_shared"), "delegation-golden-rule.md.hbs");
23804
+ if (!existsSync7(fragPath))
23805
+ return "";
23806
+ const source = readFileSync7(fragPath, "utf-8");
23807
+ const template = import_handlebars.default.compile(source, { noEscape: true });
23808
+ return template(context).trimEnd();
23809
+ }
23790
23810
  function renderReplyDisciplineFragment(context = {}, profilesRoot = PROFILES_ROOT) {
23791
23811
  const fragPath = join4(resolve4(profilesRoot, "_shared"), "reply-discipline.md.hbs");
23792
23812
  if (!existsSync7(fragPath))
@@ -27567,6 +27587,13 @@ function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchro
27567
27587
  rendered = rendered.trimEnd() + `
27568
27588
 
27569
27589
  ` + devProtocol + `
27590
+ `;
27591
+ }
27592
+ const delegationGoldenRule = renderDelegationGoldenRuleFragment(context);
27593
+ if (delegationGoldenRule) {
27594
+ rendered = rendered.trimEnd() + `
27595
+
27596
+ ` + delegationGoldenRule + `
27570
27597
  `;
27571
27598
  }
27572
27599
  }
@@ -28450,6 +28477,13 @@ ${baseAppend}` : TELEGRAM_FORMATTING_FLOOR_CARD;
28450
28477
  rendered = rendered.trimEnd() + `
28451
28478
 
28452
28479
  ` + devProtocol + `
28480
+ `;
28481
+ }
28482
+ const delegationGoldenRule = renderDelegationGoldenRuleFragment(claudeContext);
28483
+ if (delegationGoldenRule) {
28484
+ rendered = rendered.trimEnd() + `
28485
+
28486
+ ` + delegationGoldenRule + `
28453
28487
  `;
28454
28488
  }
28455
28489
  if (agentConfig.claude_md_raw) {
@@ -63192,7 +63226,15 @@ function detectTurnFindings(agent, turns) {
63192
63226
  const dur = typeof t.duration_ms === "number" ? t.duration_ms : 0;
63193
63227
  const synthetic = tid.includes("synthetic-");
63194
63228
  const ts = isoFromTs(t.ts);
63195
- if (st !== "complete" && st !== "no_reply") {
63229
+ if (st === "send_failed") {
63230
+ findings.push({
63231
+ signal: "send-failed-delivery",
63232
+ agent,
63233
+ turn_id: tid,
63234
+ log_pointer: `turns.jsonl:${tid} status=send_failed`,
63235
+ ts
63236
+ });
63237
+ } else if (st !== "complete" && st !== "no_reply") {
63196
63238
  findings.push({
63197
63239
  signal: "killed-incomplete-turn",
63198
63240
  agent,
@@ -63268,7 +63310,7 @@ function scanAgent(agent, turnsText, gatewayText) {
63268
63310
  const turnFindings = detectTurnFindings(agent, turns);
63269
63311
  const { findings: gwFindings, gw_hits } = detectGatewayFindings(agent, gatewayText);
63270
63312
  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;
63313
+ 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
63314
  return { agent, turns: turns.length, status_mix, findings, gw_hits, escalate };
63273
63315
  }
63274
63316
  var HANG_MS = 360000, HANG_MAXTOOLS = 2, GATEWAY_SIGNATURES;
@@ -63346,6 +63388,12 @@ var init_mapping = __esm(() => {
63346
63388
  job_spec: "steer-or-queue-mid-flight",
63347
63389
  signature: "killed:incomplete-turn"
63348
63390
  },
63391
+ "send-failed-delivery": {
63392
+ failure_mode: "success-theater",
63393
+ severity: 3,
63394
+ job_spec: "talk-to-agents-from-anywhere",
63395
+ signature: "send-failed:turn-flush-backstop"
63396
+ },
63349
63397
  "represent-escalation": {
63350
63398
  failure_mode: "drift",
63351
63399
  severity: 1,
@@ -66775,6 +66823,10 @@ Scaffolding agent: ${name}
66775
66823
  }
66776
66824
  return;
66777
66825
  }
66826
+ if (result.outcome === "skipped") {
66827
+ console.log(source_default.yellow(`\u21b7 ${result.command} \u2014 skipped (precondition not met at send time)`));
66828
+ return;
66829
+ }
66778
66830
  const code = result.errorCode ?? "tmux_failed";
66779
66831
  const msg = result.errorMessage ?? "unknown error";
66780
66832
  console.error(source_default.red(`inject failed (${code}): ${msg}`));
@@ -78749,6 +78801,7 @@ function applySubagentsSchema(db) {
78749
78801
  db.exec("ALTER TABLE subagents ADD COLUMN model TEXT");
78750
78802
  }
78751
78803
  db.exec("CREATE INDEX IF NOT EXISTS subagents_jsonl_id ON subagents(jsonl_agent_id)");
78804
+ db.exec("CREATE INDEX IF NOT EXISTS subagents_parent_agent ON subagents(parent_agent_id)");
78752
78805
  }
78753
78806
  function mapSubagentRow(row) {
78754
78807
  return {
@@ -81332,7 +81385,7 @@ async function onHermesMessage(ctx, raw) {
81332
81385
  sendResponse(ctx, rpcErr(id, -32603, msg));
81333
81386
  break;
81334
81387
  }
81335
- const output = injectResult.outcome === "ok" ? injectResult.output ?? "" : `*(${fullCommand} sent)*`;
81388
+ const output = injectResult.outcome === "ok" ? injectResult.output ?? "" : injectResult.outcome === "skipped" ? `*(${fullCommand} skipped \u2014 precondition not met)*` : `*(${fullCommand} sent)*`;
81336
81389
  sendResponse(ctx, rpcOk(id, { ok: true, output }));
81337
81390
  break;
81338
81391
  }
@@ -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.21";
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.21",
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,9 @@
1
+ ## Delegation — the last word
2
+
3
+ This is the tail reminder on purpose: whatever the grounding, execution-bias, and development-protocol guidance above told you to *do*, the default way to do execution-class work is to **delegate it, not to run it inline.**
4
+
5
+ - **Golden rule: when in doubt, delegate.** Any code change, research spanning 3+ file reads, file/report generation, build/deploy, or a task needing 3+ sequential tool calls without user input goes to a sub-agent (`@worker` for changes, `@researcher` for research, `@reviewer` for review) — per the Sub-Agent Delegation section. Unnecessary delegation costs a few tokens; a blocked foreground session costs the user's attention.
6
+ - **The main session is for conversation.** Keep your own turns short — dispatch and acknowledge. The user should never wait more than 10 seconds for a response. "Act in-turn" is satisfied by dispatching the worker, not by doing the work yourself.
7
+ - **Anti-pattern:** starting a task inline "because it's almost done", then racking up 5+ tool calls in the foreground. If it's execution-class, hand it off at the first tool call, not the fifth.
8
+
9
+ If no sub-agents are configured, do the work yourself.
@@ -2,6 +2,8 @@
2
2
 
3
3
  How development work gets done here — orient, clarify, align, ship, communicate. These are procedural rules for any substantive coding, infra, or debugging task. For the long-form playbook (design reports, adversarial review structure, re-review verdicts), load the bundled `dev-protocol` skill before starting substantive development work.
4
4
 
5
+ **This protocol governs HOW delegated work is done, not a license to do it inline.** Substantive dev work is execution-class — per the Sub-Agent Delegation section it is dispatched to `@worker`/sub-agents so the foreground session stays free for the user. The orient / clarify / design-align / pipeline / communicate rules below bind that delegated work (and the sub-agent doing it); they are not an invitation for the main session to start editing, building, and testing in-turn instead of delegating.
6
+
5
7
  ### Orient — ground before you build
6
8
 
7
9
  - **Validate, don't assume.** Read the actual code, config, and system state before forming a theory. Never assert a fact you haven't checked this turn.
@@ -14,5 +14,5 @@ When you genuinely can't verify something this turn, say so plainly ("I haven't
14
14
 
15
15
  How you should decide what to do next. These are procedural rules, not vibe.
16
16
 
17
- - **Act in-turn.** If the request is actionable, do it this turn. Don't finish with a plan or promise when tools can move it forward.
18
- - **Non-final turn:** use tools to advance, or ask the one clarifying question that unblocks safe progress. One question, not five.
17
+ - **Act in-turn.** If the request is actionable, move it forward this turn. Don't finish with a plan or promise when tools can advance it. **But "act" composes with the Sub-Agent Delegation rules — it does not override them:** for an execution-class task (any code change, research spanning 3+ file reads, multi-step infra, report generation — see that section), the in-turn act IS dispatching the sub-agent, not doing the work inline. Acting immediately and delegating are the same move here; keep your own turn short and let the worker execute.
18
+ - **Non-final turn:** use tools to advance (dispatch the worker when the task is execution-class), or ask the one clarifying question that unblocks safe progress. One question, not five.
@@ -49,7 +49,7 @@ Save proactively: architecture decisions, codebase patterns, conventions, known
49
49
 
50
50
  ## Sub-Agent Delegation
51
51
 
52
- If sub-agents are configured, delegate implementation to `@worker` (background, own worktree) and research to `@researcher` (background). Keep your turns short — dispatch and acknowledge quickly so you stay available for the user.
52
+ **Golden rule: when in doubt, delegate.** The main session is for conversation; execution belongs in sub-agents. If sub-agents are configured, delegate implementation to `@worker` (background, own worktree), research spanning 3+ file reads to `@researcher` (background), and review to `@reviewer`. Unnecessary delegation costs a few tokens; a blocked foreground session costs the user's attention. Keep your turns short — dispatch and acknowledge quickly so you stay available for the user; the user should never wait more than 10 seconds for a response. Acting in-turn on an execution-class task means dispatching the worker, not doing the work inline.
53
53
 
54
54
  If the user amends in-flight delegated work mid-turn, steer the running worker now (`SendMessage` to the worker by name, or by the agent id from its spawn result) instead of holding the update for handback — and say in your reply whether you folded the update into the running worker or queued it as a separate task; never classify silently. If unsure whether a message amends in-flight work, queue it and say so — queue is the default. If the steer lands too late (worker effectively done), say so and apply the update yourself.
55
55
 
@@ -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
+ }