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
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Deterministic ack-queue-apply-confirm for the session-mutating Telegram
3
+ * commands (`/model`, `/effort`).
4
+ *
5
+ * The bug this closes (#TODO): a `/model` or `/effort` command issued while
6
+ * the agent is MID-TURN used to dead-end. The typed `/model <name>` path
7
+ * refused with "⏳ … Try again in a moment." and dropped the request; the
8
+ * menu taps toasted the same refusal; `/effort` had no busy gate at all and
9
+ * silently maybe-failed ("sent, but couldn't confirm it applied"). In every
10
+ * case the operator's choice was lost — they had to notice and re-issue it.
11
+ *
12
+ * The contract now, consistent across BOTH commands and BOTH their typed and
13
+ * menu surfaces:
14
+ *
15
+ * 1. immediately APPLICABLE (session idle) → apply + confirm, unchanged.
16
+ * 2. session MID-TURN → immediately ACK ("📥 … the moment this turn
17
+ * finishes"), deterministically QUEUE the request, then APPLY the moment
18
+ * the agent goes idle and EDIT the ack card into the confirmation.
19
+ *
20
+ * This is a PER-KIND slot store (one slot for `model`, one for `effort`):
21
+ * same-kind last-write-wins — a rapid `/model fable` then `/model opus`
22
+ * queues only the latest (the earlier ack card is edited to "superseded") —
23
+ * while cross-kind requests coexist (a queued `/effort` never displaces a
24
+ * queued `/model`; both apply at idle). It reuses the same idle-gate
25
+ * discipline as `pendingRestarts` / proactive `/compact` — drained at the
26
+ * model-idle gate (`activeTurnStartedAt.size === 0`) with a bounded reaper
27
+ * fallback so a session that never cleanly idles still applies-or-reports.
28
+ * The reaper cap DEFERS while a turn is genuinely in flight (see
29
+ * `drainCapDecision`): forcing mid-turn would hit the typed handler's busy
30
+ * refusal (dropping the choice) or type into the live claude input.
31
+ *
32
+ * The apply itself (running the real handler, recording the session-model
33
+ * override, editing the ack card) lives in the gateway — this module is the
34
+ * pure slot + the ack/confirm/superseded text so it is unit-testable without
35
+ * booting the bot. Mirrors the split shape of model-command.ts /
36
+ * effort-command.ts.
37
+ */
38
+
39
+ export type PendingCommandKind = 'model' | 'effort'
40
+ export type PendingCommandOrigin = 'typed' | 'menu'
41
+
42
+ export interface PendingSessionCommand {
43
+ kind: PendingCommandKind
44
+ /**
45
+ * How the request arrived — `typed` for `/model <name>` / `/effort <level>`,
46
+ * `menu` for an inline-keyboard tap. Determines which handler the drain
47
+ * replays (the typed handler vs the menu-callback handler).
48
+ */
49
+ origin: PendingCommandOrigin
50
+ /**
51
+ * The apply payload the drain replays verbatim:
52
+ * - typed model → the (alias-expanded) model token, e.g. `fable`, `sr-glm-5`
53
+ * - typed effort → the effort level, e.g. `high`
54
+ * - menu → the raw callback data (`mdl:s:<tag>` / `mdl:alias:<a>` /
55
+ * `mdl:sr:<name>` / `eff:s:<level>`), replayed through the
56
+ * menu-callback handler which re-discovers at idle.
57
+ */
58
+ arg: string
59
+ /**
60
+ * Human-facing label for the ack / superseded text (e.g. `fable`, `high`,
61
+ * `Gemini 2.5 Pro`). Display-only — never fed to `claude --model`.
62
+ */
63
+ targetLabel: string
64
+ /** The chat the command came from (for building model deps on the deferred apply). */
65
+ chatId: string
66
+ /** The forum topic, if any. */
67
+ threadId?: number
68
+ /** The chat the ack card was posted to (usually === chatId). */
69
+ ackChatId: string
70
+ /** The ack card's message id — the drain EDITS this into the confirmation. */
71
+ ackMessageId: number
72
+ requestedAt: number
73
+ }
74
+
75
+ export interface PendingSessionCommandSlots {
76
+ /** The queued command of that kind, or null when its slot is empty. */
77
+ get(kind: PendingCommandKind): PendingSessionCommand | null
78
+ /**
79
+ * Enqueue. SAME-KIND last-write-wins: returns the same-kind command it
80
+ * DISPLACED (if any) so the caller can edit that stale ack card into a
81
+ * "superseded" note. CROSS-KIND commands coexist — a `/effort` never
82
+ * displaces a queued `/model` and vice versa.
83
+ */
84
+ set(cmd: PendingSessionCommand): PendingSessionCommand | null
85
+ /** Atomically remove and return the queued command of that kind. */
86
+ take(kind: PendingCommandKind): PendingSessionCommand | null
87
+ /**
88
+ * Atomically remove and return ALL queued commands in enqueue order (a
89
+ * same-kind re-enqueue moves to the back). The drain's and shutdown's read.
90
+ */
91
+ takeAll(): PendingSessionCommand[]
92
+ /** Snapshot of every queued command WITHOUT removing (the reaper's overdue check). */
93
+ list(): PendingSessionCommand[]
94
+ /** Drop every queued command without applying it. */
95
+ clear(): void
96
+ /** 0..2 — mirrors the `pendingRestarts.size` gate shape. */
97
+ readonly size: number
98
+ }
99
+
100
+ /** Create an empty per-kind slot store. */
101
+ export function createPendingSessionCommandSlots(): PendingSessionCommandSlots {
102
+ const slots = new Map<PendingCommandKind, PendingSessionCommand>()
103
+ return {
104
+ get: (kind: PendingCommandKind) => slots.get(kind) ?? null,
105
+ set(cmd: PendingSessionCommand): PendingSessionCommand | null {
106
+ const displaced = slots.get(cmd.kind) ?? null
107
+ // Delete-then-set so a same-kind re-enqueue moves to the BACK of the
108
+ // enqueue order — takeAll applies in latest-request order.
109
+ slots.delete(cmd.kind)
110
+ slots.set(cmd.kind, cmd)
111
+ return displaced
112
+ },
113
+ take(kind: PendingCommandKind): PendingSessionCommand | null {
114
+ const cur = slots.get(kind) ?? null
115
+ slots.delete(kind)
116
+ return cur
117
+ },
118
+ takeAll(): PendingSessionCommand[] {
119
+ const all = [...slots.values()]
120
+ slots.clear()
121
+ return all
122
+ },
123
+ list: () => [...slots.values()],
124
+ clear(): void {
125
+ slots.clear()
126
+ },
127
+ get size(): number {
128
+ return slots.size
129
+ },
130
+ }
131
+ }
132
+
133
+ /**
134
+ * IO seams the drain iteration needs from the gateway. Extracted so the
135
+ * iteration ORDER + loss-safety invariants (#3042 blocker 1) are unit-testable
136
+ * without booting the bot.
137
+ */
138
+ export interface DrainIo {
139
+ /** Is a session relaunch pending (the live session is going away)? */
140
+ restartPending: () => boolean
141
+ /** Is a turn in flight right now (re-checked per command)? */
142
+ turnInFlight: () => boolean
143
+ /** Apply the command via the real handler; returns the reply body. */
144
+ apply: (cmd: PendingSessionCommand) => Promise<string>
145
+ /** Does this reply body mean "refused because busy" (not applied)? */
146
+ isBusyRefusal: (text: string) => boolean
147
+ /** Resolve a command that must ride the restart carriers; returns card text. */
148
+ resolveForRestartText: (cmd: PendingSessionCommand) => string
149
+ /** Edit the command's ack card. Best-effort. */
150
+ editCard: (cmd: PendingSessionCommand, text: string) => Promise<void>
151
+ /** Re-enqueue a command the drain could not safely apply. */
152
+ reEnqueue: (cmd: PendingSessionCommand) => void
153
+ /** Render the apply-threw failure card text. */
154
+ failureText: (cmd: PendingSessionCommand, err: unknown) => string
155
+ }
156
+
157
+ /**
158
+ * Drain iteration over the commands takeAll() returned. The loss-safety
159
+ * invariant (#3042 blocker 1): when the loop must stop early (turn raced in,
160
+ * or the handler returned a busy refusal), EVERY not-yet-applied taken
161
+ * command — including the current one — is re-enqueued. takeAll() can hold
162
+ * both a model AND an effort command; breaking after re-enqueueing only the
163
+ * current one would silently drop the other with its ack card stuck at
164
+ * "queued" forever.
165
+ */
166
+ export async function drainTakenCommands(
167
+ taken: readonly PendingSessionCommand[],
168
+ io: DrainIo,
169
+ ): Promise<void> {
170
+ for (let i = 0; i < taken.length; i++) {
171
+ const cmd = taken[i]
172
+ if (io.restartPending()) {
173
+ // The live session is going away — carry the choice across the bounce
174
+ // via the durable carriers (or the re-issue fallback).
175
+ await io.editCard(cmd, io.resolveForRestartText(cmd))
176
+ continue
177
+ }
178
+ // Race guard: a new turn may have started before the drain reached this
179
+ // command. Applying now would hit the handlers' busy refusals and stamp
180
+ // "not applied" onto the ack card as a false final state.
181
+ if (io.turnInFlight()) {
182
+ for (const rest of taken.slice(i)) io.reEnqueue(rest)
183
+ return
184
+ }
185
+ let body: string
186
+ try {
187
+ body = await io.apply(cmd)
188
+ } catch (err) {
189
+ await io.editCard(cmd, io.failureText(cmd, err))
190
+ continue
191
+ }
192
+ // The handler itself refused because a turn raced in — not applied.
193
+ if (io.isBusyRefusal(body)) {
194
+ for (const rest of taken.slice(i)) io.reEnqueue(rest)
195
+ return
196
+ }
197
+ await io.editCard(cmd, body)
198
+ }
199
+ }
200
+
201
+ /**
202
+ * What the reaper's bounded drain-cap should do this tick.
203
+ *
204
+ * - 'wait' — nothing queued long enough; keep waiting.
205
+ * - 'defer-turn-in-flight' — something IS overdue, but a turn is genuinely
206
+ * in flight. Forcing a drain now would destroy the queued command: the
207
+ * typed model path hits handleModelCommand's busy gate (the old "try
208
+ * again" refusal, choice dropped) and /effort would type into the live
209
+ * claude input. The cap only rescues the MISSED-IDLE-GATE case, so keep
210
+ * waiting — the idle gate drains at turn end.
211
+ * - 'force' — overdue AND idle: the idle gate was missed;
212
+ * force the apply-or-report so the ack card never dangles unresolved.
213
+ */
214
+ export type DrainCapDecision = 'wait' | 'defer-turn-in-flight' | 'force'
215
+
216
+ export function drainCapDecision(
217
+ queued: readonly PendingSessionCommand[],
218
+ now: number,
219
+ capMs: number,
220
+ turnInFlight: boolean,
221
+ ): DrainCapDecision {
222
+ const overdue = queued.some(c => now - c.requestedAt > capMs)
223
+ if (!overdue) return 'wait'
224
+ return turnInFlight ? 'defer-turn-in-flight' : 'force'
225
+ }
226
+
227
+ /** One best-effort ack-card edit the caller should perform. */
228
+ export interface PendingCommandCardEdit {
229
+ chatId: string
230
+ messageId: number
231
+ text: string
232
+ }
233
+
234
+ /**
235
+ * What the gateway should DURABLY record for a queued command that can't
236
+ * apply live because the session is going away (gateway shutdown or a pending
237
+ * session relaunch). #3039: instead of telling the operator to re-issue, the
238
+ * choice is persisted to the durable carriers (`.session-model` /
239
+ * `.session-effort`) that start.sh honors on every boot — so the queued
240
+ * command still deterministically applies, just via the relaunch.
241
+ *
242
+ * - 'model' — persist `arg` as the `.session-model` override
243
+ * - 'effort' — persist `arg` as the `.session-effort` override
244
+ * - 'clear-model' — the queued command was `/model default`: clear the carrier
245
+ * - 'clear-effort' — the queued command was `/effort default`: clear the carrier
246
+ * - null — not offline-resolvable (a `mdl:s:<tag>` menu selection
247
+ * needs live picker discovery to become a token); the
248
+ * operator is asked to re-issue after boot.
249
+ */
250
+ export type ShutdownPersistKind = 'model' | 'effort' | 'clear-model' | 'clear-effort' | null
251
+
252
+ export interface ShutdownResolutionAction {
253
+ cmd: PendingSessionCommand
254
+ persist: ShutdownPersistKind
255
+ /** The canonical token / allowlisted level to persist (when persist != null). */
256
+ arg: string
257
+ /** Ack-card edit when the durable write succeeds. */
258
+ persistedText: string
259
+ /** Ack-card edit when persist is null or the durable write failed. */
260
+ reissueText: string
261
+ }
262
+
263
+ const EFFORT_MENU_SELECT_PREFIX = 'eff:s:'
264
+
265
+ function classifyShutdownPersist(cmd: PendingSessionCommand): { persist: ShutdownPersistKind; arg: string } {
266
+ if (cmd.kind === 'effort') {
267
+ const level = cmd.origin === 'menu' && cmd.arg.startsWith(EFFORT_MENU_SELECT_PREFIX)
268
+ ? cmd.arg.slice(EFFORT_MENU_SELECT_PREFIX.length)
269
+ : cmd.arg
270
+ if (level === 'default') return { persist: 'clear-effort', arg: level }
271
+ return { persist: 'effort', arg: level }
272
+ }
273
+ // model
274
+ if (cmd.origin === 'menu') return { persist: null, arg: cmd.arg } // mdl:s:<tag> — needs live discovery
275
+ if (cmd.arg === 'default') return { persist: 'clear-model', arg: cmd.arg }
276
+ return { persist: 'model', arg: cmd.arg }
277
+ }
278
+
279
+ function persistedText(cmd: PendingSessionCommand, escapeHtml: (s: string) => string): string {
280
+ const noun = KIND_NOUN[cmd.kind]
281
+ const isClear = cmd.arg === 'default' || cmd.targetLabel === 'default'
282
+ if (isClear) {
283
+ return `💾 The session is restarting — your ${noun} override was cleared as requested; the agent boots on the configured default.`
284
+ }
285
+ return `💾 The session is restarting — your \`${escapeHtml(cmd.targetLabel)}\` ${noun} choice is saved and applies as the agent boots.`
286
+ }
287
+
288
+ /**
289
+ * Empty the slots and return, per queued command, what to persist and which
290
+ * ack-card edit to make. The gateway performs the durable writes (this module
291
+ * stays fs-free and unit-testable) and edits with `persistedText` on success
292
+ * or `reissueText` on a null/failed persist. Shared by the SIGTERM shutdown
293
+ * handler AND the drain's pending-restart branch — a queued choice is never
294
+ * dropped with a bare "re-issue" when it can be carried across the bounce.
295
+ */
296
+ export function shutdownResolutionActions(
297
+ slots: PendingSessionCommandSlots,
298
+ escapeHtml: (s: string) => string,
299
+ ): ShutdownResolutionAction[] {
300
+ return slots.takeAll().map(cmd => resolveForRestart(cmd, escapeHtml))
301
+ }
302
+
303
+ /** Single-command form of shutdownResolutionActions (the drain's pending-restart branch). */
304
+ export function resolveForRestart(
305
+ cmd: PendingSessionCommand,
306
+ escapeHtml: (s: string) => string,
307
+ ): ShutdownResolutionAction {
308
+ const { persist, arg } = classifyShutdownPersist(cmd)
309
+ return {
310
+ cmd,
311
+ persist,
312
+ arg,
313
+ persistedText: persistedText(cmd, escapeHtml),
314
+ reissueText: restartSupersededText(cmd, escapeHtml),
315
+ }
316
+ }
317
+
318
+ const KIND_NOUN: Record<PendingCommandKind, string> = {
319
+ model: 'model',
320
+ effort: 'effort',
321
+ }
322
+
323
+ const KIND_VERB: Record<PendingCommandKind, string> = {
324
+ model: 'switch to',
325
+ effort: 'set effort to',
326
+ }
327
+
328
+ /**
329
+ * The immediate ACK shown when a command is queued behind a live turn. Names
330
+ * the target so the operator sees their choice was captured, not dropped.
331
+ * `escapeHtml` guards the (already shape-gated) label for the HTML send path.
332
+ */
333
+ export function ackText(
334
+ kind: PendingCommandKind,
335
+ targetLabel: string,
336
+ escapeHtml: (s: string) => string,
337
+ ): string {
338
+ return `📥 Agent is mid-turn — I'll ${KIND_VERB[kind]} \`${escapeHtml(targetLabel)}\` the moment this turn finishes.`
339
+ }
340
+
341
+ /**
342
+ * Edited onto a stale ack card when a NEWER command of the same slot displaces
343
+ * it (last-write-wins). Tells the operator their earlier choice was replaced,
344
+ * not silently lost.
345
+ */
346
+ export function supersededText(
347
+ displaced: PendingSessionCommand,
348
+ next: PendingSessionCommand,
349
+ escapeHtml: (s: string) => string,
350
+ ): string {
351
+ return `↩️ Superseded — queued \`${escapeHtml(next.targetLabel)}\` instead (your earlier \`${escapeHtml(displaced.targetLabel)}\` ${KIND_NOUN[displaced.kind]} request was replaced before it applied).`
352
+ }
353
+
354
+ /**
355
+ * Edited onto the ack card when the queued command could not apply because the
356
+ * session is restarting (a Claude-model / effort session change does not
357
+ * survive the bounce). The operator is told to re-issue after boot rather than
358
+ * being left believing it landed.
359
+ */
360
+ export function restartSupersededText(
361
+ cmd: PendingSessionCommand,
362
+ escapeHtml: (s: string) => string,
363
+ ): string {
364
+ return `↩️ The session is restarting before your \`${escapeHtml(cmd.targetLabel)}\` ${KIND_NOUN[cmd.kind]} change could apply — re-issue \`/${cmd.kind} ${escapeHtml(cmd.targetLabel)}\` once the agent is back.`
365
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * In-memory storage for the LONG-TAIL of gateway.ts pending-state Maps —
3
+ * the auth/vault-wizard, config-edit correlation, and transient capture Maps
4
+ * that PR #3008 deferred when it moved the four agent-initiated approval-card
5
+ * families behind `approval-card-stores.ts`. Phase 3 step 2 of the gateway
6
+ * decomposition (see #2996): STORAGE ONLY.
7
+ *
8
+ * Two shapes, mirroring the two shapes the long-tail Maps actually have:
9
+ *
10
+ * - `createSweepableStore<T>(isExpired)` — a Map-compatible store whose
11
+ * `sweep(now)` deletes every entry the caller's `isExpired(value, now)`
12
+ * predicate reports as past its TTL. The predicate is supplied by the
13
+ * gateway and CLOSES OVER the family's TTL constant, so the TTL and its
14
+ * comparison DIRECTION stay verbatim in gateway.ts — different families use
15
+ * `now - staged_at > TTL`, `now - startedAt > TTL`, `now - createdAt > TTL`,
16
+ * or an absolute `now > expiresAt`, and each is preserved byte-identically.
17
+ * `sweep` is a plain delete-past-TTL loop (no wake / side effects); it
18
+ * replaces the identical open-coded reaper / standalone-sweep loops. The
19
+ * delete-during-iteration is safe (JS Map iterators tolerate deleting the
20
+ * current key) — exactly as the original loops relied on.
21
+ *
22
+ * - `createPlainStore<T>()` — a Map-compatible store with NO sweep, for the
23
+ * long-tail Maps whose lifetime is bounded by per-entry timers or an
24
+ * LRU cap rather than a TTL sweep (`pendingAskUser`, `agentButtonMeta`).
25
+ *
26
+ * Why a Map-compatible surface (get/set/delete/has/size/keys/values/entries/
27
+ * forEach/iteration) rather than a bespoke API: every existing gateway call
28
+ * site used the raw Map directly. Preserving the exact Map method surface —
29
+ * and keeping the variable name unchanged — keeps those call sites
30
+ * BYTE-IDENTICAL. The store moves WHERE the Map is constructed and (for the
31
+ * sweepable shape) WHERE its sweep lives, nothing about how the gateway reads
32
+ * or writes it.
33
+ *
34
+ * The backing Map is the sole storage and backs every iterator, so mutation-
35
+ * during-iteration order/semantics are identical to the raw Map the gateway
36
+ * used before.
37
+ */
38
+
39
+ export interface PlainStore<T> {
40
+ get(key: string): T | undefined
41
+ set(key: string, value: T): void
42
+ delete(key: string): boolean
43
+ has(key: string): boolean
44
+ clear(): void
45
+ readonly size: number
46
+ keys(): IterableIterator<string>
47
+ values(): IterableIterator<T>
48
+ entries(): IterableIterator<[string, T]>
49
+ forEach(cb: (value: T, key: string, map: Map<string, T>) => void): void
50
+ [Symbol.iterator](): IterableIterator<[string, T]>
51
+ }
52
+
53
+ export interface SweepableStore<T> extends PlainStore<T> {
54
+ /**
55
+ * Delete every entry past its TTL per the injected `isExpired` predicate.
56
+ * Plain delete-past-TTL, no wake — byte-identical to the open-coded reaper /
57
+ * standalone sweep loop it replaces.
58
+ */
59
+ sweep(now: number): void
60
+ }
61
+
62
+ // Build the shared Map surface onto `target` (a getter for `size`, so the
63
+ // live Map size is read on every access — an object spread would freeze it).
64
+ function attachMapSurface<T, S extends object>(target: S, map: Map<string, T>): S & PlainStore<T> {
65
+ return Object.defineProperties(target, {
66
+ get: { value: (key: string) => map.get(key), enumerable: true },
67
+ set: { value: (key: string, value: T) => void map.set(key, value), enumerable: true },
68
+ delete: { value: (key: string) => map.delete(key), enumerable: true },
69
+ has: { value: (key: string) => map.has(key), enumerable: true },
70
+ clear: { value: () => map.clear(), enumerable: true },
71
+ size: { get: () => map.size, enumerable: true },
72
+ keys: { value: () => map.keys(), enumerable: true },
73
+ values: { value: () => map.values(), enumerable: true },
74
+ entries: { value: () => map.entries(), enumerable: true },
75
+ forEach: {
76
+ value: (cb: (value: T, key: string, m: Map<string, T>) => void) => map.forEach(cb),
77
+ enumerable: true,
78
+ },
79
+ [Symbol.iterator]: { value: () => map[Symbol.iterator](), enumerable: true },
80
+ }) as S & PlainStore<T>
81
+ }
82
+
83
+ /**
84
+ * Create a Map-backed store with no automatic expiry. For long-tail Maps
85
+ * whose entries are removed by per-entry timers or an LRU cap, not a TTL sweep.
86
+ */
87
+ export function createPlainStore<T>(): PlainStore<T> {
88
+ return attachMapSurface({}, new Map<string, T>())
89
+ }
90
+
91
+ /**
92
+ * Create a Map-backed, self-sweeping store for one long-tail pending-state
93
+ * family. `isExpired` closes over the family's TTL constant and encodes its
94
+ * exact comparison direction; `sweep(now)` deletes every entry it flags.
95
+ */
96
+ export function createSweepableStore<T>(
97
+ isExpired: (value: T, now: number) => boolean,
98
+ ): SweepableStore<T> {
99
+ const map = new Map<string, T>()
100
+ const sweep = (now: number): void => {
101
+ for (const [k, v] of map) {
102
+ if (isExpired(v, now)) map.delete(k)
103
+ }
104
+ }
105
+ return attachMapSurface({ sweep }, map)
106
+ }
@@ -150,6 +150,31 @@ export function buildTimedOutCardEdits(
150
150
  }))
151
151
  }
152
152
 
153
+ /**
154
+ * Suffix appended to a card body when its turn is halted by the operator
155
+ * (#3020 `/stop` / bare "stop" / empty `!`). Same keyboard-strip contract as
156
+ * the timeout path: the halted turn's MCP permission call is being denied,
157
+ * so a later Approve tap must not be able to dispatch into an idle session.
158
+ */
159
+ export const CANCELLED_FOOTER = '\n\n⏹ Cancelled — the turn was stopped'
160
+
161
+ /**
162
+ * Build the (pure) list of card edits the halt path applies when the
163
+ * operator stops the in-flight turn: re-render each recorded card with the
164
+ * cancelled footer and strip the keyboard. Mirrors buildTimedOutCardEdits.
165
+ */
166
+ export function buildCancelledCardEdits(
167
+ cardText: string,
168
+ cards: readonly PermissionCardRef[],
169
+ ): TimedOutCardEdit[] {
170
+ return cards.map(({ chatId, messageId }) => ({
171
+ chatId,
172
+ messageId,
173
+ text: `${cardText}${CANCELLED_FOOTER}`,
174
+ stripKeyboard: true,
175
+ }))
176
+ }
177
+
153
178
  /**
154
179
  * A tap on a permission card is STALE when no pending entry exists for its
155
180
  * request_id — the reaper already auto-denied + deleted it on TTL. A stale
@@ -0,0 +1,30 @@
1
+ import type { Bot } from 'grammy'
2
+ import {
3
+ TELEGRAM_BASE_COMMANDS,
4
+ TELEGRAM_SWITCHROOM_COMMANDS,
5
+ } from '../welcome-text.js'
6
+
7
+ /**
8
+ * Register the bot's slash-command menu with Telegram (`setMyCommands`).
9
+ *
10
+ * Extracted verbatim from gateway.ts (#2996 Phase 5 leaf move). The `bot`
11
+ * singleton is injected rather than imported so this stays a pure leaf with
12
+ * no back-reference into the gateway module.
13
+ *
14
+ * Slash-menu is deliberately trimmed from the full command catalogue.
15
+ * See telegram-plugin/welcome-text.ts TELEGRAM_MENU_COMMANDS for the
16
+ * rationale (mobile UX focus; ops primitives stay typable but out of
17
+ * the autocomplete clutter). /commands surfaces the full list.
18
+ */
19
+ export async function registerSwitchroomBotCommands(bot: Bot): Promise<void> {
20
+ await bot.api.setMyCommands(
21
+ [...TELEGRAM_BASE_COMMANDS, ...TELEGRAM_SWITCHROOM_COMMANDS],
22
+ { scope: { type: 'all_private_chats' } },
23
+ )
24
+ // Group chats don't support /start pairing, so only the switchroom
25
+ // commands are registered there.
26
+ await bot.api.setMyCommands(
27
+ TELEGRAM_SWITCHROOM_COMMANDS,
28
+ { scope: { type: 'all_group_chats' } },
29
+ )
30
+ }
@@ -140,6 +140,20 @@ export interface ResumeInboundContext {
140
140
  * Rendered into the inbound so the resumed session knows what to
141
141
  * re-dispatch. Omitted / empty → the inbound is unchanged. */
142
142
  subagents?: InterruptedSubagent[]
143
+ /** Why the framework itself triggered the restart, when it did (e.g. the
144
+ * bridge-dead escalation, #3038). Surfaced verbatim in the inbound text
145
+ * (`note`) and as `meta.restart_cause` (`reason`) so the resume is
146
+ * HONEST about the cause — a bridge-dead bounce must not read as an
147
+ * operator restart or masquerade as a watchdog timeout. Omitted → the
148
+ * inbound is unchanged (the common human-restart/crash case). */
149
+ restartCause?: { reason: string; note: string }
150
+ }
151
+
152
+ /** Render the optional framework-restart-cause block appended to a resume /
153
+ * report inbound. Empty string when no cause was recorded. */
154
+ function renderRestartCauseBlock(cause: { note: string } | undefined): string {
155
+ if (!cause || cause.note.trim().length === 0) return ''
156
+ return `\n\nWhy this restart happened: ${cause.note.trim()}`
143
157
  }
144
158
 
145
159
  /**
@@ -223,6 +237,7 @@ export function buildResumeInterruptedInbound(ctx: ResumeInboundContext): Inboun
223
237
  started_at: String(ctx.turn.started_at),
224
238
  }
225
239
  if (ctx.turn.user_prompt_preview) meta.original_prompt = ctx.turn.user_prompt_preview
240
+ if (ctx.restartCause) meta.restart_cause = ctx.restartCause.reason
226
241
  return {
227
242
  type: 'inbound',
228
243
  chatId: ctx.turn.chat_id,
@@ -247,7 +262,8 @@ export function buildResumeInterruptedInbound(ctx: ResumeInboundContext): Inboun
247
262
  `actual task. Do not ask whether to resume; just resume. If even after ` +
248
263
  `reading the recent messages you genuinely can't tell what the work was, ` +
249
264
  `say so and ask.` +
250
- renderInterruptedSubagentsBlock(ctx.subagents),
265
+ renderInterruptedSubagentsBlock(ctx.subagents) +
266
+ renderRestartCauseBlock(ctx.restartCause),
251
267
  meta,
252
268
  }
253
269
  }
@@ -287,6 +303,7 @@ export function buildResumeWatchdogReportInbound(
287
303
  }
288
304
  if (ctx.turn.tool_call_count != null) meta.tool_call_count = String(ctx.turn.tool_call_count)
289
305
  if (ctx.turn.user_prompt_preview) meta.original_prompt = ctx.turn.user_prompt_preview
306
+ if (ctx.restartCause) meta.restart_cause = ctx.restartCause.reason
290
307
  return {
291
308
  type: 'inbound',
292
309
  chatId: ctx.turn.chat_id,
@@ -309,7 +326,8 @@ export function buildResumeWatchdogReportInbound(
309
326
  `speculate about a deeper root cause you can't see.` +
310
327
  // Deferred (non-assertive) form: this is the ask-first path — the killed
311
328
  // workers are listed as facts, but re-dispatch waits on the user's call.
312
- renderInterruptedSubagentsBlock(ctx.subagents, { assertive: false }),
329
+ renderInterruptedSubagentsBlock(ctx.subagents, { assertive: false }) +
330
+ renderRestartCauseBlock(ctx.restartCause),
313
331
  meta,
314
332
  }
315
333
  }
@@ -398,6 +416,7 @@ export function buildResumeDeferredReportInbound(
398
416
  started_at: String(ctx.turn.started_at),
399
417
  }
400
418
  if (ctx.turn.user_prompt_preview) meta.original_prompt = ctx.turn.user_prompt_preview
419
+ if (ctx.restartCause) meta.restart_cause = ctx.restartCause.reason
401
420
  const cause =
402
421
  ctx.reason === 'loop-guard'
403
422
  ? `Your previous turn was ALREADY a resume of earlier interrupted work, ` +
@@ -428,7 +447,8 @@ export function buildResumeDeferredReportInbound(
428
447
  // Deferred (non-assertive) form, same as the watchdog path: this is an
429
448
  // ask-first inbound — killed workers are named as facts, but
430
449
  // re-dispatch waits on the user's call.
431
- renderInterruptedSubagentsBlock(ctx.subagents, { assertive: false }),
450
+ renderInterruptedSubagentsBlock(ctx.subagents, { assertive: false }) +
451
+ renderRestartCauseBlock(ctx.restartCause),
432
452
  meta,
433
453
  }
434
454
  }