switchroom 0.18.15 → 0.18.18
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 +16 -0
- package/dist/auth-broker/index.js +445 -10
- package/dist/cli/notion-write-pretool.mjs +16 -0
- package/dist/cli/switchroom.js +654 -479
- package/dist/host-control/main.js +20 -1
- package/dist/vault/approvals/kernel-server.js +16 -0
- package/dist/vault/broker/server.js +16 -0
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +81 -139
- package/telegram-plugin/bridge/bridge.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -1
- package/telegram-plugin/dist/gateway/gateway.js +1758 -661
- package/telegram-plugin/dist/server.js +26 -1
- package/telegram-plugin/draft-stream.ts +78 -3
- package/telegram-plugin/fleet-fallback-resume.ts +26 -3
- package/telegram-plugin/gateway/approval-hold.ts +49 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +64 -22
- package/telegram-plugin/gateway/effort-command.ts +9 -7
- package/telegram-plugin/gateway/gateway.ts +627 -291
- package/telegram-plugin/gateway/linear-activity.ts +20 -4
- package/telegram-plugin/gateway/litellm-local-notice-wiring.ts +200 -0
- package/telegram-plugin/gateway/model-command.ts +96 -18
- package/telegram-plugin/gateway/pending-session-command.ts +10 -8
- package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
- package/telegram-plugin/gateway/session-model-file.ts +141 -172
- package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
- package/telegram-plugin/litellm-local-notice.ts +189 -0
- package/telegram-plugin/llm-error-present.ts +436 -0
- package/telegram-plugin/operator-events.ts +7 -1
- package/telegram-plugin/permission-title.ts +172 -10
- package/telegram-plugin/premium-recovery.ts +101 -0
- package/telegram-plugin/quota-watch.ts +16 -4
- package/telegram-plugin/raw-error-scrub.ts +73 -0
- package/telegram-plugin/retry-api-call.ts +8 -2
- package/telegram-plugin/runtime-metrics.ts +16 -0
- package/telegram-plugin/send-gate-degraded.test.ts +161 -8
- package/telegram-plugin/send-gate-observability.test.ts +140 -0
- package/telegram-plugin/send-gate-observability.ts +65 -20
- package/telegram-plugin/send-gate.test.ts +143 -1
- package/telegram-plugin/send-gate.ts +246 -23
- package/telegram-plugin/session-tail.ts +16 -0
- package/telegram-plugin/shared/local-time.ts +69 -0
- package/telegram-plugin/stream-controller.ts +143 -20
- package/telegram-plugin/stream-reply-handler.ts +12 -2
- package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
- package/telegram-plugin/tests/bot-api.harness.ts +7 -2
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
- package/telegram-plugin/tests/draft-stream.test.ts +110 -1
- package/telegram-plugin/tests/effort-command.test.ts +4 -4
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +5 -4
- package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +33 -19
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +47 -127
- package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
- package/telegram-plugin/tests/litellm-local-notice.test.ts +417 -0
- package/telegram-plugin/tests/llm-error-present.test.ts +380 -0
- package/telegram-plugin/tests/model-command.test.ts +84 -1
- package/telegram-plugin/tests/permission-title.test.ts +167 -4
- package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
- package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
- package/telegram-plugin/tests/quota-watch.test.ts +21 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +8 -3
- package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
- package/telegram-plugin/tests/session-model-file.test.ts +7 -155
- package/telegram-plugin/tests/stream-controller-send-gate.test.ts +521 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +44 -0
- package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
- package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
- package/telegram-plugin/tests/worker-activity-feed.test.ts +212 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
- package/telegram-plugin/tier-downgrade.ts +198 -0
- package/telegram-plugin/tool-activity-summary.ts +99 -0
- package/telegram-plugin/worker-activity-feed.ts +543 -368
|
@@ -297,11 +297,20 @@ export async function emitLinearAgentActivity(
|
|
|
297
297
|
return { content: [{ type: 'text', text: `Linear ${type} emitted on session ${sessionId}` }] }
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
/** The exact HTML-comment carrying a capture's dedup key. Both the embed
|
|
301
|
+
* (captureDedupMarker) and the dedup lookup match on this precise string, so a
|
|
302
|
+
* re-capture is confirmed by exact-key equality, not fuzzy search relevance.
|
|
303
|
+
* The trailing ` -->` and leading `: ` anchor the key so `abc` never matches
|
|
304
|
+
* `abcd`. */
|
|
305
|
+
export function captureDedupComment(dedupKey: string): string {
|
|
306
|
+
return `<!-- switchroom-capture: ${dedupKey} -->`
|
|
307
|
+
}
|
|
308
|
+
|
|
300
309
|
/** Hidden marker appended to a captured issue's description so a re-capture of
|
|
301
310
|
* the same Telegram message can be detected (dedup backstop; the gateway-side
|
|
302
311
|
* seen-set is the primary, race-free guard). */
|
|
303
312
|
export function captureDedupMarker(dedupKey: string): string {
|
|
304
|
-
return `\n\n
|
|
313
|
+
return `\n\n${captureDedupComment(dedupKey)}`
|
|
305
314
|
}
|
|
306
315
|
|
|
307
316
|
/**
|
|
@@ -390,19 +399,26 @@ export async function createLinearIssue(
|
|
|
390
399
|
}
|
|
391
400
|
|
|
392
401
|
// Dedup backstop: search for a prior capture of the same Telegram message.
|
|
402
|
+
// `searchIssues` is a relevance-ranked full-text search, so its top hit for a
|
|
403
|
+
// key can be an unrelated issue that merely shares tokens. We therefore only
|
|
404
|
+
// treat a result as a dedup match when its description carries the EXACT
|
|
405
|
+
// capture marker for this key, never the raw top hit.
|
|
393
406
|
if (dedupKey) {
|
|
407
|
+
const marker = captureDedupComment(dedupKey)
|
|
394
408
|
const search = await gql(
|
|
395
|
-
'query($term: String!) { searchIssues(term: $term) { nodes { id url title } } }',
|
|
409
|
+
'query($term: String!) { searchIssues(term: $term, first: 25) { nodes { id url title description } } }',
|
|
396
410
|
{ term: dedupKey },
|
|
397
411
|
)
|
|
398
412
|
if (search.ok) {
|
|
399
|
-
const
|
|
413
|
+
const nodes = (search.data?.searchIssues?.nodes ?? []) as Array<{ url?: string; description?: string }>
|
|
414
|
+
const hit = nodes.find((n) => typeof n.description === 'string' && n.description.includes(marker))
|
|
400
415
|
if (hit?.url) {
|
|
401
416
|
log(`telegram gateway: linear_create_issue: dedup hit key=${dedupKey} agent=${agent}\n`)
|
|
402
417
|
return { content: [{ type: 'text', text: `Already filed: ${hit.url}` }] }
|
|
403
418
|
}
|
|
404
419
|
}
|
|
405
|
-
// a failed search is non-fatal — fall through to
|
|
420
|
+
// a failed search or no exact-marker match is non-fatal — fall through to
|
|
421
|
+
// create (gateway seen-set is the primary, race-free guard).
|
|
406
422
|
}
|
|
407
423
|
|
|
408
424
|
// Resolve the team.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* litellm-local-notice-wiring.ts — side-effect runner for the debounced
|
|
3
|
+
* litellm-local 429 notice.
|
|
4
|
+
*
|
|
5
|
+
* The state machine, notice text, config parsing, and metric payload live in
|
|
6
|
+
* ../litellm-local-notice.ts (pure). This module owns the sequencing, with
|
|
7
|
+
* every dependency injected so the wiring is unit-testable without importing
|
|
8
|
+
* gateway.ts (same shape as throttle-tier-wiring.ts):
|
|
9
|
+
*
|
|
10
|
+
* 1. Guard — only the `litellm-local` classification is eligible
|
|
11
|
+
* (isLitellmLocalNoticeEligible). account-scoped keeps the throttle
|
|
12
|
+
* tier; generic-transient keeps the calm rate-limited card. The guard
|
|
13
|
+
* lives HERE (not in the caller) so "a notice never fires for
|
|
14
|
+
* non-litellm-local classifications" is mechanism, not discipline.
|
|
15
|
+
* 2. Per-agent cooldown — evaluate against the resolved window
|
|
16
|
+
* (deps.windowMs(), re-read per event so an operator-tuned
|
|
17
|
+
* channels.telegram.litellm_notice.window_ms takes effect after
|
|
18
|
+
* apply+restart without re-creating the runner). Suppressed events are
|
|
19
|
+
* counted silently.
|
|
20
|
+
* 3. ONE calm notice — broadcast to every authorized chat via the injected
|
|
21
|
+
* send (the gateway binds it to swallowingApiCall, the standard
|
|
22
|
+
* retry-wrapped path). The window is armed, the
|
|
23
|
+
* `litellm_local_429_notice` metric emitted, and "posted" logged ONLY
|
|
24
|
+
* when at least one send was actually ISSUED — an empty allowFrom or a
|
|
25
|
+
* send callback that throws for every chat leaves the state untouched,
|
|
26
|
+
* so the next event retries instead of silently claiming delivery.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately NO broker calls, NO quota-ledger writes, NO failover, NO
|
|
29
|
+
* retry nudge: the litellm-local calm path's invariant is that account state
|
|
30
|
+
* is never touched (the request never reached Anthropic). Claude Code's own
|
|
31
|
+
* retry handles the turn.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
buildLitellmLocalNoticeMetric,
|
|
36
|
+
evaluateLitellmLocalNotice,
|
|
37
|
+
initialLitellmLocalNoticeState,
|
|
38
|
+
isLitellmLocalNoticeEligible,
|
|
39
|
+
renderLitellmLocalNotice,
|
|
40
|
+
type LitellmLocalNoticeState,
|
|
41
|
+
} from '../litellm-local-notice.js'
|
|
42
|
+
import type { RateLimit429Classification } from '../throttle-tier.js'
|
|
43
|
+
import type { RuntimeMetricEvent } from '../runtime-metrics.js'
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The user-facing surface decision for one terminal non-account-scoped
|
|
47
|
+
* `rate-limited` operator event — the gateway's ordering contract with the
|
|
48
|
+
* shared per-agent-per-kind card cooldown (shouldEmitOperatorEvent in
|
|
49
|
+
* operator-events.ts), extracted so it is pinnable by tests:
|
|
50
|
+
*
|
|
51
|
+
* - `litellm-local` resolves BEFORE the shared gate is consulted. Two
|
|
52
|
+
* load-bearing consequences: (a) it never ARMS `${agent}:rate-limited`
|
|
53
|
+
* (shouldEmitOperatorEvent's true-return records the timestamp), so a
|
|
54
|
+
* proxy-cap burst can't suppress a later genuine transient's card; and
|
|
55
|
+
* (b) it is never SUPPRESSED by a cooldown that a recent 529 / generic
|
|
56
|
+
* card armed — the notice runner owns its own per-agent debounce
|
|
57
|
+
* instead.
|
|
58
|
+
* - Every other classification consults (and on true, arms) the shared
|
|
59
|
+
* gate exactly once — the caller must NOT re-consult it downstream.
|
|
60
|
+
*/
|
|
61
|
+
export function decideRateLimitedSurface(opts: {
|
|
62
|
+
classification: RateLimit429Classification
|
|
63
|
+
agent: string
|
|
64
|
+
/** The shared cooldown gate — gateway binds
|
|
65
|
+
* (a) => shouldEmitOperatorEvent(a, 'rate-limited'). Consulted (and on
|
|
66
|
+
* true, armed) only for non-litellm-local classifications. */
|
|
67
|
+
shouldEmitCard: (agent: string) => boolean
|
|
68
|
+
}): 'litellm-local-notice' | 'generic-card' | 'cooldown-suppressed' {
|
|
69
|
+
if (isLitellmLocalNoticeEligible(opts.classification)) return 'litellm-local-notice'
|
|
70
|
+
return opts.shouldEmitCard(opts.agent) ? 'generic-card' : 'cooldown-suppressed'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface LitellmLocalNoticeRunnerDeps {
|
|
74
|
+
/** Chats the notice broadcasts to (access.allowFrom, resolved per call). */
|
|
75
|
+
listNoticeChats(): Array<string | number>
|
|
76
|
+
/** Fire-and-forget rich send (gateway wraps swallowingApiCall). */
|
|
77
|
+
sendNotice(chatId: string | number, markdown: string): void
|
|
78
|
+
/** Resolved cooldown window (ms), re-read per event —
|
|
79
|
+
* parseLitellmNoticeWindowMs(loadAccess().litellmNoticeWindowMs). */
|
|
80
|
+
windowMs(): number
|
|
81
|
+
/** Runtime-metric sink (gateway binds emitRuntimeMetric). */
|
|
82
|
+
emitMetric(event: RuntimeMetricEvent): void
|
|
83
|
+
log(msg: string): void
|
|
84
|
+
now?: () => number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What one event produced:
|
|
89
|
+
* - `sent` — a notice was issued to ≥1 chat (window armed, metric
|
|
90
|
+
* emitted). The gateway records the event into the
|
|
91
|
+
* operator-event history ONLY on this outcome, so a
|
|
92
|
+
* suppressed low-stakes proxy throttle never overwrites a
|
|
93
|
+
* more important most-recent event (e.g.
|
|
94
|
+
* credentials-expired) in /status enrichment.
|
|
95
|
+
* - `suppressed` — inside the cooldown window; counted silently.
|
|
96
|
+
* - `skipped` — ineligible classification, no authorized recipients,
|
|
97
|
+
* every send failed synchronously, or an internal error
|
|
98
|
+
* (logged). No debounce state change.
|
|
99
|
+
*/
|
|
100
|
+
export type LitellmLocalNoticeOutcome = 'sent' | 'suppressed' | 'skipped'
|
|
101
|
+
|
|
102
|
+
export interface LitellmLocalNoticeRunner {
|
|
103
|
+
/**
|
|
104
|
+
* Run the notice path for one terminal rate-limited operator event.
|
|
105
|
+
* No-ops (returns 'skipped') unless `classification` is `litellm-local`.
|
|
106
|
+
* Never throws.
|
|
107
|
+
*/
|
|
108
|
+
onRateLimited(
|
|
109
|
+
classification: RateLimit429Classification,
|
|
110
|
+
agent: string,
|
|
111
|
+
): LitellmLocalNoticeOutcome
|
|
112
|
+
/** Test/debug view of internal state. */
|
|
113
|
+
inspect(): { state: LitellmLocalNoticeState }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createLitellmLocalNoticeRunner(
|
|
117
|
+
deps: LitellmLocalNoticeRunnerDeps,
|
|
118
|
+
): LitellmLocalNoticeRunner {
|
|
119
|
+
const now = deps.now ?? (() => Date.now())
|
|
120
|
+
// In-memory only — the debounce resets on gateway restart, so a sustained
|
|
121
|
+
// throttle can produce one extra notice per restart. Accepted trade-off:
|
|
122
|
+
// persisting a courtesy-surface cooldown isn't worth state-file plumbing,
|
|
123
|
+
// and the failure mode is one duplicate calm message, not a storm.
|
|
124
|
+
let state = initialLitellmLocalNoticeState()
|
|
125
|
+
|
|
126
|
+
function onRateLimited(
|
|
127
|
+
classification: RateLimit429Classification,
|
|
128
|
+
agent: string,
|
|
129
|
+
): LitellmLocalNoticeOutcome {
|
|
130
|
+
try {
|
|
131
|
+
if (!isLitellmLocalNoticeEligible(classification)) return 'skipped'
|
|
132
|
+
const windowMs = deps.windowMs()
|
|
133
|
+
const verdict = evaluateLitellmLocalNotice(state, agent, now(), windowMs)
|
|
134
|
+
if (!verdict.send) {
|
|
135
|
+
state = verdict.next
|
|
136
|
+
deps.log(
|
|
137
|
+
`[litellm-local-notice] suppressed (cooldown) agent=${agent} ` +
|
|
138
|
+
`suppressedSoFar=${state.suppressedCountByAgent[agent] ?? 0}`,
|
|
139
|
+
)
|
|
140
|
+
return 'suppressed'
|
|
141
|
+
}
|
|
142
|
+
const chats = deps.listNoticeChats()
|
|
143
|
+
const markdown = renderLitellmLocalNotice({
|
|
144
|
+
agent,
|
|
145
|
+
suppressedSinceLastNotice: verdict.suppressedSinceLastNotice,
|
|
146
|
+
})
|
|
147
|
+
let issued = 0
|
|
148
|
+
for (const chatId of chats) {
|
|
149
|
+
try {
|
|
150
|
+
deps.sendNotice(chatId, markdown)
|
|
151
|
+
issued++
|
|
152
|
+
} catch (err) {
|
|
153
|
+
deps.log(
|
|
154
|
+
`[litellm-local-notice] send failed chat=${chatId} agent=${agent}: ` +
|
|
155
|
+
`${(err as Error)?.message ?? err}`,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (issued === 0) {
|
|
160
|
+
// Nothing actually went out (no authorized chats, or every send
|
|
161
|
+
// threw synchronously). Do NOT arm the window, emit the metric, or
|
|
162
|
+
// claim "posted" — the next event retries instead of the debounce
|
|
163
|
+
// silently absorbing events no one will ever hear about.
|
|
164
|
+
deps.log(
|
|
165
|
+
`[litellm-local-notice] no sends issued (chats=${chats.length}) agent=${agent} — ` +
|
|
166
|
+
`window not armed, will retry on the next event`,
|
|
167
|
+
)
|
|
168
|
+
return 'skipped'
|
|
169
|
+
}
|
|
170
|
+
state = verdict.next
|
|
171
|
+
deps.emitMetric(
|
|
172
|
+
buildLitellmLocalNoticeMetric({
|
|
173
|
+
agent,
|
|
174
|
+
suppressedCount: verdict.suppressedSinceLastNotice,
|
|
175
|
+
windowMs,
|
|
176
|
+
}),
|
|
177
|
+
)
|
|
178
|
+
deps.log(
|
|
179
|
+
`[litellm-local-notice] posted agent=${agent} chats=${issued} ` +
|
|
180
|
+
`suppressedSinceLast=${verdict.suppressedSinceLastNotice} windowMs=${windowMs}`,
|
|
181
|
+
)
|
|
182
|
+
return 'sent'
|
|
183
|
+
} catch (err) {
|
|
184
|
+
// The notice is a courtesy surface — a failure here must never break
|
|
185
|
+
// the operator-event path that invoked it. The log itself is wrapped
|
|
186
|
+
// too: a dead stderr sink must not void the never-throws contract.
|
|
187
|
+
try {
|
|
188
|
+
deps.log(
|
|
189
|
+
`[litellm-local-notice] error agent=${agent}: ${(err as Error)?.message ?? err}`,
|
|
190
|
+
)
|
|
191
|
+
} catch { /* sink dead — nothing left to do */ }
|
|
192
|
+
return 'skipped'
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
onRateLimited,
|
|
198
|
+
inspect: () => ({ state }),
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -108,6 +108,82 @@ export function parseModelCommand(text: string): ParsedModelCommand | null {
|
|
|
108
108
|
return { kind: 'set', model: arg }
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The busy state a typed `/model` disposition is decided against. Two
|
|
113
|
+
* independent signals are folded here (#3177): `currentTurnActive` is the
|
|
114
|
+
* gateway's per-turn atom (`currentTurn !== null`), which is NULLED at
|
|
115
|
+
* `turn_end`; `turnInFlight` is the authoritative delivery-machine +
|
|
116
|
+
* pending-approval gate (`turnInFlightForGate()`). The atom can read idle
|
|
117
|
+
* while the session is still busy (the "recovered-late" / premature-turn-end
|
|
118
|
+
* window seen in finn's 2026-07-12 log), so a switch decided on the atom ALONE
|
|
119
|
+
* takes the direct-inject path and types `/model <name>` into a still-busy
|
|
120
|
+
* pane, where claude swallows it as literal text — a silent no-op. Folding
|
|
121
|
+
* BOTH signals means a session busy by EITHER measure ack+queues instead.
|
|
122
|
+
*/
|
|
123
|
+
export interface ModelCommandContext {
|
|
124
|
+
/** Gateway per-turn atom is non-null (`currentTurn !== null`). */
|
|
125
|
+
currentTurnActive: boolean
|
|
126
|
+
/** Authoritative busy gate (`turnInFlightForGate()`): machine-in-turn OR a pending approval. */
|
|
127
|
+
turnInFlight: boolean
|
|
128
|
+
/** Interactive picker menu is enabled (`SWITCHROOM_MODEL_MENU !== '0'`). */
|
|
129
|
+
menuEnabled: boolean
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* True when the session is busy by EITHER busy signal. A typed `/model` set
|
|
134
|
+
* MUST NOT take the direct-inject path while this holds — it would type into a
|
|
135
|
+
* busy pane and be swallowed as text. See ModelCommandContext.
|
|
136
|
+
*/
|
|
137
|
+
export function isModelCommandBusy(ctx: Pick<ModelCommandContext, 'currentTurnActive' | 'turnInFlight'>): boolean {
|
|
138
|
+
return ctx.currentTurnActive || ctx.turnInFlight
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* What the gateway should do with a parsed `/model` command. Pure so the
|
|
143
|
+
* routing decision is unit-testable without booting the bot. Every parsed
|
|
144
|
+
* shape maps to exactly one visible action — there is NO branch that silently
|
|
145
|
+
* does nothing (the #3177 swallow):
|
|
146
|
+
*
|
|
147
|
+
* - `menu` — bare `/model` with the picker enabled → render the dashboard.
|
|
148
|
+
* - `queue` — a `set` while busy (by EITHER signal) → ack + enqueue for
|
|
149
|
+
* apply-on-idle. `target` is the alias-expanded token.
|
|
150
|
+
* - `apply` — run the handler now (idle `set`, or `show`/`help` text paths).
|
|
151
|
+
*/
|
|
152
|
+
export type ModelCommandDisposition =
|
|
153
|
+
| { kind: 'menu' }
|
|
154
|
+
| { kind: 'queue'; target: string }
|
|
155
|
+
| { kind: 'apply'; parsed: ParsedModelCommand }
|
|
156
|
+
|
|
157
|
+
export function planModelCommand(
|
|
158
|
+
parsed: ParsedModelCommand,
|
|
159
|
+
ctx: ModelCommandContext,
|
|
160
|
+
): ModelCommandDisposition {
|
|
161
|
+
if (parsed.kind === 'show' && ctx.menuEnabled) return { kind: 'menu' }
|
|
162
|
+
if (parsed.kind === 'set' && isModelCommandBusy(ctx)) {
|
|
163
|
+
return { kind: 'queue', target: expandSrAlias(parsed.model) }
|
|
164
|
+
}
|
|
165
|
+
return { kind: 'apply', parsed }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The unconditional durable log line the gateway writes at `/model` entry,
|
|
170
|
+
* BEFORE any branch or reply attempt (#3177 guarantee (b)). Even if every
|
|
171
|
+
* downstream reply is shed/dropped, this line — plus the paired history row —
|
|
172
|
+
* guarantees a typed `/model` is never invisible. Kept pure + shape-stable so
|
|
173
|
+
* the format is testable and greppable (`grep 'gw /model received'`).
|
|
174
|
+
*/
|
|
175
|
+
export function modelCommandReceiptLine(
|
|
176
|
+
agent: string,
|
|
177
|
+
parsed: ParsedModelCommand,
|
|
178
|
+
busy: boolean,
|
|
179
|
+
): string {
|
|
180
|
+
const arg =
|
|
181
|
+
parsed.kind === 'set' ? parsed.model
|
|
182
|
+
: parsed.kind === 'show' ? '(show)'
|
|
183
|
+
: '(help)'
|
|
184
|
+
return `telegram gateway: gw /model received agent=${agent} kind=${parsed.kind} arg=${arg} busy=${busy}`
|
|
185
|
+
}
|
|
186
|
+
|
|
111
187
|
export interface ModelCommandDeps {
|
|
112
188
|
/** Inject primitive — wired to injectSlashCommand in the gateway. */
|
|
113
189
|
inject: (agent: string, command: string) => Promise<InjectResult>
|
|
@@ -175,7 +251,7 @@ export interface ModelCommandReply {
|
|
|
175
251
|
}
|
|
176
252
|
|
|
177
253
|
const PERSIST_NOTE =
|
|
178
|
-
'
|
|
254
|
+
'_Session-only — this override lasts until the agent’s next restart, then reverts to the configured \`model:\`. \`/model default\` clears it now. To change the default permanently, set \`model:\` in switchroom.yaml._'
|
|
179
255
|
|
|
180
256
|
function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
|
|
181
257
|
const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `\`${a}\``).join(' · ')
|
|
@@ -507,18 +583,19 @@ export const SR_MODEL_ALIASES: Record<string, string> = {
|
|
|
507
583
|
|
|
508
584
|
/** Expand a short alias (case-insensitive) to its full sr-* id, or return the original. */
|
|
509
585
|
/**
|
|
510
|
-
* #3042 review blocker 2a: can `token` be trusted for a
|
|
511
|
-
* `.session-model` persist WITHOUT a live confirmation from claude?
|
|
586
|
+
* #3042 review blocker 2a: can `token` be trusted for a boot-applied
|
|
587
|
+
* `.session-model` carrier persist WITHOUT a live confirmation from claude?
|
|
512
588
|
*
|
|
513
589
|
* A queued typed `/model <arg>` that is persisted at shutdown was never
|
|
514
|
-
* validated by claude's picker
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
* tokens with a switchroom-known
|
|
518
|
-
* Claude aliases (claude resolves
|
|
519
|
-
*
|
|
520
|
-
* arbitrary `sr-*` ids typed by
|
|
521
|
-
* session to verify, so the operator is
|
|
590
|
+
* validated by claude's picker. Under the consume-once carrier (rev 4) a
|
|
591
|
+
* garbage-but-shape-valid token (e.g. `claude-nonexistnet-9`) can crash at
|
|
592
|
+
* most ONE boot before the carrier is gone and the agent reverts — but we
|
|
593
|
+
* still avoid even that crash-boot. Only tokens with a switchroom-known
|
|
594
|
+
* meaning are offline-trustable: the static Claude aliases (claude resolves
|
|
595
|
+
* them itself) and the curated sr-* alias TARGETS (present in the LiteLLM
|
|
596
|
+
* config by construction). Full `claude-*` / arbitrary `sr-*` ids typed by
|
|
597
|
+
* hand are refused — they need the live session to verify, so the operator is
|
|
598
|
+
* asked to re-issue after boot.
|
|
522
599
|
*/
|
|
523
600
|
export function isOfflineTrustedModelToken(token: string): boolean {
|
|
524
601
|
// #3043 item 1: the live accept path normalizes case (isClaudeModel /
|
|
@@ -833,18 +910,19 @@ export interface ModelCallbackOutcome {
|
|
|
833
910
|
/**
|
|
834
911
|
* The canonical `claude --model` token (alias or full `claude-*` id) for a
|
|
835
912
|
* Claude selection, when derivable — distinct from `selectedModel` (a display
|
|
836
|
-
* name for /status).
|
|
837
|
-
*
|
|
838
|
-
*
|
|
913
|
+
* name for /status). Session-scoped (rev 4): a live Claude selection persists
|
|
914
|
+
* NO carrier (the switch applies in-session and reverts on the next boot);
|
|
915
|
+
* the gateway uses this token ONLY on an sr-* → Claude transition, writing it
|
|
916
|
+
* to the consume-once `.session-model` carrier so that transition's own
|
|
917
|
+
* apply-relaunch boots the tapped model (then reverts on the following
|
|
839
918
|
* restart). Absent when the target has no derivable token.
|
|
840
919
|
*/
|
|
841
920
|
selectedModelToken?: string
|
|
842
921
|
/**
|
|
843
922
|
* True when the confirmed selection was the "Default (recommended)" row —
|
|
844
|
-
* i.e. the session is now on the configured default and any
|
|
845
|
-
* `.session-model`
|
|
846
|
-
*
|
|
847
|
-
* model on the next keep-relaunch).
|
|
923
|
+
* i.e. the session is now on the configured default and any leftover
|
|
924
|
+
* `.session-model` carrier must be CLEARED (a stale carrier would be
|
|
925
|
+
* consumed — mis-applied — by the next boot).
|
|
848
926
|
*/
|
|
849
927
|
clearedDefault?: boolean
|
|
850
928
|
/** Short toast for answerCallbackQuery. */
|
|
@@ -244,15 +244,17 @@ export interface PendingCommandCardEdit {
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
/**
|
|
247
|
-
* What the gateway should
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
* `.session-effort
|
|
252
|
-
*
|
|
247
|
+
* What the gateway should record for a queued command that can't apply live
|
|
248
|
+
* because the session is going away (gateway shutdown or a pending session
|
|
249
|
+
* relaunch). Instead of telling the operator to re-issue, the choice is
|
|
250
|
+
* persisted to the CONSUME-ONCE boot carriers (`.session-model` /
|
|
251
|
+
* `.session-effort`, #3184/#3186): start.sh applies each on the single boot
|
|
252
|
+
* that reads it — the queued command's apply-relaunch — and then deletes it,
|
|
253
|
+
* so the queued command still deterministically applies via the relaunch and
|
|
254
|
+
* any SUBSEQUENT restart reverts to the configured default.
|
|
253
255
|
*
|
|
254
|
-
* - 'model' — persist `arg` as the `.session-model`
|
|
255
|
-
* - 'effort' — persist `arg` as the `.session-effort`
|
|
256
|
+
* - 'model' — persist `arg` as the `.session-model` carrier
|
|
257
|
+
* - 'effort' — persist `arg` as the `.session-effort` carrier
|
|
256
258
|
* - 'clear-model' — the queued command was `/model default`: clear the carrier
|
|
257
259
|
* - 'clear-effort' — the queued command was `/effort default`: clear the carrier
|
|
258
260
|
* - null — not offline-resolvable (a `mdl:s:<tag>` menu selection
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* premium-recovery-wiring.ts — the gateway GLUE for the "premium model
|
|
3
|
+
* recovered" ping, behind injected deps so the never-storm orchestration is
|
|
4
|
+
* unit-testable without importing the whole gateway (mirrors
|
|
5
|
+
* tier-downgrade-wiring.ts).
|
|
6
|
+
*
|
|
7
|
+
* The pure recovery predicate (`decidePremiumRecovery`), the honest wording +
|
|
8
|
+
* button (`renderPremiumRecoveryPing`), and the fleet-dedup key
|
|
9
|
+
* (`premiumRecoveryClaimKey`) all live in premium-recovery.ts. This module owns
|
|
10
|
+
* the ORDER-SENSITIVE side effects the gateway performs off that verdict — the
|
|
11
|
+
* P0 at-most-once guarantee a review cares about:
|
|
12
|
+
*
|
|
13
|
+
* - the marker is READ first; a missing/corrupt marker is a no-op (no send);
|
|
14
|
+
* - the pure decision gates the send: `fire === false` → NO clear, NO send
|
|
15
|
+
* (the marker survives for a later tick once the tier actually recovers);
|
|
16
|
+
* - a fleet-wide `claim-notification` gates against a bounce / concurrent
|
|
17
|
+
* tick; on `!granted` the marker is cleared (so it can't linger and
|
|
18
|
+
* re-attempt every tick) and we bail with NO send;
|
|
19
|
+
* - on a GRANTED claim the marker is cleared BEFORE the send (never-storm:
|
|
20
|
+
* at-most-once is the hard requirement — a transient send fault is covered
|
|
21
|
+
* by the caller's retry policy, a double-send is not recoverable);
|
|
22
|
+
* - EXACTLY ONE send per recorded chat, each carrying the session-scoped
|
|
23
|
+
* `mdl:alias:<premium>` switch-back button (the SAME apply path as the model
|
|
24
|
+
* menu — it does not bypass handleModelMenuCallback).
|
|
25
|
+
*
|
|
26
|
+
* Behaviour is a faithful extraction of the former inline
|
|
27
|
+
* `maybePremiumRecoveryPing`; the ordering above is preserved exactly.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
renderPremiumRecoveryPing,
|
|
32
|
+
premiumRecoveryClaimKey,
|
|
33
|
+
type PremiumRecoveryPing,
|
|
34
|
+
} from '../premium-recovery.js'
|
|
35
|
+
import { MODEL_CALLBACK_ALIAS } from './model-command.js'
|
|
36
|
+
|
|
37
|
+
/** The one-tap switch-back keyboard (single callback button). */
|
|
38
|
+
export interface PremiumRecoveryKeyboard {
|
|
39
|
+
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The minimal marker view the ping orchestration needs. */
|
|
43
|
+
export interface PremiumRecoveryMarkerView {
|
|
44
|
+
/** The dropped premium `/model` token to offer switching back to. */
|
|
45
|
+
premiumModel: string
|
|
46
|
+
/** Chat ids to ping (the downgrade notice's allowFrom); may be empty → fallback. */
|
|
47
|
+
chats: string[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PremiumRecoveryPingDeps {
|
|
51
|
+
/** Bind-mounted agent state dir, or null when unresolvable (→ no-op). */
|
|
52
|
+
getAgentDir: () => string | null
|
|
53
|
+
/** Read the parsed `.premium-recovery` marker, or null when absent/corrupt. */
|
|
54
|
+
readMarker: (agentDir: string) => PremiumRecoveryMarkerView | null
|
|
55
|
+
/** Delete the marker (consume-once). Best-effort. */
|
|
56
|
+
clearMarker: (agentDir: string) => void
|
|
57
|
+
/** The agent name (for the fleet-dedup claim key). */
|
|
58
|
+
getAgent: () => string
|
|
59
|
+
/** The pure recovery predicate, bound to THIS tick's live accounts. Returns
|
|
60
|
+
* whether to fire (the marker is already known present when this is called). */
|
|
61
|
+
decide: () => boolean
|
|
62
|
+
/** Fleet-wide at-most-once claim for this premium token. Returns granted
|
|
63
|
+
* (fail-open: true on any broker error → degrades to at-least-once). */
|
|
64
|
+
claimNotification: (claimKey: string) => Promise<boolean>
|
|
65
|
+
/** Fallback chats when the marker records none (parity with the inline path). */
|
|
66
|
+
fallbackChats: () => string[]
|
|
67
|
+
/** Send the recovery ping (with the switch-back keyboard) to ONE chat. */
|
|
68
|
+
sendToChat: (
|
|
69
|
+
chatId: string,
|
|
70
|
+
ping: PremiumRecoveryPing,
|
|
71
|
+
keyboard: PremiumRecoveryKeyboard,
|
|
72
|
+
) => void
|
|
73
|
+
/** Structured logger (stderr in prod, captured in test). */
|
|
74
|
+
log: (msg: string) => void
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Run the premium-recovery ping glue. No-op unless a marker is pending, the
|
|
79
|
+
* pure decision says fire, and the fleet claim is granted — then clears the
|
|
80
|
+
* marker BEFORE fanning out exactly one send per chat. Async because the claim
|
|
81
|
+
* is a broker round-trip; the caller `.catch`es (a convenience ping is never
|
|
82
|
+
* allowed to throw the quota-watch tick).
|
|
83
|
+
*/
|
|
84
|
+
export async function runPremiumRecoveryPing(deps: PremiumRecoveryPingDeps): Promise<void> {
|
|
85
|
+
const agentDir = deps.getAgentDir()
|
|
86
|
+
if (!agentDir) return
|
|
87
|
+
const marker = deps.readMarker(agentDir)
|
|
88
|
+
if (marker == null) return
|
|
89
|
+
// Pure decision gate: on `false` leave the marker in place (a later tick fires
|
|
90
|
+
// once the tier recovers) — no clear, no claim, no send.
|
|
91
|
+
if (!deps.decide()) return
|
|
92
|
+
const agent = deps.getAgent()
|
|
93
|
+
// Fleet-wide dedup: a fresh gateway boot or a second tick inside the window
|
|
94
|
+
// must not re-send. Fail-open — a convenience ping degrades to at-least-once,
|
|
95
|
+
// never lost.
|
|
96
|
+
const granted = await deps.claimNotification(
|
|
97
|
+
premiumRecoveryClaimKey(agent, marker.premiumModel),
|
|
98
|
+
)
|
|
99
|
+
if (!granted) {
|
|
100
|
+
// Another gateway already owns this recovery ping — consume our marker so it
|
|
101
|
+
// can't linger and re-attempt every tick, and bail (NO send).
|
|
102
|
+
deps.clearMarker(agentDir)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
// Consume the marker BEFORE sending (never-storm: at-most-once is the hard
|
|
106
|
+
// requirement for this ping; a transient send fault is covered by the
|
|
107
|
+
// caller's retry policy, whereas a double-send is not recoverable).
|
|
108
|
+
deps.clearMarker(agentDir)
|
|
109
|
+
const ping = renderPremiumRecoveryPing(marker.premiumModel)
|
|
110
|
+
const keyboard: PremiumRecoveryKeyboard = {
|
|
111
|
+
inline_keyboard: [
|
|
112
|
+
[{ text: ping.buttonText, callback_data: `${MODEL_CALLBACK_ALIAS}${marker.premiumModel}` }],
|
|
113
|
+
],
|
|
114
|
+
}
|
|
115
|
+
const chats = marker.chats.length > 0 ? marker.chats : deps.fallbackChats()
|
|
116
|
+
for (const chatId of chats) {
|
|
117
|
+
deps.sendToChat(chatId, ping, keyboard)
|
|
118
|
+
}
|
|
119
|
+
deps.log(
|
|
120
|
+
`[premium-recovery] ${marker.premiumModel} servable again — ping sent agent=${agent} chats=${chats.length}`,
|
|
121
|
+
)
|
|
122
|
+
}
|