switchroom 0.18.31 → 0.18.33

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 (159) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +21 -3
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +1410 -852
  5. package/dist/host-control/main.js +22 -4
  6. package/dist/vault/approvals/kernel-server.js +21 -3
  7. package/dist/vault/broker/server.js +48 -4
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +148 -23
  10. package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
  11. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  12. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  13. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  14. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  15. package/telegram-plugin/gateway/boot-card.ts +169 -1
  16. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  17. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  18. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  19. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  20. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  21. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  22. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  23. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  24. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  25. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  26. package/telegram-plugin/gateway/gateway.ts +6403 -13456
  27. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  28. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  29. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  30. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  32. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  33. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  34. package/telegram-plugin/gateway/model-command.ts +23 -0
  35. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  36. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  37. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  38. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  39. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  40. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  41. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  42. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  43. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  44. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  45. package/telegram-plugin/gateway/turn-end.ts +606 -0
  46. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  47. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  48. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  49. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  50. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  51. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  52. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  53. package/telegram-plugin/narrative-dedup.ts +24 -1
  54. package/telegram-plugin/narrative-flush.ts +2 -2
  55. package/telegram-plugin/pending-user-notice.ts +59 -13
  56. package/telegram-plugin/render/render.ts +25 -1
  57. package/telegram-plugin/status-no-truncate.ts +13 -0
  58. package/telegram-plugin/subagent-watcher.ts +297 -31
  59. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  60. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  61. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  62. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  63. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  64. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  65. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  66. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  67. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  68. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  69. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
  70. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  71. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
  72. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  73. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  74. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  75. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  76. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  77. package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
  78. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  79. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
  80. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  81. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
  82. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
  83. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  85. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
  86. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  87. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
  88. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  89. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  90. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  91. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  92. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  93. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  94. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  95. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
  96. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  97. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  98. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  99. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  100. package/telegram-plugin/tests/model-command.test.ts +30 -0
  101. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
  102. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  103. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  104. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  105. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  106. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  107. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  108. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
  109. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  111. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  112. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  113. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  114. package/telegram-plugin/tests/render/render.test.ts +42 -0
  115. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  116. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  117. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  118. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
  119. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  120. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  121. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  122. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  123. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  124. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  125. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
  126. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  127. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  128. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  129. package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
  130. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
  131. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  132. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  133. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  134. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  135. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  136. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  137. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  138. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  139. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  140. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  141. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
  142. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  143. package/telegram-plugin/tool-activity-summary.ts +110 -38
  144. package/telegram-plugin/turn-flush-safety.ts +80 -14
  145. package/telegram-plugin/uat/restart-capability.ts +76 -0
  146. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  147. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  148. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  149. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  150. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  151. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  152. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  153. package/telegram-plugin/worker-activity-feed.ts +34 -4
  154. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  155. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  156. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  157. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  158. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  159. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -81,8 +81,16 @@ interface TrailEntry {
81
81
  text: string;
82
82
  }
83
83
 
84
+ // Mirrors the production matcher (`pending-work-progress.ts` SUFFIX_RE):
85
+ // the CURRENT emit is the italic rich-markdown form
86
+ // `\n\n_still working (Nm) · message me anytime, I'll keep you posted_`
87
+ // (#2669) — mtcute's parsed `Message.text` strips the italic markers, so
88
+ // the observed text is `\n\nstill working (Nm) · …`. The legacy em-dash
89
+ // prefix (`— still working`) and a literal-underscore render are also
90
+ // tolerated. The old mandatory-em-dash regex could not match ANY current
91
+ // production edit (stale oracle, diagnosed 2026-07-18).
84
92
  const SUFFIX_RE =
