switchroom 0.18.8 → 0.18.10

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 (93) hide show
  1. package/README.md +2 -2
  2. package/dist/agent-scheduler/index.js +1 -0
  3. package/dist/auth-broker/index.js +198 -13
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +28 -4
  6. package/dist/host-control/main.js +3 -2
  7. package/dist/vault/approvals/kernel-server.js +2 -1
  8. package/dist/vault/broker/server.js +2 -1
  9. package/package.json +1 -1
  10. package/profiles/_base/start.sh.hbs +119 -37
  11. package/profiles/_shared/dev-protocol.md.hbs +42 -0
  12. package/skills/dev-protocol/SKILL.md +131 -0
  13. package/telegram-plugin/README.md +2 -1
  14. package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
  15. package/telegram-plugin/admin-commands/index.ts +6 -1
  16. package/telegram-plugin/bridge/bridge.ts +23 -1
  17. package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
  18. package/telegram-plugin/chat-lock.ts +13 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +24 -1
  20. package/telegram-plugin/dist/gateway/gateway.js +80236 -77465
  21. package/telegram-plugin/dist/server.js +29 -2
  22. package/telegram-plugin/fallback-card-collapse.ts +131 -0
  23. package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
  24. package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
  25. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
  26. package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
  27. package/telegram-plugin/gateway/effort-command.ts +47 -3
  28. package/telegram-plugin/gateway/gateway.ts +2051 -3180
  29. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
  30. package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
  31. package/telegram-plugin/gateway/model-command.ts +94 -8
  32. package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
  33. package/telegram-plugin/gateway/pending-session-command.ts +365 -0
  34. package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
  35. package/telegram-plugin/gateway/permission-timeout.ts +25 -0
  36. package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
  37. package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
  38. package/telegram-plugin/gateway/session-model-file.ts +166 -23
  39. package/telegram-plugin/gateway/stop-command.ts +56 -0
  40. package/telegram-plugin/photo-precheck.ts +201 -0
  41. package/telegram-plugin/quota-watch.ts +141 -2
  42. package/telegram-plugin/registry/subagents-schema.ts +26 -3
  43. package/telegram-plugin/registry/subagents.test.ts +67 -0
  44. package/telegram-plugin/retry-api-call.ts +31 -0
  45. package/telegram-plugin/subagent-watcher.ts +392 -1
  46. package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
  47. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
  48. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
  49. package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
  50. package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
  51. package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
  52. package/telegram-plugin/tests/effort-command.test.ts +59 -2
  53. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
  54. package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
  55. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
  56. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
  57. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
  58. package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
  59. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
  60. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
  61. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
  62. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
  63. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
  64. package/telegram-plugin/tests/model-command.test.ts +46 -3
  65. package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
  66. package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
  67. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
  68. package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
  69. package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
  70. package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
  71. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
  72. package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
  73. package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
  74. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
  75. package/telegram-plugin/tests/quota-watch.test.ts +225 -0
  76. package/telegram-plugin/tests/session-model-file.test.ts +101 -2
  77. package/telegram-plugin/tests/stop-command.test.ts +234 -0
  78. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
  79. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
  80. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
  81. package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
  82. package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
  83. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
  84. package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
  85. package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
  86. package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
  87. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
  88. package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
  89. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
  90. package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
  91. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
  92. package/telegram-plugin/welcome-text.ts +4 -3
  93. package/telegram-plugin/worker-activity-feed.ts +27 -0
@@ -19,9 +19,16 @@ import {
19
19
  clearRelaunchModelIntent,
20
20
  readConfiguredDefaultModel,
21
21
  intentForRestartReason,
22
+ readRelaunchModelIntent,
23
+ clearStaleGatewayShutdownIntent,
24
+ GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX,
22
25
  SESSION_MODEL_FILE,
23
26
  RELAUNCH_MODEL_INTENT_FILE,
24
27
  CONFIGURED_DEFAULT_MODEL_FILE,
28
+ parseSessionEffort,
29
+ writeSessionEffortFile,
30
+ readSessionEffortFile,
31
+ clearSessionEffortFile,
25
32
  } from '../gateway/session-model-file.js'
26
33
 
27
34
  let dir: string
@@ -108,8 +115,8 @@ describe('intentForRestartReason — the triggerSelfRestart per-reason table (RF
108
115
  expect(intentForRestartReason(reason)).toBe('keep')
109
116
  })
