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.
- package/README.md +2 -2
- package/dist/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +198 -13
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +28 -4
- package/dist/host-control/main.js +3 -2
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +119 -37
- package/profiles/_shared/dev-protocol.md.hbs +42 -0
- package/skills/dev-protocol/SKILL.md +131 -0
- package/telegram-plugin/README.md +2 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
- package/telegram-plugin/admin-commands/index.ts +6 -1
- package/telegram-plugin/bridge/bridge.ts +23 -1
- package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
- package/telegram-plugin/chat-lock.ts +13 -0
- package/telegram-plugin/dist/bridge/bridge.js +24 -1
- package/telegram-plugin/dist/gateway/gateway.js +80236 -77465
- package/telegram-plugin/dist/server.js +29 -2
- package/telegram-plugin/fallback-card-collapse.ts +131 -0
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/effort-command.ts +47 -3
- package/telegram-plugin/gateway/gateway.ts +2051 -3180
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/model-command.ts +94 -8
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-session-command.ts +365 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
- package/telegram-plugin/gateway/session-model-file.ts +166 -23
- package/telegram-plugin/gateway/stop-command.ts +56 -0
- package/telegram-plugin/photo-precheck.ts +201 -0
- package/telegram-plugin/quota-watch.ts +141 -2
- package/telegram-plugin/registry/subagents-schema.ts +26 -3
- package/telegram-plugin/registry/subagents.test.ts +67 -0
- package/telegram-plugin/retry-api-call.ts +31 -0
- package/telegram-plugin/subagent-watcher.ts +392 -1
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
- package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
- package/telegram-plugin/tests/effort-command.test.ts +59 -2
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/model-command.test.ts +46 -3
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
- package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
- package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
- package/telegram-plugin/tests/quota-watch.test.ts +225 -0
- package/telegram-plugin/tests/session-model-file.test.ts +101 -2
- package/telegram-plugin/tests/stop-command.test.ts +234 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
- package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
- package/telegram-plugin/welcome-text.ts +4 -3
- 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
|
}
|