85
- /\n\n— still working \(\d+m\)( · message me anytime, I'll keep you posted)?$/;
93
+ /\n\n(?:— |_)?still working \(\d+m\)( · message me anytime, I'll keep you posted)?_?$/;
86
94
 
87
95
  function pad(s: string, n: number): string {
88
96
  return s.length >= n ? s : s + " ".repeat(n - s.length);
@@ -139,11 +147,20 @@ describe("uat: cross-turn pending-async ambient progress (#1445)", () => {
139
147
  if (firstAnchorMsgId == null && entry.kind === "fresh") {
140
148
  firstAnchorMsgId = entry.messageId;
141
149
  }
150
+ // EXACT match only. The prompt demands the completion signal be
151
+ // the single word "done" and nothing else; the loose `/\bdone\b/`
152
+ // fallback false-positived on the model's beat-1 ACK ("…I'll
153
+ // reply \"done\" when it finishes"), tripping a 10s wind-down that
154
+ // quit the test ~22s BEFORE the turn even ended — so the ambient
155
+ // edit mechanism was never observable (#3334 item b, observed UAT
156
+ // v0.18.32 r2). Requiring `trim() === "done"` makes the terminal
157
+ // signal deterministic and un-spoofable by an ack that merely
158
+ // mentions the word.
142
159
  const trimmedFinal = entry.text.trim().toLowerCase();
143
160
  const looksLikeDone =
144
161
  entry.kind === "fresh" &&
145
162
  entry.messageId !== firstAnchorMsgId &&
146
- (trimmedFinal === "done" || /\bdone\b/.test(trimmedFinal));
163
+ trimmedFinal === "done";
147
164
  if (looksLikeDone) {
148
165
  sawDone = true;
149
166
  quiescenceDeadline = Date.now() + 10_000;
@@ -55,8 +55,10 @@
55
55
  import { describe, it, expect, beforeAll } from "vitest";
56
56
  import { execSync } from "node:child_process";
57
57
  import { spinUp } from "../harness.js";
58
+ import { restartCapableOrAnnounceSkip } from "../restart-capability.js";
58
59
 
59
60
  const AGENT = "test-harness";
61
+ const SCENARIO_TITLE = "uat: always-on after restart";
60
62
 
61
63
  // Budget for the marker-safe restart itself (per
62
64
  // feedback_agent_restart_needs_sudo_when_running.md, restart blocks
@@ -74,15 +76,6 @@ const HARD_REPLY_BUDGET_MS = 120_000;
74
76
  // contract but worth logging for forensic visibility.
75
77
  const VISION_REPLY_BUDGET_MS = 30_000;
76
78
 
77
- function canShellSudo(): boolean {
78
- try {
79
- execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
80
- return true;
81
- } catch {
82
- return false;
83
- }
84
- }
85
-
86
79
  function restartAgent(name: string): void {
87
80
  // Marker-safe restart per memory feedback_compose_rollout.md +
88
81
  // feedback_agent_restart_needs_sudo_when_running.md. Apply step
@@ -96,11 +89,12 @@ function restartAgent(name: string): void {
96
89
  }
97
90
 
98
91
  // This scenario requires NOPASSWD sudo + the switchroom CLI on PATH on
99
- // the harness host. Skip on CI runners that don't expose those.
100
- const sudoOk = canShellSudo();
92
+ // the harness host. Skip on CI runners that don't expose those — and when
93
+ // we skip, announce it LOUDLY so a green isn't mistaken for live proof.
94
+ const sudoOk = restartCapableOrAnnounceSkip(SCENARIO_TITLE);
101
95
 
102
96
  (sudoOk ? describe : describe.skip)(
103
- "uat: always-on after restart",
97
+ SCENARIO_TITLE,
104
98
  () => {
105
99
  beforeAll(() => {
106
100
  restartAgent(AGENT);
@@ -21,10 +21,13 @@
21
21
  */
22
22
 
23
23
  import { describe, it, expect } from "vitest";
24
- import { execSync, spawn } from "node:child_process";
24
+ import { spawn } from "node:child_process";
25
25
  import { spinUp } from "../harness.js";
26
+ import { restartCapableOrAnnounceSkip } from "../restart-capability.js";
26
27
 
27
28
  const AGENT = "test-harness";
29
+ const SCENARIO_TITLE =
30
+ "uat: deliberate restart mid-turn resumes exactly once (DM, #2988)";
28
31
  const MID_TURN_MS = 10_000; // let the turn get in-flight before the bounce
29
32
  const RESUME_BUDGET_MS = 180_000; // boot + resume + reply
30
33
  const QUIET_WINDOW_MS = 90_000; // after the resume completes, no second resume may fire
@@ -36,15 +39,6 @@ const SAME_TURN_GRACE_MS = 20_000;
36
39
 
37
40
  const RESUME_FRAMING = /resum|picking .*back|interrupted|cut off|just restarted/i;
38
41
 
39
- function canShellSudo(): boolean {
40
- try {
41
- execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
42
- return true;
43
- } catch {
44
- return false;
45
- }
46
- }
47
-
48
42
  function kickRestartDetached(name: string): void {
49
43
  // A deliberate operator restart: clean SIGTERM path, clean-shutdown marker
50
44
  // written — the exact class #2988 makes resume in-flight work through.
@@ -57,10 +51,10 @@ function kickRestartDetached(name: string): void {
57
51
  child.unref();
58
52
  }
59
53
 
60
- const sudoOk = canShellSudo();
54
+ const sudoOk = restartCapableOrAnnounceSkip(SCENARIO_TITLE);
61
55
 
62
56
  (sudoOk ? describe : describe.skip)(
63
- "uat: deliberate restart mid-turn resumes exactly once (DM, #2988)",
57
+ SCENARIO_TITLE,
64
58
  () => {
65
59
  it(
66
60
  "an operator restart mid-turn resumes the work once, and never a second time",
@@ -23,26 +23,20 @@
23
23
  */
24
24
 
25
25
  import { describe, it, expect } from "vitest";
26
- import { execSync, spawn } from "node:child_process";
26
+ import { spawn } from "node:child_process";
27
27
  import { spinUp } from "../harness.js";
28
+ import { restartCapableOrAnnounceSkip } from "../restart-capability.js";
28
29
 
29
30
  const AGENT = "test-harness";
30
31
  const MID_TURN_MS = 10_000; // let the turn enqueue (become a recorded interrupted turn)
31
32
  const RESUME_BUDGET_MS = 180_000; // boot + resume + reply
32
33
 
34
+ const SCENARIO_TITLE = "uat: interrupted turn resumes after restart (DM)";
35
+
33
36
  // The resume builder tells the model to "briefly let the user know you're
34
37
  // resuming what was interrupted" — so the reply always opens with this framing.
35
38
  const RESUME_FRAMING = /resum|picking .*back|interrupted|cut off|just restarted/i;
36
39
 
37
- function canShellSudo(): boolean {
38
- try {
39
- execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
40
- return true;
41
- } catch {
42
- return false;
43
- }
44
- }
45
-
46
40
  function kickRestartDetached(name: string): void {
47
41
  // --force WITHOUT --wait → recreate now, interrupting the in-flight turn.
48
42
  const child = spawn(
@@ -54,9 +48,9 @@ function kickRestartDetached(name: string): void {
54
48
  child.unref();
55
49
  }
56
50
 
57
- const sudoOk = canShellSudo();
51
+ const sudoOk = restartCapableOrAnnounceSkip(SCENARIO_TITLE);
58
52
 
59
- (sudoOk ? describe : describe.skip)("uat: interrupted turn resumes after restart (DM)", () => {
53
+ (sudoOk ? describe : describe.skip)(SCENARIO_TITLE, () => {
60
54
  it(
61
55
  "a turn interrupted by a restart is resumed and the resume turn completes",
62
56
  async () => {
@@ -70,23 +70,47 @@ const TAIL = "MULTITAIL9";
70
70
 
71
71
  // We need the composed reply to exceed the 32768-char cap so the gateway
72
72
  // chunks it. Asking a model to emit ~40k literal chars is flaky (it'll
73
- // summarize or truncate). Instead we ask it to REPEAT a fixed, cheap-to-
74
- // generate padding block a deterministic number of times — a task an agent
75
- // reliably obeys because it's mechanical, not generative.
76
- const PAD_LINE = "The quick brown fox jumps over the lazy dog. ";
77
- // Overshoot the cap by a comfortable margin so a slightly-short reply still
78
- // splits: (cap / line) rounded up, times a 1.3 safety factor.
79
- const REPEATS = Math.ceil((RICH_MESSAGE_MAX_CHARS / PAD_LINE.length) * 1.3);
73
+ // summarize or truncate). We keep the task MECHANICAL (cycle a fixed
74
+ // sentence list, prefix each line with its index) so an agent reliably
75
+ // obeys — but every emitted line is UNIQUE. Verbatim repetition of one
76
+ // sentence 900+ times reads as degenerate output to Anthropic's content
77
+ // filter and 4xx-kills the turn before any reply exists to send (observed
78
+ // live 2026-07-18, ci-uat run 29634400115: four "unknown-4xx / Output
79
+ // blocked by content filtering policy" events, gateway-supervisor.log
80
+ // 06:43–06:53Z — the reply never reached the send path).
81
+ const PAD_SENTENCES = [
82
+ "The quick brown fox jumps over the lazy dog.",
83
+ "Pack my box with five dozen liquor jugs.",
84
+ "How vexingly quick daft zebras jump.",
85
+ "Sphinx of black quartz, judge my vow.",
86
+ "The five boxing wizards jump quickly.",
87
+ "Jackdaws love my big sphinx of quartz.",
88
+ "Waltz, bad nymph, for quick jigs vex.",
89
+ "Glib jocks quiz nymph to vex dwarf.",
90
+ "Bright vixens jump; dozy fowl quack.",
91
+ "Quick zephyrs blow, vexing daft Jim.",
92
+ ];
93
+ // Conservative per-line floor: the SHORTEST pad sentence plus the numeric
94
+ // prefix. Overshoot the cap by a 1.3 safety factor so a slightly-short
95
+ // reply still splits.
96
+ const PER_LINE_MIN =
97
+ Math.min(...PAD_SENTENCES.map((s) => s.length)) + "Line 1: ".length;
98
+ const LINE_COUNT = Math.ceil((RICH_MESSAGE_MAX_CHARS / PER_LINE_MIN) * 1.3);
80
99
 
81
100
  const PROMPT = [
82
101
  `I need a LONG reply to test message chunking. Do EXACTLY this, nothing else:`,
83
102
  ``,
84
103
  `1. Start your reply with: ${HEAD}: **head bold marker**`,
85
- `2. Then output this exact sentence ${REPEATS} times, each on its own line:`,
86
- ` "${PAD_LINE.trim()}"`,
104
+ `2. Then output ${LINE_COUNT} numbered lines. Line i (1-based) must be:`,
105
+ ` "Line i: " followed by sentence number (i mod 10) from this list`,
106
+ ` (list index 0-9):`,
107
+ ...PAD_SENTENCES.map((s, idx) => ` ${idx}: "${s}"`),
108
+ ` So line 1 is "Line 1: ${PAD_SENTENCES[1]}", line 2 is`,
109
+ ` "Line 2: ${PAD_SENTENCES[2]}", and so on, wrapping the list.`,
87
110
  `3. End your reply with: ${TAIL}: **tail bold marker**`,
88
111
  ``,
89
- `Do not summarize or shorten. Emit all ${REPEATS} repetitions verbatim.`,
112
+ `Every line is unique because of its number. Do not summarize, do not`,
113
+ `stop early — emit all ${LINE_COUNT} lines.`,
90
114
  ].join("\n");
91
115
 
92
116
  function kinds(msg: ObservedMessage): Set<string> {
@@ -104,9 +128,17 @@ function kinds(msg: ObservedMessage): Set<string> {
104
128
  await sc.sendDM(PROMPT);
105
129
 
106
130
  // Chunk 1: the part carrying the HEAD marker.
131
+ //
132
+ // Budget: 240s, not 120s. When the model stages the long output
133
+ // via a file Write+Read (a 900+-line, ~90k-token turn), the
134
+ // inbound→chunks wall time runs ~3m49s (observed UAT v0.18.32 r2:
135
+ // test gave up at 120s / 08:42:13; the correctly-chunked reply
136
+ // actually landed 08:44:02 — a timing-budget oracle defect, not a
137
+ // delivery failure, since both ordered chunks delivered with zero
138
+ // 4xx). 240s clears that staging path with headroom (#3334 item a).
107
139
  const first = await sc.expectMessage(
108
140
  (m: ObservedMessage) => m.text.includes(HEAD) || m.text === "\x01",
109
- { from: "bot", timeout: 120_000 },
141
+ { from: "bot", timeout: 240_000 },
110
142
  );
111
143
  // Chunk 2 (final): the part carrying the TAIL marker. This is a
112
144
  // DIFFERENT message than `first` — if the reply hadn't chunked,
@@ -116,7 +148,7 @@ function kinds(msg: ObservedMessage): Set<string> {
116
148
  (m: ObservedMessage) =>
117
149
  (m.text.includes(TAIL) || m.text === "\x01") &&
118
150
  m.messageId !== first.messageId,
119
- { from: "bot", timeout: 120_000 },
151
+ { from: "bot", timeout: 240_000 },
120
152
  );
121
153
 
122
154
  // (4) Neither part is the unsupported-media sentinel.
@@ -163,7 +195,9 @@ function kinds(msg: ObservedMessage): Set<string> {
163
195
  await sc.tearDown();
164
196
  }
165
197
  },
166
- 180_000,
198
+ // Outer budget must exceed the two sequential 240s expectMessage
199
+ // windows (chunk 1, then chunk 2) plus spinUp settle + slack.
200
+ 540_000,
167
201
  );
168
202
  },
169
203
  );
@@ -54,11 +54,12 @@ import {
54
54
  stripMarkdown,
55
55
  truncate,
56
56
  } from './card-format.js'
57
- import { STATUS_ROLLING_LINES } from './status-no-truncate.js'
57
+ import { WORKER_HISTORY_MAX } from './status-no-truncate.js'
58
58
  import {
59
59
  renderStatusCard,
60
60
  formatStepSuffix,
61
61
  renderCombinedWorkerFeed,
62
+ workerHistoryDepth,
62
63
  type CombinedWorkerRow,
63
64
  } from './tool-activity-summary.js'
64
65
  import { isSendGateShed } from './send-gate.js'
@@ -213,6 +214,9 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
213
214
  final: finished,
214
215
  liveSuffix: finished ? '' : liveSuffix,
215
216
  result,
217
+ // Lone-worker card: window to the w=1 point of Ken's curve (6) so it shows
218
+ // the full recent trail, not the 5-line agent-card default (#3349).
219
+ historyWindow: workerHistoryDepth(1),
216
220
  })
217
221
  if (card == null) {
218
222
  // Unreachable (header always present) — defensive.
@@ -420,7 +424,7 @@ interface WorkerRow {
420
424
  agentId: string
421
425
  /**
422
426
  * Accumulated narrative lines (oldest→newest), deduped within the whole
423
- * rolling window. Rolling-window capped to STATUS_ROLLING_LINES. Grows the
427
+ * rolling window. Rolling-window capped to WORKER_HISTORY_MAX. Grows the
424
428
  * live render so the feed reads like the main agent's answer.
425
429
  */
426
430
  narrative: string[]
@@ -471,6 +475,14 @@ interface WorkerRow {
471
475
  * once past STEP_TIMER_MIN_MS.
472
476
  */
473
477
  stepStartedAtMs: number | null
478
+ /**
479
+ * Stable 1-based ordinal within the group's CURRENT card, assigned from the
480
+ * group's monotonic counter when the row is first registered and IMMUTABLE
481
+ * thereafter. The combined feed renders it as a `{n}. ` header prefix when
482
+ * 2+ workers are running; survivors keep their numbers when an earlier
483
+ * worker finishes (never renumbered positionally — #3298).
484
+ */
485
+ ordinal: number
474
486
  }
475
487
 
476
488
  /**
@@ -511,6 +523,14 @@ interface FeedGroup {
511
523
  chain: Promise<void>
512
524
  /** Live workers in this group, keyed by agentId (insertion ≈ dispatch order). */
513
525
  workers: Map<string, WorkerRow>
526
+ /**
527
+ * Monotonic per-card worker counter — `++counter` hands each newly
528
+ * registered row its stable {@link WorkerRow.ordinal}. Reset to 0 whenever a
529
+ * fresh card starts (group creation, and registration into an emptied group
530
+ * — which covers the group-reuse-after-terminal repaint, since finalize
531
+ * drops rows from `workers` immediately), so every new card numbers from 1.
532
+ */
533
+ workerOrdinalCounter: number
514
534
  /**
515
535
  * Terminal renders (per finishing worker's recap) staged because a 429
516
536
  * cooldown / flood window blocked the edit. Keyed by agentId so a SECOND
@@ -781,8 +801,10 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
781
801
  row.narrative.push(line)
782
802
  // The `→` current-step line just CHANGED — reset the per-step timer.
783
803
  row.stepStartedAtMs = nowFn()
784
- if (row.narrative.length > STATUS_ROLLING_LINES) {
785
- row.narrative.splice(0, row.narrative.length - STATUS_ROLLING_LINES)
804
+ // Retain up to WORKER_HISTORY_MAX (6) — the lone-worker card's deepest
805
+ // window (#3349). STATUS_ROLLING_LINES (5) governs the agent card, not this.
806
+ if (row.narrative.length > WORKER_HISTORY_MAX) {
807
+ row.narrative.splice(0, row.narrative.length - WORKER_HISTORY_MAX)
786
808
  }
787
809
  }
788
810
 
@@ -844,6 +866,9 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
844
866
  const currentStep = r.narrative.length > 0 ? r.narrative[r.narrative.length - 1] : v.latestSummary
845
867
  return {
846
868
  description: v.description,
869
+ // Stable per-card ordinal — assigned at registration, kept for the
870
+ // card's life (survivors don't renumber when an earlier worker ends).
871
+ ordinal: r.ordinal,
847
872
  elapsedMs: elapsedFor(r),
848
873
  toolCount: v.toolCount,
849
874
  totalTokens: v.totalTokens,
@@ -1488,6 +1513,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1488
1513
  cooldownUntil: 0,
1489
1514
  chain: Promise.resolve(),
1490
1515
  workers: new Map(),
1516
+ workerOrdinalCounter: 0,
1491
1517
  pendingFinalize: new Map(),
1492
1518
  terminalPainted: false,
1493
1519
  }
@@ -1512,8 +1538,12 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1512
1538
  }
1513
1539
  let row = g.workers.get(agentId)
1514
1540
  if (row == null) {
1541
+ // A registration into an EMPTIED group starts a fresh card — number
1542
+ // from 1 again rather than continuing the dead card's sequence.
1543
+ if (g.workers.size === 0) g.workerOrdinalCounter = 0
1515
1544
  row = {
1516
1545
  agentId,
1546
+ ordinal: ++g.workerOrdinalCounter,
1517
1547
  narrative: [],
1518
1548
  lastView: null,
1519
1549
  state: 'running',
@@ -1,113 +0,0 @@
1
- /**
2
- * #2787 Mechanism B — the `claudeBusyKeys` orphan-reaper lifecycle, extracted
3
- * so the gateway glue that mutates the busy-key set stays in lockstep with its
4
- * shadow insertion-timestamp map and is unit-testable WITHOUT importing
5
- * `gateway.ts` (which boots the bot + IPC listener on module load and so cannot
6
- * run inside a unit test). The gateway keeps ownership of the two containers
7
- * (`claudeBusyKeys: Set`, `claudeBusyKeySince: Map`) and calls these helpers at
8
- * every mark / reap site; `disconnect-flush.ts` clears them at bridge death.
9
- * The test harness (`tests/busy-key-reaper.test.ts`) wires the SAME containers,
10
- * the SAME delivery queue, and the real `flushOnAgentDisconnect` to reproduce
11
- * the disconnect → reconnect → re-mark and slow-vs-orphan delivery sequences.
12
- *
13
- * Two invariants this module enforces:
14
- *
15
- * 1. LOCKSTEP. The timestamp map shadows actual SET MEMBERSHIP, not its own
16
- * prior presence. `markBusyKeyLockstep` stamps a fresh timestamp whenever a
17
- * key transitions idle→busy (`claudeBusyKeys.has(key) === false`). Gating on
18
- * the set — not on `since.has` — is load-bearing: a disconnect flush clears
19
- * `claudeBusyKeys` directly, so if the shadow map ever lagged, a `!since.has`
20
- * guard would decline to re-stamp on the reconnect→re-mark and leave a
21
- * stale, >TTL-old timestamp on a freshly-marked key — which the reaper would
22
- * then reap out from under a live delivery.
23
- *
24
- * 2. SLOW-DELIVERY SAFETY (the #1922 hazard). `busy` is marked EAGERLY at
25
- * delivery, and the gateway→claude enqueue-ack lag can be up to ~5 MINUTES
26
- * under load (#1922). During that eager-mark→enqueue window the gateway's
27
- * `currentTurn` is null yet the turn is real-and-merely-slow. A bare
28
- * time-grace reaper cannot tell "slow" from "orphaned" and would reap a
29
- * genuine delivery, re-opening the idle-drain gate while claude is about to
30
- * process the very inbound whose key it just reaped (duplicate / concurrent
31
- * delivery on the CORE inbound path). So the reap is PROOF-GATED: a key is
32
- * reaped ONLY when it has NO entry in the delivery-confirm queue. A
33
- * slow-but-real inbound is tracked there from delivery until claude's
34
- * `enqueue` ack (via the never-drop re-deliver loop), so any key still
35
- * awaiting its turn is present and skipped — no matter how slow. The time
36
- * grace is retained as defense-in-depth, defaulting well above the observed
37
- * ack-lag tail. Only a busy-marked key with NO pending delivery AND age past
38
- * the grace is a true orphan (its turn acked and should have cleared busy at
39
- * turn_end, or it was a steer/interrupt inbound excluded from tracking that
40
- * amends a now-absent turn) — reaping those can never clobber a slow one.
41
- */
42
-
43
- /** Lockstep mark: add to the busy set and, on the idle→busy transition only,
44
- * stamp the insertion time. Keyed on set membership so a re-mark after a
45
- * disconnect flush (which cleared the set directly) always re-stamps fresh. */
46
- export function markBusyKeyLockstep(
47
- keys: Set<string>,
48
- since: Map<string, number>,
49
- key: string,
50
- now: number,
51
- ): void {
52
- const wasBusy = keys.has(key)
53
- keys.add(key)
54
- if (!wasBusy) since.set(key, now)
55
- }
56
-
57
- /** Lockstep clear: delete from both containers. */
58
- export function clearBusyKeyLockstep(
59
- keys: Set<string>,
60
- since: Map<string, number>,
61
- key: string,
62
- ): void {
63
- keys.delete(key)
64
- since.delete(key)
65
- }
66
-
67
- export interface ReapOptions {
68
- /** Grace before a busy-marked key with no pending delivery is a true orphan. */
69
- ttlMs: number
70
- /** True iff a delivery-confirm entry is still tracked for this key — i.e. a
71
- * slow-but-real inbound awaiting its enqueue ack. Such keys are NEVER reaped,
72
- * regardless of age (the #1922 slow-delivery guarantee). */
73
- hasPendingDelivery: (key: string) => boolean
74
- /** Optional line logger for each reaped key (observability). */
75
- log?: (msg: string) => void
76
- }
77
-
78
- /**
79
- * Reap orphaned busy markers. Call ONLY once the gateway has asserted no turn is
80
- * in flight (`currentTurn == null`). Returns the keys reaped (for the caller's
81
- * metrics / assertions). Also prunes shadow-map entries whose key already left
82
- * the set, so the map can't grow unbounded.
83
- */
84
- export function reapOrphanBusyKeys(
85
- keys: Set<string>,
86
- since: Map<string, number>,
87
- now: number,
88
- opts: ReapOptions,
89
- ): string[] {
90
- const reaped: string[] = []
91
- for (const key of [...since.keys()]) {
92
- if (!keys.has(key)) {
93
- since.delete(key)
94
- continue
95
- }
96
- // Proof gate: a key with a pending delivery is a slow-but-real inbound still
97
- // awaiting its enqueue ack (the never-drop re-deliver loop owns it) — NEVER
98
- // an orphan, regardless of age. Skip so a multi-minute (#1922) delivery
99
- // can't be reaped out from under claude.
100
- if (opts.hasPendingDelivery(key)) continue
101
- const stampedAt = since.get(key) ?? now
102
- if (now - stampedAt >= opts.ttlMs) {
103
- keys.delete(key)
104
- since.delete(key)
105
- reaped.push(key)
106
- opts.log?.(
107
- `telegram gateway: reaped orphan busy key=${key} (no turn in flight, ` +
108
- `no pending delivery, >${opts.ttlMs}ms) — unwedging idle-drain`,
109
- )
110
- }
111
- }
112
- return reaped
113
- }
@@ -1,102 +0,0 @@
1
- /**
2
- * Gate-parity drift probe — scaffolding for the inbound-delivery
3
- * state-machine cutover (#2794, RFC PR3b→PR4).
4
- *
5
- * ## Why this exists
6
- *
7
- * The inbound-delivery pipeline is mid-migration and TRIPLE-maintained:
8
- *
9
- * 1. the pure state machine (`inbound-delivery-machine.ts`) — the model,
10
- * 2. the shadow (`inbound-delivery-machine-shadow.ts`) — module-scope
11
- * machine state advanced by `shadowEmit`, now AUTHORITATIVE for the
12
- * turn-in-flight gate via `isMachineInTurn()`,
13
- * 3. the legacy imperative `claudeBusyKeys` set — still fully maintained
14
- * in parallel and read as the kill-switch fallback.
15
- *
16
- * Per #2794 the standing risk is DRIFT: because the same turn-lifecycle
17
- * fact ("is a turn in flight?") is tracked in two live places, a fix in
18
- * one can silently diverge from the other. The machine is authoritative
19
- * today, but `claudeBusyKeys` is still wired and still read on the
20
- * kill-switch path — so the two MUST NOT drift in the dangerous direction.
21
- *
22
- * ## What "agree" means (and the ONE intended divergence)
23
- *
24
- * The machine and `claudeBusyKeys` are designed to agree on every
25
- * WELL-FORMED schedule (every turnStart has a matching turnEnd). They are
26
- * deliberately allowed to diverge in exactly ONE direction on a MALFORMED
27
- * schedule — an orphaned turnStart (turn B opens before turn A's turnEnd
28
- * ever lands, the gymbro/clerk 5-min dangle of 2026-05-28):
29
- *
30
- * - `busykeys_dangle` — machine reads idle (self-healed via TTL tick or
31
- * single-activeTurn reopen) while `claudeBusyKeys`
32
- * still holds an orphan key. This is EXPECTED and
33
- * GOOD: it is precisely the wedge the machine was
34
- * made authoritative to kill. Not a drift alarm.
35
- *
36
- * - `machine_over_holds` — machine reads in-flight while `claudeBusyKeys`
37
- * is empty. This is the DANGEROUS direction: the
38
- * now-authoritative gate would hold closed while
39
- * the imperative view says idle — a NEW wedge
40
- * class the cutover must never introduce. This
41
- * is the drift #2794 warns about; surface it.
42
- *
43
- * This module is a pure classifier plus a log-only runtime probe. It
44
- * performs NO I/O of its own beyond an optional injected log sink and
45
- * changes NO delivery behaviour — it only observes. Deleting it once the
46
- * `claudeBusyKeys` shadow is removed in PR4 is a no-op for behaviour.
47
- */
48
-
49
- export type GateParityDivergence = 'none' | 'busykeys_dangle' | 'machine_over_holds'
50
-
51
- /**
52
- * Classify the relationship between the machine's authoritative
53
- * turn-in-flight read and the legacy imperative `claudeBusyKeys` size.
54
- * Pure — no side effects.
55
- */
56
- export function gateParityDivergence(
57
- machineInTurn: boolean,
58
- busyKeysSize: number,
59
- ): GateParityDivergence {
60
- const busy = busyKeysSize > 0
61
- if (machineInTurn === busy) return 'none'
62
- // machine idle, busyKeys non-empty → the orphan dangle the machine heals.
63
- if (!machineInTurn && busy) return 'busykeys_dangle'
64
- // machine in-flight, busyKeys empty → the dangerous over-hold.
65
- return 'machine_over_holds'
66
- }
67
-
68
- /**
69
- * True only for divergences that indicate a real cutover regression
70
- * (the machine holding a gate the imperative shadow believes is open).
71
- * The benign `busykeys_dangle` — the very wedge the machine fixes — is
72
- * NOT flagged, so this probe has zero false positives on the known-good
73
- * self-heal path.
74
- */
75
- export function isDangerousGateDivergence(d: GateParityDivergence): boolean {
76
- return d === 'machine_over_holds'
77
- }
78
-
79
- /**
80
- * Log-only runtime drift canary. Call at the authoritative gate read.
81
- * Emits a single grep-friendly `gw-trace gate-drift` line ONLY on the
82
- * dangerous over-hold direction. Returns the machine value UNCHANGED so
83
- * it can wrap the gate read without altering behaviour:
84
- *
85
- * return probeGateParity(isMachineInTurn(), claudeBusyKeys.size)
86
- *
87
- * @param log optional sink (default stderr) — test hook.
88
- */
89
- export function probeGateParity(
90
- machineInTurn: boolean,
91
- busyKeysSize: number,
92
- log: (line: string) => void = (line) => process.stderr.write(line),
93
- ): boolean {
94
- const d = gateParityDivergence(machineInTurn, busyKeysSize)
95
- if (isDangerousGateDivergence(d)) {
96
- log(
97
- `gw-trace gate-drift kind=${d} machineInTurn=${machineInTurn} ` +
98
- `busyKeys=${busyKeysSize} note=machine-authoritative-gate-holds-while-imperative-idle\n`,
99
- )
100
- }
101
- return machineInTurn
102
- }