switchroom 0.18.9 → 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/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 +1831 -263
- package/telegram-plugin/dist/server.js +29 -2
- package/telegram-plugin/fallback-card-collapse.ts +131 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
- package/telegram-plugin/gateway/effort-command.ts +47 -3
- package/telegram-plugin/gateway/gateway.ts +1435 -211
- package/telegram-plugin/gateway/model-command.ts +94 -8
- package/telegram-plugin/gateway/pending-session-command.ts +365 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -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/bridge-dead-watchdog.test.ts +576 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
- 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/fallback-card-collapse.test.ts +104 -0
- 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/model-command.test.ts +46 -3
- package/telegram-plugin/tests/pending-session-command.test.ts +322 -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/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
|
@@ -175,7 +175,7 @@ export interface ModelCommandReply {
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
const PERSIST_NOTE =
|
|
178
|
-
'_Sticky
|
|
178
|
+
'_Sticky — persists across restarts, deploys, and crashes until \`/model default\` clears it (or the configured \`model:\` in switchroom.yaml changes, which resets it and notifies you). To change the default, set \`model:\` in switchroom.yaml._'
|
|
179
179
|
|
|
180
180
|
function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
|
|
181
181
|
const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `\`${a}\``).join(' · ')
|
|
@@ -230,7 +230,7 @@ export async function handleModelCommand(
|
|
|
230
230
|
// and ask the operator to retry (parity with the menu callback's isBusy check).
|
|
231
231
|
if (deps.isBusy()) {
|
|
232
232
|
return {
|
|
233
|
-
text: '⏳ The agent is mid-turn — a model switch needs an idle session.
|
|
233
|
+
text: '⏳ The agent is mid-turn — a model switch needs an idle session. The switch was not applied.',
|
|
234
234
|
html: true,
|
|
235
235
|
}
|
|
236
236
|
}
|
|
@@ -506,6 +506,26 @@ export const SR_MODEL_ALIASES: Record<string, string> = {
|
|
|
506
506
|
}
|
|
507
507
|
|
|
508
508
|
/** Expand a short alias (case-insensitive) to its full sr-* id, or return the original. */
|
|
509
|
+
/**
|
|
510
|
+
* #3042 review blocker 2a: can `token` be trusted for a DURABLE, boot-applied
|
|
511
|
+
* `.session-model` persist WITHOUT a live confirmation from claude?
|
|
512
|
+
*
|
|
513
|
+
* A queued typed `/model <arg>` that is persisted at shutdown was never
|
|
514
|
+
* validated by claude's picker — and under the keep-by-default boot (#3039)
|
|
515
|
+
* a garbage-but-shape-valid token (e.g. `claude-nonexistnet-9`) would make
|
|
516
|
+
* every boot run `claude --model <garbage>` with the gateway dead. Only
|
|
517
|
+
* tokens with a switchroom-known meaning are offline-trustable: the static
|
|
518
|
+
* Claude aliases (claude resolves them itself) and the curated sr-* alias
|
|
519
|
+
* TARGETS (present in the LiteLLM config by construction). Full `claude-*` /
|
|
520
|
+
* arbitrary `sr-*` ids typed by hand are refused — they need the live
|
|
521
|
+
* session to verify, so the operator is asked to re-issue after boot.
|
|
522
|
+
*/
|
|
523
|
+
export function isOfflineTrustedModelToken(token: string): boolean {
|
|
524
|
+
if ((MODEL_ALIASES as readonly string[]).includes(token)) return true
|
|
525
|
+
if (token in SR_MODEL_ALIASES) return true
|
|
526
|
+
return Object.values(SR_MODEL_ALIASES).includes(token)
|
|
527
|
+
}
|
|
528
|
+
|
|
509
529
|
export function expandSrAlias(arg: string): string {
|
|
510
530
|
return SR_MODEL_ALIASES[arg.toLowerCase()] ?? arg
|
|
511
531
|
}
|
|
@@ -548,10 +568,67 @@ export function modelSelectCallbackData(label: string): string {
|
|
|
548
568
|
return `${MODEL_CALLBACK_SELECT}${labelTag(label)}`
|
|
549
569
|
}
|
|
550
570
|
|
|
551
|
-
|
|
571
|
+
const BUSY_REFUSAL_TEXT =
|
|
572
|
+
'⏳ The agent is mid-turn — the model picker needs an idle prompt. Your tap was not applied.'
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Busy-refusal detector (#3039). The gateway's queued-command drain applies a
|
|
576
|
+
* command believing the session is idle; if a new turn started in the race
|
|
577
|
+
* window the underlying handler still refuses with one of these texts. The
|
|
578
|
+
* drain matches on this and RE-ENQUEUES the command instead of stamping the
|
|
579
|
+
* refusal onto the ack card as a false final state — so no user-visible path
|
|
580
|
+
* ever ends at "try again".
|
|
581
|
+
*/
|
|
582
|
+
export function isBusyRefusalText(text: string): boolean {
|
|
583
|
+
return text.includes('The agent is mid-turn')
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Mid-turn `/model` menu (#3039): instead of refusing ("try again in a
|
|
588
|
+
* moment"), render a STATIC keyboard that needs no picker discovery — the
|
|
589
|
+
* alias rows + the external page. Every switch tap rides the gateway's
|
|
590
|
+
* mid-turn queue (ack → apply at idle → confirm), so opening the menu during
|
|
591
|
+
* a long turn still lets the operator lock in a choice.
|
|
592
|
+
*/
|
|
593
|
+
function busyStaticMenu(
|
|
594
|
+
deps: Pick<ModelMenuDeps, 'escapeHtml'> & Pick<ModelCommandDeps, 'getAgentName'>,
|
|
595
|
+
page: ModelMenuPage,
|
|
596
|
+
): ModelMenuReply {
|
|
597
|
+
const externalNames = externalModelNames([])
|
|
598
|
+
if (page === 'external') {
|
|
599
|
+
return {
|
|
600
|
+
text: [
|
|
601
|
+
`**Model — ${deps.escapeHtml(deps.getAgentName())}** · 🌐 External`,
|
|
602
|
+
'_Agent is mid-turn — taps are queued and apply the moment the turn ends._',
|
|
603
|
+
'These models are **billed separately** via OpenRouter. Tap one to switch the **live session**:',
|
|
604
|
+
PERSIST_NOTE,
|
|
605
|
+
].join('\n'),
|
|
606
|
+
html: true,
|
|
607
|
+
keyboard: externalPageKeyboard(externalNames),
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const rows: ModelMenuKeyboardButton[][] = []
|
|
611
|
+
for (const alias of MODEL_ALIASES) {
|
|
612
|
+
if (alias === 'default') continue
|
|
613
|
+
rows.push([{
|
|
614
|
+
text: alias.charAt(0).toUpperCase() + alias.slice(1),
|
|
615
|
+
callback_data: `${MODEL_CALLBACK_ALIAS}${alias}`,
|
|
616
|
+
}])
|
|
617
|
+
}
|
|
618
|
+
rows.push([{ text: 'Default (configured)', callback_data: `${MODEL_CALLBACK_ALIAS}default` }])
|
|
619
|
+
if (externalNames.length > 0) {
|
|
620
|
+
rows.push([{ text: '🌐 External models ▸', callback_data: MODEL_CALLBACK_PAGE_EXTERNAL }])
|
|
621
|
+
}
|
|
622
|
+
rows.push([{ text: '🔄 Refresh', callback_data: MODEL_CALLBACK_REFRESH }])
|
|
552
623
|
return {
|
|
553
|
-
text:
|
|
624
|
+
text: [
|
|
625
|
+
`**Model — ${deps.escapeHtml(deps.getAgentName())}**`,
|
|
626
|
+
'_Agent is mid-turn, so the live picker can\u2019t be read right now — this is the quick list. A tap is queued and applies the moment the turn ends._',
|
|
627
|
+
'Tap a model to switch the **live session**:',
|
|
628
|
+
PERSIST_NOTE,
|
|
629
|
+
].join('\n'),
|
|
554
630
|
html: true,
|
|
631
|
+
keyboard: rows,
|
|
555
632
|
}
|
|
556
633
|
}
|
|
557
634
|
|
|
@@ -654,7 +731,7 @@ export async function buildModelMenu(
|
|
|
654
731
|
deps: ModelMenuDeps & ModelCommandDeps,
|
|
655
732
|
page: ModelMenuPage = 'main',
|
|
656
733
|
): Promise<ModelMenuReply> {
|
|
657
|
-
if (deps.isBusy()) return
|
|
734
|
+
if (deps.isBusy()) return busyStaticMenu(deps, page)
|
|
658
735
|
|
|
659
736
|
const [discovered, quota, srNames] = await Promise.all([
|
|
660
737
|
deps.discover(deps.getAgentName()),
|
|
@@ -734,6 +811,12 @@ export interface ModelCallbackOutcome {
|
|
|
734
811
|
* "try again" line (which read as "nothing happened").
|
|
735
812
|
*/
|
|
736
813
|
toastOnly?: boolean
|
|
814
|
+
/**
|
|
815
|
+
* True when this outcome is the mid-turn refusal (#3039). The queued-command
|
|
816
|
+
* drain re-enqueues on this instead of stamping the refusal onto the ack
|
|
817
|
+
* card as a false final state.
|
|
818
|
+
*/
|
|
819
|
+
busyRefusal?: boolean
|
|
737
820
|
/**
|
|
738
821
|
* On a successful session switch, the live model name now running (parsed
|
|
739
822
|
* from claude's confirmation, e.g. "Fable 5"). The gateway records this as
|
|
@@ -799,8 +882,9 @@ export async function handleModelMenuCallback(
|
|
|
799
882
|
if (deps.isBusy()) {
|
|
800
883
|
return {
|
|
801
884
|
answer: '⏳ Agent is mid-turn — tap again when it’s idle',
|
|
802
|
-
reply:
|
|
885
|
+
reply: { text: BUSY_REFUSAL_TEXT, html: true },
|
|
803
886
|
toastOnly: true,
|
|
887
|
+
busyRefusal: true,
|
|
804
888
|
}
|
|
805
889
|
}
|
|
806
890
|
let aliasResult: InjectResult
|
|
@@ -862,8 +946,9 @@ export async function handleModelMenuCallback(
|
|
|
862
946
|
if (deps.isBusy()) {
|
|
863
947
|
return {
|
|
864
948
|
answer: '⏳ Agent is mid-turn — tap again when it’s idle',
|
|
865
|
-
reply:
|
|
949
|
+
reply: { text: BUSY_REFUSAL_TEXT, html: true },
|
|
866
950
|
toastOnly: true,
|
|
951
|
+
busyRefusal: true,
|
|
867
952
|
}
|
|
868
953
|
}
|
|
869
954
|
try {
|
|
@@ -896,8 +981,9 @@ export async function handleModelMenuCallback(
|
|
|
896
981
|
if (deps.isBusy()) {
|
|
897
982
|
return {
|
|
898
983
|
answer: '⏳ Agent is mid-turn — tap again when it’s idle',
|
|
899
|
-
reply:
|
|
984
|
+
reply: { text: BUSY_REFUSAL_TEXT, html: true },
|
|
900
985
|
toastOnly: true,
|
|
986
|
+
busyRefusal: true,
|
|
901
987
|
}
|
|
902
988
|
}
|
|
903
989
|
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
@@ -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
|
}
|