110
117
 
111
- it('inline-button-restart (operator-deliberate) revert', () => {
112
- expect(intentForRestartReason('inline-button-restart')).toBe('revert')
118
+ it('inline-button-restart → keep (#3039: a restart is not "clear my model")', () => {
119
+ expect(intentForRestartReason('inline-button-restart')).toBe('keep')
113
120
  })
114
121
 
115
122
  it('unknown gateway reasons default to keep (only gateway code calls triggerSelfRestart; crashes never do)', () => {
@@ -117,6 +124,47 @@ describe('intentForRestartReason — the triggerSelfRestart per-reason table (RF
117
124
  })
118
125
  })
119
126
 
127
+ describe('readRelaunchModelIntent', () => {
128
+ it('round-trips a stamped intent; null when absent / corrupt / malformed', () => {
129
+ expect(readRelaunchModelIntent(dir)).toBeNull()
130
+ writeRelaunchModelIntent(dir, 'keep', 'watchdog recovery')
131
+ const rec = readRelaunchModelIntent(dir)!
132
+ expect(rec.intent).toBe('keep')
133
+ expect(rec.reason).toBe('watchdog recovery')
134
+ expect(Math.abs(Date.now() - rec.ts)).toBeLessThan(5000)
135
+ writeFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), '{broken')
136
+ expect(readRelaunchModelIntent(dir)).toBeNull()
137
+ writeFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), '{"intent":"maybe","reason":"x","ts":1}\n')
138
+ expect(readRelaunchModelIntent(dir)).toBeNull()
139
+ })
140
+ })
141
+
142
+ describe('clearStaleGatewayShutdownIntent (#3018 finding 4 — a gateway-only bounce must not leave a keep stamp)', () => {
143
+ it('clears ONLY a gateway-shutdown-stamped intent and reports it', () => {
144
+ writeRelaunchModelIntent(
145
+ dir,
146
+ 'keep',
147
+ `${GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX} graceful SIGTERM shutdown (deploy/rolling restart) — preserving user-chosen session model`,
148
+ )
149
+ expect(clearStaleGatewayShutdownIntent(dir)).toBe(true)
150
+ expect(existsSync(join(dir, RELAUNCH_MODEL_INTENT_FILE))).toBe(false)
151
+ // Idempotent: a second call finds nothing.
152
+ expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
153
+ })
154
+
155
+ it('never touches a triggerSelfRestart / user-slash stamp (un-prefixed reason)', () => {
156
+ writeRelaunchModelIntent(dir, 'keep', 'sr-to-claude-model-switch')
157
+ expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
158
+ expect(readRelaunchModelIntent(dir)!.reason).toBe('sr-to-claude-model-switch')
159
+ })
160
+
161
+ it('is a safe no-op on an absent or corrupt intent file', () => {
162
+ expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
163
+ writeFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), 'not json')
164
+ expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
165
+ })
166
+ })
167
+
120
168
  describe('readConfiguredDefaultModel', () => {
121
169
  it('reads the trimmed value; null when absent or empty', () => {
122
170
  expect(readConfiguredDefaultModel(dir)).toBeNull()
@@ -130,3 +178,54 @@ describe('readConfiguredDefaultModel', () => {
130
178
  expect(() => clearSessionModelFile(dir)).not.toThrow()
131
179
  })
132
180
  })
181
+
182
+ // ─── #3039: durable session-effort carrier ───────────────────────────────────
183
+
184
+ describe('session-effort file helpers (#3039)', () => {
185
+ let dir: string
186
+ beforeEach(() => {
187
+ dir = mkdtempSync(join(tmpdir(), 'sr-session-effort-'))
188
+ })
189
+ afterEach(() => {
190
+ rmSync(dir, { recursive: true, force: true })
191
+ })
192
+
193
+ it('round-trips a valid level with the configured default at write', () => {
194
+ writeSessionEffortFile(dir, 'xhigh', 'low')
195
+ const rec = readSessionEffortFile(dir)
196
+ expect(rec?.level).toBe('xhigh')
197
+ expect(rec?.configuredDefaultAtWrite).toBe('low')
198
+ expect(typeof rec?.ts).toBe('number')
199
+ })
200
+
201
+ it('refuses to persist a non-allowlisted level (verbatim --effort surface)', () => {
202
+ expect(() => writeSessionEffortFile(dir, 'mega; rm -rf /', 'low')).toThrow(/non-allowlisted/)
203
+ expect(readSessionEffortFile(dir)).toBeNull()
204
+ })
205
+
206
+ it('parseSessionEffort rejects corrupt JSON, bad shape, and bad levels', () => {
207
+ expect(parseSessionEffort('{broken')).toBeNull()
208
+ expect(parseSessionEffort('{"level":"turbo","configuredDefaultAtWrite":"low","ts":1}')).toBeNull()
209
+ expect(parseSessionEffort('{"level":"high","ts":1}')).toBeNull()
210
+ expect(parseSessionEffort('{"level":"high","configuredDefaultAtWrite":"low","ts":1}')?.level).toBe('high')
211
+ })
212
+
213
+ it('clearSessionEffortFile removes it; read after clear is null', () => {
214
+ writeSessionEffortFile(dir, 'high', '')
215
+ clearSessionEffortFile(dir)
216
+ expect(readSessionEffortFile(dir)).toBeNull()
217
+ })
218
+ })
219
+
220
+ describe('intentForRestartReason is keep-for-everything (#3039)', () => {
221
+ it('every reason keeps — restarts never clear a user model choice', () => {
222
+ for (const reason of [
223
+ 'inline-button-restart',
224
+ 'user: /restart from chat',
225
+ 'schedule-restart-immediate',
226
+ 'anything-else',
227
+ ]) {
228
+ expect(intentForRestartReason(reason)).toBe('keep')
229
+ }
230
+ })
231
+ })
@@ -0,0 +1,234 @@
1
+ /**
2
+ * #3020 — Telegram `/stop` command + bare operator "stop" keyword.
3
+ *
4
+ * Pure-logic coverage for telegram-plugin/gateway/stop-command.ts (parser +
5
+ * reply builder), plus structural pins on gateway.ts wiring in the style of
6
+ * gateway-pending-command-wiring.test.ts — the halt sequence lives in
7
+ * un-exported inline closures, so we assert on the source structure.
8
+ * Also pins the /stop → /agentstop rename in the command catalogue.
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest'
12
+ import { readFileSync } from 'node:fs'
13
+ import { fileURLToPath } from 'node:url'
14
+ import { dirname, resolve } from 'node:path'
15
+ import { parseStopKeyword, buildStopReply } from '../gateway/stop-command.js'
16
+ import { switchroomHelpCommandNames, switchroomHelpText } from '../welcome-text.js'
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url))
19
+ const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
20
+
21
+ describe('parseStopKeyword', () => {
22
+ it('matches the exact word stop, case-insensitively', () => {
23
+ for (const t of ['stop', 'Stop', 'STOP', 'sToP']) {
24
+ expect(parseStopKeyword(t), t).toBe(true)
25
+ }
26
+ })
27
+ it('tolerates surrounding whitespace and a single trailing . or !', () => {
28
+ for (const t of [' stop ', 'stop.', 'stop!', ' STOP! ', '\nstop\n']) {
29
+ expect(parseStopKeyword(t), JSON.stringify(t)).toBe(true)
30
+ }
31
+ })
32
+ it('does NOT match stop inside normal speech', () => {
33
+ for (const t of [
34
+ 'stop the build',
35
+ 'please stop',
36
+ 'stop it',
37
+ 'stopping',
38
+ 'nonstop',
39
+ 'stop?',
40
+ 'stop!!',
41
+ 'stop..',
42
+ '/stop', // the slash command routes via bot.command, not the keyword
43
+ '! stop',
44
+ 'stop now',
45
+ '',
46
+ ' ',
47
+ ]) {
48
+ expect(parseStopKeyword(t), JSON.stringify(t)).toBe(false)
49
+ }
50
+ })
51
+ })
52
+
53
+ describe('buildStopReply', () => {
54
+ it('is honest when nothing is running', () => {
55
+ const r = buildStopReply(false, [])
56
+ expect(r.text).toContain('Nothing running')
57
+ expect(r.text).not.toContain('cancelled')
58
+ })
59
+ it('confirms the cancel when a turn was in flight', () => {
60
+ const r = buildStopReply(true, [])
61
+ expect(r.text).toContain('Turn cancelled')
62
+ expect(r.text).not.toContain('Queued')
63
+ })
64
+ it('mentions surviving queued session commands (singular)', () => {
65
+ const r = buildStopReply(true, ['/model fable'])
66
+ expect(r.text).toContain('Turn cancelled')
67
+ expect(r.text).toContain('/model fable')
68
+ expect(r.text).toContain('survives')
69
+ expect(r.text).toContain('idle')
70
+ })
71
+ it('mentions surviving queued session commands (plural)', () => {
72
+ const r = buildStopReply(true, ['/model fable', '/effort high'])
73
+ expect(r.text).toContain('/model fable')
74
+ expect(r.text).toContain('/effort high')
75
+ expect(r.text).toContain('Queued commands')
76
+ expect(r.text).toContain('survive')
77
+ })
78
+ })
79
+
80
+ describe('gateway wiring — /stop cancels the in-flight turn (#3020)', () => {
81
+ it('registers /stop gated on isAuthorizedSender, honest-idle via turnInFlightForGate, halting via executeHaltNow', () => {
82
+ const idx = GATEWAY_SRC.indexOf("bot.command('stop', async ctx => {")
83
+ expect(idx).toBeGreaterThan(0)
84
+ const win = GATEWAY_SRC.slice(idx, idx + 900)
85
+ expect(win).toContain('if (!isAuthorizedSender(ctx)) return')
86
+ expect(win).toContain('turnInFlightForGate()')
87
+ expect(win).toContain('buildStopReply(false')
88
+ expect(win).toContain("executeHaltNow('stop-command')")
89
+ expect(win).toContain('buildStopReply(true')
90
+ // Queued /model + /effort survive the halt and are named in the reply.
91
+ expect(win).toContain('pendingSessionCommand.list()')
92
+ })
93
+
94
+ it('container stop moved to /agentstop and still dispatches hostd agent_stop', () => {
95
+ const idx = GATEWAY_SRC.indexOf("bot.command('agentstop', async ctx => {")
96
+ expect(idx).toBeGreaterThan(0)
97
+ const win = GATEWAY_SRC.slice(idx, idx + 700)
98
+ expect(win).toContain("op: 'agent_stop'")
99
+ // The new /stop must NOT dispatch a container stop.
100
+ const stopIdx = GATEWAY_SRC.indexOf("bot.command('stop', async ctx => {")
101
+ expect(GATEWAY_SRC.slice(stopIdx, stopIdx + 900)).not.toContain("op: 'agent_stop'")
102
+ })
103
+
104
+ it('executeHaltNow honors the safe-boundary deferral then fires tmux C-c, cancels the obligation, and releases busy state', () => {
105
+ const fnIdx = GATEWAY_SRC.indexOf('async function executeHaltNow(')
106
+ expect(fnIdx).toBeGreaterThan(0)
107
+ const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 5500)
108
+ const deferIdx = win.indexOf('decideInterruptTiming({')
109
+ expect(deferIdx).toBeGreaterThan(0)
110
+ expect(win).toContain('resolveInterruptMaxWaitMs')
111
+ const waitIdx = win.indexOf('await waitForSafeBoundary(')
112
+ const sigintIdx = win.indexOf('sendAgentInterrupt({ agentName })')
113
+ const cancelIdx = win.indexOf('cancelInterruptedObligation()')
114
+ const stampIdx = win.indexOf("endedVia: 'stop' as const")
115
+ const endIdx = win.indexOf('endCurrentTurnAtomic(turn)')
116
+ const releaseIdx = win.indexOf('releaseTurnBufferGate(key, turn)')
117
+ // Ordered: defer-wait → C-c → obligation cancel → clean registry stamp
118
+ // (so the cancelled turn can never boot-resume) → deterministic release.
119
+ expect(waitIdx).toBeGreaterThan(deferIdx)
120
+ expect(sigintIdx).toBeGreaterThan(waitIdx)
121
+ expect(cancelIdx).toBeGreaterThan(sigintIdx)
122
+ expect(stampIdx).toBeGreaterThan(cancelIdx)
123
+ expect(endIdx).toBeGreaterThan(stampIdx)
124
+ expect(releaseIdx).toBeGreaterThan(endIdx)
125
+ })
126
+
127
+ it('executeHaltNow guards against the wrong-turn kill race (snapshot at entry, no-op if the turn changed)', () => {
128
+ const fnIdx = GATEWAY_SRC.indexOf('async function executeHaltNow(')
129
+ const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 5500)
130
+ // Identity snapshotted BEFORE the boundary wait…
131
+ const snapIdx = win.indexOf('const haltTarget = currentTurn')
132
+ const waitIdx = win.indexOf('await waitForSafeBoundary(')
133
+ expect(snapIdx).toBeGreaterThan(0)
134
+ expect(snapIdx).toBeLessThan(waitIdx)
135
+ // …and re-checked AFTER it, before the C-c: a changed turn no-ops both
136
+ // the SIGINT and the teardown (the requester's turn already ended).
137
+ const guardIdx = win.indexOf('currentTurn !== haltTarget')
138
+ const sigintIdx = win.indexOf('sendAgentInterrupt({ agentName })')
139
+ expect(guardIdx).toBeGreaterThan(waitIdx)
140
+ expect(guardIdx).toBeLessThan(sigintIdx)
141
+ const afterGuard = win.slice(guardIdx)
142
+ expect(afterGuard).toMatch(/\n\s*return\b/)
143
+ })
144
+
145
+ it('executeHaltNow cancels the halted turn\'s pending permission cards and kicks the session-command drain', () => {
146
+ const fnIdx = GATEWAY_SRC.indexOf('async function executeHaltNow(')
147
+ const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 5500)
148
+ // Item 4: deny + strip pending Approve/Deny cards so the buffer gate
149
+ // opens and a later tap can't dispatch into an idle session.
150
+ const permCancelIdx = win.indexOf('cancelPendingPermissionsForHalt(origin)')
151
+ expect(permCancelIdx).toBeGreaterThan(0)
152
+ // Item 5: after busy release, drain a queued /model / /effort promptly
153
+ // instead of waiting for the 60s reaper.
154
+ const drainIdx = win.indexOf('if (!turnInFlightForGate()) void drainPendingSessionCommand()')
155
+ const releaseIdx = win.indexOf('releaseTurnBufferGate(key, turn)')
156
+ expect(drainIdx).toBeGreaterThan(releaseIdx)
157
+ // The cancel helper itself denies, strips, and forgets each card.
158
+ const helperIdx = GATEWAY_SRC.indexOf('function cancelPendingPermissionsForHalt(')
159
+ expect(helperIdx).toBeGreaterThan(0)
160
+ const helperWin = GATEWAY_SRC.slice(helperIdx, helperIdx + 1200)
161
+ expect(helperWin).toContain("behavior: 'deny'")
162
+ expect(helperWin).toContain('stripCancelledPermissionCards(')
163
+ expect(helperWin).toContain('pendingPermissions.delete(requestId)')
164
+ expect(helperWin).toContain('permCardStore.remove(requestId)')
165
+ })
166
+
167
+ it('the session-event ingest kicks halt-boundary waiters alongside the deferred-interrupt boundary check', () => {
168
+ const idx = GATEWAY_SRC.indexOf('toolFlightTracker.onEvent(ev)')
169
+ expect(idx).toBeGreaterThan(0)
170
+ const win = GATEWAY_SRC.slice(idx, idx + 600)
171
+ expect(win).toContain('notifyHaltBoundaryWaiters()')
172
+ })
173
+
174
+ it('bare "stop" bypasses the coalescer and routes into executeHaltNow in handleInbound', () => {
175
+ // Coalesce bypass sits next to the `!` bypass.
176
+ const bypassIdx = GATEWAY_SRC.indexOf('parseInterruptMarker(text).isInterrupt')
177
+ expect(bypassIdx).toBeGreaterThan(0)
178
+ const bypassWin = GATEWAY_SRC.slice(bypassIdx, bypassIdx + 800)
179
+ expect(bypassWin).toContain('parseStopKeyword(text)')
180
+ // handleInbound keyword block: honest idle reply or halt + confirmation.
181
+ const kwIdx = GATEWAY_SRC.indexOf("executeHaltNow('stop-keyword')")
182
+ expect(kwIdx).toBeGreaterThan(0)
183
+ const kwWin = GATEWAY_SRC.slice(kwIdx - 1600, kwIdx + 900)
184
+ expect(kwWin).toContain('turnInFlightForGate()')
185
+ expect(kwWin).toContain('buildStopReply(inFlight, queuedLabels)')
186
+ // Intercepted — never forwarded to the agent as a turn.
187
+ expect(kwWin).toContain('return')
188
+ })
189
+
190
+ it('the empty-`!` interrupt routes through the same executeHaltNow helper', () => {
191
+ expect(GATEWAY_SRC).toContain("executeHaltNow('bang-empty')")
192
+ })
193
+
194
+ it('/stop with an argument warns and does NOT halt (old container-stop muscle memory)', () => {
195
+ const idx = GATEWAY_SRC.indexOf("bot.command('stop', async ctx => {")
196
+ const win = GATEWAY_SRC.slice(idx, idx + 1600)
197
+ const argIdx = win.indexOf('const arg = ctx.match?.trim()')
198
+ const haltIdx = win.indexOf("executeHaltNow('stop-command')")
199
+ expect(argIdx).toBeGreaterThan(0)
200
+ // Arg check runs BEFORE the halt and returns without cancelling.
201
+ expect(argIdx).toBeLessThan(haltIdx)
202
+ expect(win).toContain('takes no argument')
203
+ expect(win).toContain('/agentstop')
204
+ expect(win).toContain('Nothing was stopped')
205
+ })
206
+
207
+ it('a photo/attachment captioned "stop" never trips the halt-and-drop path', () => {
208
+ // Coalesce bypass: pure text only.
209
+ const bypassIdx = GATEWAY_SRC.indexOf(
210
+ 'parseStopKeyword(text) && downloadImage == null && attachment == null',
211
+ )
212
+ expect(bypassIdx).toBeGreaterThan(0)
213
+ // handleInbound keyword block: also excludes coalesced extra attachments.
214
+ const kwIdx = GATEWAY_SRC.indexOf("executeHaltNow('stop-keyword')")
215
+ const kwWin = GATEWAY_SRC.slice(kwIdx - 2400, kwIdx)
216
+ expect(kwWin).toContain('downloadImage == null')
217
+ expect(kwWin).toContain('attachment == null')
218
+ expect(kwWin).toContain('extraAttachments == null || extraAttachments.length === 0')
219
+ })
220
+ })
221
+
222
+ describe('command catalogue — /agentstop rename regression (#3020)', () => {
223
+ it('help name array carries both stop (turn cancel) and agentstop (container)', () => {
224
+ expect(switchroomHelpCommandNames as readonly string[]).toContain('stop')
225
+ expect(switchroomHelpCommandNames as readonly string[]).toContain('agentstop')
226
+ })
227
+ it('help text documents /agentstop as the container verb and /stop as the turn cancel', () => {
228
+ const out = switchroomHelpText('assistant')
229
+ expect(out).toContain('/agentstop')
230
+ expect(out).toMatch(/\/stop.*(in-flight|turn)/i)
231
+ // No stale "/stop [name] — stop an agent" container wording.
232
+ expect(out).not.toContain('`/stop [name]`')
233
+ })
234
+ })
@@ -28,6 +28,7 @@ function makeHarness(opts: {
28
28
  configStallThresholdMs?: number
29
29
  configSilentSynthStallThresholdMs?: number
30
30
  configSilentStallTerminalMs?: number
31
+ configInflightTerminalCapMs?: number
31
32
  initialContent?: string
32
33
  } = {}) {
33
34
  const {
@@ -35,6 +36,7 @@ function makeHarness(opts: {
35
36
  configStallThresholdMs,
36
37
  configSilentSynthStallThresholdMs,
37
38
  configSilentStallTerminalMs,
39
+ configInflightTerminalCapMs,
38
40
  initialContent,
39
41
  } = opts
40
42
 
@@ -91,6 +93,7 @@ function makeHarness(opts: {
91
93
  stallThresholdMs: configStallThresholdMs,
92
94
  silentSynthesisStallThresholdMs: configSilentSynthStallThresholdMs ?? configStallThresholdMs,
93
95
  silentStallTerminalMs: configSilentStallTerminalMs,
96
+ inflightTerminalCapMs: configInflightTerminalCapMs,
94
97
  rescanMs: 500,
95
98
  onStall: (_id, idleMs) => stallCalls.push({ idleMs }),
96
99
  onStallTerminal: (id) => stallTerminalCalls.push({ agentId: id }),
@@ -132,6 +135,7 @@ const ENV_KEYS = [
132
135
  'SWITCHROOM_SUBAGENT_STALL_MS',
133
136
  'SWITCHROOM_SUBAGENT_SILENT_SYNTH_STALL_MS',
134
137
  'SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS',
138
+ 'SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS',
135
139
  ] as const
136
140
 
137
141
  describe('subagent-watcher env-var threshold overrides', () => {
@@ -219,23 +223,28 @@ describe('subagent-watcher env-var threshold overrides', () => {
219
223
  expect(h.stallTerminalCalls).toHaveLength(0)
220
224
  })
221
225
 
222
- it('a Bash-in-flight worker skips the tight stall threshold but still releases the terminal gate', () => {
223
- // End-to-end of the long-runner suppression under compressed env-tuned
224
- // windows: a worker mid-`Bash` (a) is NOT flagged at the tight active-loop
225
- // threshold, (b) IS eventually flagged at the widened silent-synthesis
226
- // window, and (c) the pass-2 terminal synthesis STILL fires the fixed
227
- // window after that the load-bearing completion-gate release is intact.
226
+ it('a Bash-in-flight worker skips the tight stall threshold, DEFERS terminal synthesis while the tool call is in flight, and still releases at the in-flight cap', () => {
227
+ // Design reconciliation (incident 2026-07-10 vs the #2777/#2782
228
+ // contract this test used to encode): a worker mid-`Bash` whose
229
+ // transcript ends in a tool_use with no matching tool_result is NOT
230
+ // silent it's inside a legal long tool call — so the pass-2 terminal
231
+ // synthesis must NOT fire at silentStallTerminalMs (the old contract;
232
+ // it finalised a live worker's card). But the completion-gate release
233
+ // from #2777/#2782 is still load-bearing for a worker that DIED
234
+ // mid-tool, so the deferral is CAPPED at inflightTerminalCapMs of
235
+ // total idle: deferred while in flight, released at the cap.
228
236
  const h = makeHarness({
229
237
  agentId: 'env-thresh-bash',
230
238
  configStallThresholdMs: 1000, // tight active-loop threshold
231
239
  configSilentSynthStallThresholdMs: 10_000, // widened long-runner window
232
240
  configSilentStallTerminalMs: 5000, // compressed terminal window
241
+ configInflightTerminalCapMs: 60_000, // compressed died-mid-tool cap
233
242
  initialContent: buildJSONL(
234
243
  subAgentUserMsg('bg task'),
235
244
  { type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tool-B', name: 'Bash', input: { command: 'npm test' } }] } },
236
245
  ),
237
246
  })
238
- h.advance(500) // register + tail → toolCount=1, lastTool=Bash
247
+ h.advance(500) // register + tail → toolCount=1, lastTool=Bash, tool-B in flight
239
248
  h.unmarkHistorical()
240
249
 
241
250
  // 5s idle — well past the 1s active-loop threshold, but a Bash worker uses
@@ -248,8 +257,17 @@ describe('subagent-watcher env-var threshold overrides', () => {
248
257
  expect(h.stallCalls).toHaveLength(1)
249
258
  expect(h.stallTerminalCalls).toHaveLength(0)
250
259
 
251
- // 5s post-stall terminal synthesis releases the deferred-completion gate.
252
- h.advance(6_000)
260
+ // 5s+ post-stall the OLD contract synthesised here. The in-flight
261
+ // Bash (no tool_result yet) now defers it: a live long-runner must not
262
+ // have its card finalised under it.
263
+ h.advance(6_000) // ~17.5s total idle, < 60s cap
264
+ expect(h.stallTerminalCalls).toHaveLength(0)
265
+ expect(h.finishCalls).toHaveLength(0)
266
+
267
+ // Cross the in-flight cap (total idle ≥ 60s, tool_result still missing)
268
+ // → died-mid-tool: synthesis proceeds and the completion gate releases.
269
+ // The #2777/#2782 release guarantee survives, just later.
270
+ h.advance(50_000)
253
271
  expect(h.stallTerminalCalls).toHaveLength(1)
254
272
  expect(h.finishCalls).toHaveLength(1)
255
273
  })