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
|
@@ -7,17 +7,41 @@
|
|
|
7
7
|
* (b) execute the returned effects against real I/O. This module owns
|
|
8
8
|
* step (b) for the cutover.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* - drainBuffer →
|
|
12
|
-
* - redeliverPersistedPermVerdicts →
|
|
10
|
+
* Effects wired to real executors (all effect kinds now handled):
|
|
11
|
+
* - drainBuffer → redeliverBufferedInbound
|
|
12
|
+
* - redeliverPersistedPermVerdicts → pendingPermissionBuffer.drain → send
|
|
13
|
+
* - deliverToBridge → client.send / ipcServer.sendToAgent
|
|
14
|
+
* - bufferInbound → pendingInboundBuffer.push
|
|
15
|
+
* - persistInbound → inboundSpool.put
|
|
16
|
+
* - deliverPermVerdict → client.send / ipcServer.sendToAgent
|
|
17
|
+
* - persistPermVerdict → pendingPermissionBuffer.push
|
|
18
|
+
* - setTurnStarted / clearTurnStarted → onSetTurnStarted / onClearTurnStarted
|
|
19
|
+
* - noteOutbound → onNoteOutbound
|
|
20
|
+
* - firePoke → onFirePoke
|
|
13
21
|
* - logTrace → executed
|
|
14
22
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
23
|
+
* Live call sites (post PR3c flip, #2794):
|
|
24
|
+
* - bridgeUp (gateway onClientRegistered): drainBuffer /
|
|
25
|
+
* redeliverPersistedPermVerdicts / logTrace.
|
|
26
|
+
* - handleInbound DEFERRED_INBOUND_EMIT: the inbound-routing effects
|
|
27
|
+
* (deliverToBridge / bufferInbound / persistInbound / setTurnStarted)
|
|
28
|
+
* are AUTHORITATIVE when the cutover is on — the imperative twin in
|
|
29
|
+
* gateway.ts runs only under the kill switch or the documented
|
|
30
|
+
* carve-outs (interrupt-while-in-turn, bridge_dead). The twin is
|
|
31
|
+
* deleted in PR4 after the 48h bake (see the "Hard removal plan"
|
|
32
|
+
* checklist in the RFC: PR3b step 2 / PR4 remain open).
|
|
33
|
+
* - turnEnd lifecycle effects remain shadow-only: the imperative
|
|
34
|
+
* turn-end sites (purgeReactionTracking / releaseTurnBufferGate)
|
|
35
|
+
* already execute the effect-equivalents with gates the machine does
|
|
36
|
+
* not model yet (serialize-until-replied drain); PR3b step 2 tracks
|
|
37
|
+
* that flip.
|
|
38
|
+
*
|
|
39
|
+
* The gateway-internal turn/poke effects (setTurnStarted,
|
|
40
|
+
* clearTurnStarted, noteOutbound, firePoke) map to gateway-scope state
|
|
41
|
+
* (`claudeBusyKeys`, the silence-poke ladder) that this decoupled module
|
|
42
|
+
* cannot reach directly, so they are dispatched through OPTIONAL
|
|
43
|
+
* callbacks on the ctx. When a callback is absent the effect logs an
|
|
44
|
+
* `unwired` trace rather than silently no-opping — the trace is the gate.
|
|
21
45
|
*
|
|
22
46
|
* Kill switch: `SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0` disables
|
|
23
47
|
* dispatcher execution and the gateway falls back to imperative-only.
|
|
@@ -26,10 +50,11 @@
|
|
|
26
50
|
|
|
27
51
|
import type {
|
|
28
52
|
Effect,
|
|
53
|
+
ChatKey,
|
|
29
54
|
InboundMessage as MachineInboundMessage,
|
|
30
55
|
} from './inbound-delivery-machine.js'
|
|
31
56
|
import type { IpcServer, IpcClient } from './ipc-server.js'
|
|
32
|
-
import type { InboundMessage } from './ipc-protocol.js'
|
|
57
|
+
import type { InboundMessage, PermissionEvent } from './ipc-protocol.js'
|
|
33
58
|
import type { PendingInboundBuffer } from './pending-inbound-buffer.js'
|
|
34
59
|
import { redeliverBufferedInbound } from './pending-inbound-buffer.js'
|
|
35
60
|
import type { InboundSpool } from './inbound-spool.js'
|
|
@@ -54,6 +79,26 @@ export interface DispatchCtx {
|
|
|
54
79
|
* (clerk lost-message incident, 2026-06-03.)
|
|
55
80
|
*/
|
|
56
81
|
readonly onUserInboundDelivered?: (merged: InboundMessage) => void
|
|
82
|
+
// ── Gateway-internal turn/poke effect callbacks ──────────────────
|
|
83
|
+
// These effects map to gateway-scope state (`claudeBusyKeys`, the
|
|
84
|
+
// silence-poke ladder) that this decoupled module cannot reach. The
|
|
85
|
+
// gateway supplies them when it flips inbound routing through the
|
|
86
|
+
// machine (PR3c). Absent → the effect logs an `unwired` trace.
|
|
87
|
+
readonly onSetTurnStarted?: (key: ChatKey, at: number) => void
|
|
88
|
+
/**
|
|
89
|
+
* Optional: observe the outcome of a `deliverToBridge` effect. The
|
|
90
|
+
* imperative twin branches on `sendToAgent`'s boolean (delivered →
|
|
91
|
+
* steer-ack + busy-mark + delivery-confirm tracking; miss → release
|
|
92
|
+
* reservation + durable-buffer + restart notice). The machine cannot
|
|
93
|
+
* model a send that fails while the bridge is nominally alive, so the
|
|
94
|
+
* PR3c live call site needs the result to run the same post-send
|
|
95
|
+
* branches the twin runs. Called once per deliverToBridge effect,
|
|
96
|
+
* after the send attempt, before `onUserInboundDelivered` enrolment.
|
|
97
|
+
*/
|
|
98
|
+
readonly onDeliverResult?: (key: ChatKey, ok: boolean, msg: InboundMessage) => void
|
|
99
|
+
readonly onClearTurnStarted?: (key: ChatKey) => void
|
|
100
|
+
readonly onNoteOutbound?: (key: ChatKey, at: number) => void
|
|
101
|
+
readonly onFirePoke?: (key: ChatKey, level: 'soft' | 'firm' | 'fallback') => void
|
|
57
102
|
}
|
|
58
103
|
|
|
59
104
|
const enabled = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== '0'
|
|
@@ -168,20 +213,133 @@ function dispatchOne(effect: Effect, ctx: DispatchCtx): void {
|
|
|
168
213
|
return
|
|
169
214
|
}
|
|
170
215
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
216
|
+
case 'deliverToBridge': {
|
|
217
|
+
// Route a single inbound to the live bridge. The machine's
|
|
218
|
+
// InboundMessage.payload carries the real ipc-protocol
|
|
219
|
+
// InboundMessage; the machine treats it as opaque.
|
|
220
|
+
const msg = effect.msg.payload as InboundMessage
|
|
221
|
+
let ok = false
|
|
222
|
+
try {
|
|
223
|
+
if (ctx.client) {
|
|
224
|
+
ctx.client.send(msg)
|
|
225
|
+
ok = true
|
|
226
|
+
} else {
|
|
227
|
+
ok = ctx.ipcServer.sendToAgent(ctx.selfAgent, msg)
|
|
228
|
+
}
|
|
229
|
+
} catch (err) {
|
|
230
|
+
log(
|
|
231
|
+
`telegram gateway: dispatch deliverToBridge send threw agent=${ctx.selfAgent} ` +
|
|
232
|
+
`key=${effect.key}: ${(err as Error).message}\n`,
|
|
233
|
+
)
|
|
234
|
+
ok = false
|
|
235
|
+
}
|
|
236
|
+
if (ctx.onDeliverResult) {
|
|
237
|
+
try {
|
|
238
|
+
ctx.onDeliverResult(effect.key, ok, msg)
|
|
239
|
+
} catch {
|
|
240
|
+
/* observer is best-effort; never breaks delivery */
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (ok && ctx.onUserInboundDelivered) {
|
|
244
|
+
// Enrol in the deliver-until-acked sweep — a socket write is not
|
|
245
|
+
// proof claude consumed it (see onUserInboundDelivered doc).
|
|
246
|
+
// Pass UNCONDITIONALLY, mirroring the drainBuffer path above:
|
|
247
|
+
// the callback self-gates via shouldTrackDelivery (inbound-
|
|
248
|
+
// delivery-confirm.ts), which REJECTS sourced messages and
|
|
249
|
+
// tracks real user inbounds (meta.source undefined) — exactly
|
|
250
|
+
// the messages the sweep protects. Do NOT pre-filter on
|
|
251
|
+
// meta.source here; an inverted guard would skip user inbounds
|
|
252
|
+
// and enrol only messages the inner gate discards.
|
|
253
|
+
try {
|
|
254
|
+
ctx.onUserInboundDelivered(msg)
|
|
255
|
+
} catch {
|
|
256
|
+
/* enrolment is best-effort; never breaks delivery */
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
log(`gw-trace dispatch deliverToBridge key=${effect.key} ok=${ok}\n`)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
case 'bufferInbound': {
|
|
264
|
+
const msg = effect.msg.payload as InboundMessage
|
|
265
|
+
const buffered = ctx.pendingInboundBuffer.push(ctx.selfAgent, msg)
|
|
266
|
+
log(`gw-trace dispatch bufferInbound key=${effect.key} buffered=${buffered}\n`)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
case 'persistInbound': {
|
|
271
|
+
// Durable spool. `pendingInboundBuffer.push` already spools when a
|
|
272
|
+
// spool is attached to the buffer, so a persistInbound paired with
|
|
273
|
+
// a bufferInbound is idempotent (put() is idempotent by spoolId).
|
|
274
|
+
const msg = effect.msg.payload as InboundMessage
|
|
275
|
+
const put = ctx.inboundSpool ? ctx.inboundSpool.put(ctx.selfAgent, msg) : false
|
|
276
|
+
log(`gw-trace dispatch persistInbound key=${effect.key} put=${put}\n`)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
case 'deliverPermVerdict': {
|
|
281
|
+
// Branch order differs from the imperative twin
|
|
282
|
+
// (gateway.ts dispatchPermissionVerdict), which always goes via
|
|
283
|
+
// `ipcServer.sendToAgent` — it runs outside any bridgeUp context,
|
|
284
|
+
// so it has no just-registered client handle. Here `ctx.client`
|
|
285
|
+
// is preferred WHEN PRESENT for the same reason as drainBuffer /
|
|
286
|
+
// redeliverPersistedPermVerdicts above: on the bridgeUp path the
|
|
287
|
+
// registry lookup may not yet observe the just-registered client,
|
|
288
|
+
// and a direct send to the connecting socket is the reliable
|
|
289
|
+
// route. PR3c's live call site for machine-driven permVerdict
|
|
290
|
+
// events (non-bridgeUp) must construct the ctx WITHOUT `client`,
|
|
291
|
+
// which makes this branch behave exactly like the twin
|
|
292
|
+
// (sendToAgent). Note the twin additionally buffers on a failed
|
|
293
|
+
// send; the machine models that as a separate persistPermVerdict
|
|
294
|
+
// effect (bridge_dead state), so no fallback push here.
|
|
295
|
+
const ev = effect.verdict.payload as PermissionEvent
|
|
296
|
+
let ok = false
|
|
297
|
+
try {
|
|
298
|
+
if (ctx.client) {
|
|
299
|
+
ctx.client.send(ev as never)
|
|
300
|
+
ok = true
|
|
301
|
+
} else {
|
|
302
|
+
ok = ctx.ipcServer.sendToAgent(ctx.selfAgent, ev as never)
|
|
303
|
+
}
|
|
304
|
+
} catch (err) {
|
|
305
|
+
log(
|
|
306
|
+
`telegram gateway: dispatch deliverPermVerdict send threw agent=${ctx.selfAgent} ` +
|
|
307
|
+
`request=${effect.verdict.requestId}: ${(err as Error).message}\n`,
|
|
308
|
+
)
|
|
309
|
+
ok = false
|
|
310
|
+
}
|
|
311
|
+
log(`gw-trace dispatch deliverPermVerdict request=${effect.verdict.requestId} ok=${ok}\n`)
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
|
|
183
315
|
case 'persistPermVerdict': {
|
|
184
|
-
|
|
316
|
+
const ev = effect.verdict.payload as PermissionEvent
|
|
317
|
+
const pushed = ctx.pendingPermissionBuffer.push(ctx.selfAgent, ev)
|
|
318
|
+
log(`gw-trace dispatch persistPermVerdict request=${effect.verdict.requestId} pushed=${pushed}\n`)
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
case 'setTurnStarted': {
|
|
323
|
+
if (ctx.onSetTurnStarted) ctx.onSetTurnStarted(effect.key, effect.at)
|
|
324
|
+
else log(`gw-trace dispatch unwired effect=setTurnStarted key=${effect.key}\n`)
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
case 'clearTurnStarted': {
|
|
329
|
+
if (ctx.onClearTurnStarted) ctx.onClearTurnStarted(effect.key)
|
|
330
|
+
else log(`gw-trace dispatch unwired effect=clearTurnStarted key=${effect.key}\n`)
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
case 'noteOutbound': {
|
|
335
|
+
if (ctx.onNoteOutbound) ctx.onNoteOutbound(effect.key, effect.at)
|
|
336
|
+
else log(`gw-trace dispatch unwired effect=noteOutbound key=${effect.key}\n`)
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
case 'firePoke': {
|
|
341
|
+
if (ctx.onFirePoke) ctx.onFirePoke(effect.key, effect.level)
|
|
342
|
+
else log(`gw-trace dispatch unwired effect=firePoke key=${effect.key} level=${effect.level}\n`)
|
|
185
343
|
return
|
|
186
344
|
}
|
|
187
345
|
}
|
|
@@ -261,6 +261,14 @@ export function transition(state: State, event: Event): Transition {
|
|
|
261
261
|
const alive = state.global.kind !== 'bridge_dead'
|
|
262
262
|
|
|
263
263
|
if (!alive) {
|
|
264
|
+
// ANCHORED STRING — `inbound_bridge_dead_buffer` is load-bearing:
|
|
265
|
+
// gateway.ts's PR3c bridge-dead carve-out keys off this exact stage
|
|
266
|
+
// string to route the inbound to the imperative twin (whose
|
|
267
|
+
// shouldTrackDelivery drop semantics + restart notice are the
|
|
268
|
+
// contract the machine doesn't model). Renaming it silently reroutes
|
|
269
|
+
// that carve-out; a pin test in inbound-delivery-cutover-flip.test.ts
|
|
270
|
+
// breaks on rename. Both the string key-off and this anchor are
|
|
271
|
+
// deleted in PR4 with the twin.
|
|
264
272
|
return {
|
|
265
273
|
state,
|
|
266
274
|
effects: [
|
|
@@ -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
|
|