switchroom 0.18.32 → 0.19.0
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/auth-broker/index.js +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/gateway.ts +1347 -6758
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +10 -4
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
type ConfigDiff,
|
|
69
69
|
} from './config-snapshot.js'
|
|
70
70
|
import { join } from 'path'
|
|
71
|
+
import { readFileSync, statSync } from 'fs'
|
|
71
72
|
import { bootCardChatKey, loadBootCardMsgId, saveBootCardMsgId } from './boot-card-msgid.js'
|
|
72
73
|
import { nonEssentialSendSuppression } from '../flood-circuit-breaker.js'
|
|
73
74
|
import { loadConfig as _loadSwitchroomConfig } from '../../src/config/loader.js'
|
|
@@ -278,6 +279,131 @@ const REASON_LABEL: Record<RestartReason, string> = {
|
|
|
278
279
|
fresh: 'fresh start',
|
|
279
280
|
}
|
|
280
281
|
|
|
282
|
+
// ─── Routing-mode row (.routing-mode observability) ─────────────────────────
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Parsed `.routing-mode` state file — one overwrite-per-boot line written by
|
|
286
|
+
* start.sh immediately before `exec claude`:
|
|
287
|
+
*
|
|
288
|
+
* mode=<router-root|passthrough|direct-oauth> base=<url> model=<effective>
|
|
289
|
+
* litellm_ok=<0|1> declared=<mode> ts=<iso>
|
|
290
|
+
*
|
|
291
|
+
* `prevBoot` marks a RENDER-TIMING hazard, not file corruption: the gateway
|
|
292
|
+
* (outer pass) can render the boot card BEFORE the inner pass writes this
|
|
293
|
+
* boot's line (the inner LiteLLM probe can hold the write back for minutes on
|
|
294
|
+
* a degraded proxy). When the file's mtime predates this gateway process
|
|
295
|
+
* start, the value belongs to the PREVIOUS boot and must be labeled as such —
|
|
296
|
+
* never shown as current.
|
|
297
|
+
*/
|
|
298
|
+
export interface RoutingModeInfo {
|
|
299
|
+
/** Landed routing mode this boot: router-root | passthrough | direct-oauth. */
|
|
300
|
+
mode: string
|
|
301
|
+
/** Declared (apply-time) routing intent, when present in the file. */
|
|
302
|
+
declared?: string
|
|
303
|
+
/** Effective launched model recorded alongside the mode. */
|
|
304
|
+
model?: string
|
|
305
|
+
/** LiteLLM liveliness at boot ("1" ok / "0" not confirmed). */
|
|
306
|
+
litellmOk?: string
|
|
307
|
+
/** True when the file predates this gateway boot (stale — prev boot's value). */
|
|
308
|
+
prevBoot: boolean
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Epoch-ms this CONTAINER booted, derived from PID 1's start time
|
|
313
|
+
* (`/proc/stat` btime + `/proc/1/stat` field 22 in USER_HZ ticks). This is the
|
|
314
|
+
* correct staleness threshold for `.routing-mode`: start.sh writes that file
|
|
315
|
+
* ONCE per container boot, before exec-ing claude/the gateway. Keying staleness
|
|
316
|
+
* on the gateway PROCESS start (`Date.now() - process.uptime()*1000`) mislabels
|
|
317
|
+
* a gateway-only respawn — supervisor restarts the gateway process while the
|
|
318
|
+
* container (and its still-current `.routing-mode`) stays up — as "(prev boot)".
|
|
319
|
+
* Container boot time is stable across such respawns (LOW-1). Returns null when
|
|
320
|
+
* /proc is unavailable or unparseable (non-Linux, tests), so callers fall back
|
|
321
|
+
* to the process-start estimate.
|
|
322
|
+
*/
|
|
323
|
+
export function containerBootStartMs(
|
|
324
|
+
fsImpl: { readFileSync: typeof readFileSync } = { readFileSync },
|
|
325
|
+
): number | null {
|
|
326
|
+
try {
|
|
327
|
+
const btimeLine = fsImpl
|
|
328
|
+
.readFileSync('/proc/stat', 'utf-8')
|
|
329
|
+
.split('\n')
|
|
330
|
+
.find((l) => l.startsWith('btime '))
|
|
331
|
+
if (!btimeLine) return null
|
|
332
|
+
const btimeSec = Number(btimeLine.split(/\s+/)[1])
|
|
333
|
+
if (!Number.isFinite(btimeSec)) return null
|
|
334
|
+
// /proc/1/stat field 22 (starttime) is in clock ticks since system boot.
|
|
335
|
+
// Fields 2 (comm) may contain spaces/parens, so split on the last ')'.
|
|
336
|
+
const stat = fsImpl.readFileSync('/proc/1/stat', 'utf-8')
|
|
337
|
+
const afterComm = stat.slice(stat.lastIndexOf(')') + 2)
|
|
338
|
+
const fields = afterComm.split(/\s+/)
|
|
339
|
+
// starttime is field 22 overall → index 19 of the post-comm slice
|
|
340
|
+
// (field 1 pid + field 2 comm consumed; field 3 becomes index 0).
|
|
341
|
+
const startTicks = Number(fields[19])
|
|
342
|
+
if (!Number.isFinite(startTicks)) return null
|
|
343
|
+
const HZ = 100 // USER_HZ on effectively all Linux; sysconf unavailable in node
|
|
344
|
+
return (btimeSec + startTicks / HZ) * 1000
|
|
345
|
+
} catch {
|
|
346
|
+
return null
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Read + parse `<agentDir>/.routing-mode`. Returns null when the file is
|
|
352
|
+
* absent, unreadable, or carries no `mode=` field (tolerant by design — the
|
|
353
|
+
* boot card must render fine on fleets that pre-date the state file).
|
|
354
|
+
*
|
|
355
|
+
* `bootStartMs` is the epoch-ms this CONTAINER booted (see
|
|
356
|
+
* `containerBootStartMs`); a file mtime strictly older than it belongs to a
|
|
357
|
+
* previous container boot and is flagged `prevBoot` so the renderer can label
|
|
358
|
+
* the row instead of presenting stale data as current. Keyed on container boot
|
|
359
|
+
* (not gateway process start) so a gateway-only respawn does not mislabel the
|
|
360
|
+
* still-current file.
|
|
361
|
+
*/
|
|
362
|
+
export function readRoutingMode(
|
|
363
|
+
agentDir: string,
|
|
364
|
+
bootStartMs: number,
|
|
365
|
+
fsImpl: { readFileSync: typeof readFileSync; statSync: typeof statSync } = { readFileSync, statSync },
|
|
366
|
+
): RoutingModeInfo | null {
|
|
367
|
+
const path = join(agentDir, '.routing-mode')
|
|
368
|
+
try {
|
|
369
|
+
const raw = fsImpl.readFileSync(path, 'utf-8')
|
|
370
|
+
const line = raw.split('\n')[0] ?? ''
|
|
371
|
+
const field = (key: string): string | undefined => {
|
|
372
|
+
const m = line.match(new RegExp(`(?:^|\\s)${key}=([^\\s]+)`))
|
|
373
|
+
return m ? m[1] : undefined
|
|
374
|
+
}
|
|
375
|
+
const mode = field('mode')
|
|
376
|
+
if (!mode) return null
|
|
377
|
+
const mtimeMs = fsImpl.statSync(path).mtimeMs
|
|
378
|
+
return {
|
|
379
|
+
mode,
|
|
380
|
+
declared: field('declared'),
|
|
381
|
+
model: field('model'),
|
|
382
|
+
litellmOk: field('litellm_ok'),
|
|
383
|
+
prevBoot: mtimeMs < bootStartMs,
|
|
384
|
+
}
|
|
385
|
+
} catch {
|
|
386
|
+
return null
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Render the routing row for the boot card. Divergence (landed ≠ declared)
|
|
392
|
+
* gets the warning dot; a converged mode renders as a dim informational row —
|
|
393
|
+
* deliberately always visible (2026-07-17 incident: both divergence cases
|
|
394
|
+
* were SILENT; the row is the standing observability surface).
|
|
395
|
+
*/
|
|
396
|
+
export function renderRoutingRow(r: RoutingModeInfo): string {
|
|
397
|
+
const diverged = r.declared != null && r.declared !== '' && r.mode !== r.declared
|
|
398
|
+
const dot = diverged ? DOT.degraded : '🔀'
|
|
399
|
+
const parts: string[] = [escapeMarkdown(r.mode)]
|
|
400
|
+
if (diverged) parts.push(`≠ declared ${escapeMarkdown(r.declared as string)}`)
|
|
401
|
+
if (r.model) parts.push(`\`${r.model}\``)
|
|
402
|
+
if (r.litellmOk != null) parts.push(r.litellmOk === '1' ? 'litellm ok' : 'litellm unconfirmed')
|
|
403
|
+
const suffix = r.prevBoot ? ' _(prev boot)_' : ''
|
|
404
|
+
return `${dot} **Routing** ${parts.join(' · ')}${suffix}`
|
|
405
|
+
}
|
|
406
|
+
|
|
281
407
|
export interface RenderBootCardOpts {
|
|
282
408
|
agentName: string
|
|
283
409
|
/** Lowercase slug used for systemd unit names. Falls back to
|
|
@@ -338,6 +464,14 @@ export interface RenderBootCardOpts {
|
|
|
338
464
|
* persisted snapshot from the prior boot. See `config-snapshot.ts`.
|
|
339
465
|
*/
|
|
340
466
|
configChanges?: ConfigDiff
|
|
467
|
+
/**
|
|
468
|
+
* Parsed `.routing-mode` state (see `readRoutingMode`). When present, a
|
|
469
|
+
* "Routing" row is rendered — always visible, warning-dotted on
|
|
470
|
+
* landed≠declared divergence, suffixed "(prev boot)" when the file
|
|
471
|
+
* predates this gateway boot. Absent/null = row omitted entirely
|
|
472
|
+
* (fleets pre-dating the state file, unreadable file).
|
|
473
|
+
*/
|
|
474
|
+
routing?: RoutingModeInfo | null
|
|
341
475
|
}
|
|
342
476
|
|
|
343
477
|
/**
|
|
@@ -456,8 +590,13 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
|
|
|
456
590
|
}
|
|
457
591
|
}
|
|
458
592
|
|
|
593
|
+
// Routing row (.routing-mode observability) — rendered whenever the state
|
|
594
|
+
// file was readable. Sits with the probe rows so divergence reads like any
|
|
595
|
+
// other degraded surface.
|
|
596
|
+
const routingRows: string[] = opts.routing ? [renderRoutingRow(opts.routing)] : []
|
|
597
|
+
|
|
459
598
|
const sections: string[] = [ack]
|
|
460
|
-
if (degradedRows.length > 0) sections.push('', ...degradedRows)
|
|
599
|
+
if (degradedRows.length > 0 || routingRows.length > 0) sections.push('', ...degradedRows, ...routingRows)
|
|
461
600
|
if (accountRows.length > 0) sections.push('', ...accountRows)
|
|
462
601
|
if (opts.updateOutcomeLine) {
|
|
463
602
|
// PR C: each line of the update-outcome blob is its own row in the
|
|
@@ -616,6 +755,15 @@ export interface RunProbesOpts {
|
|
|
616
755
|
floodStatePath?: string
|
|
617
756
|
/** Injectable clock for the flood-wait check (tests). Defaults to Date.now. */
|
|
618
757
|
nowMs?: () => number
|
|
758
|
+
/**
|
|
759
|
+
* Epoch-ms this gateway boot started — the staleness threshold for the
|
|
760
|
+
* `.routing-mode` row (a file mtime older than this is the PREVIOUS boot's
|
|
761
|
+
* value and is labeled, never shown as current). Defaults to the CONTAINER
|
|
762
|
+
* boot time (`containerBootStartMs`, stable across gateway-only respawns),
|
|
763
|
+
* falling back to the gateway process start when /proc is unavailable. Test
|
|
764
|
+
* override.
|
|
765
|
+
*/
|
|
766
|
+
routingBootStartMs?: number
|
|
619
767
|
}
|
|
620
768
|
|
|
621
769
|
/** Run all six probes concurrently with their own per-probe timeouts.
|
|
@@ -879,6 +1027,23 @@ export async function startBootCard(
|
|
|
879
1027
|
}
|
|
880
1028
|
}
|
|
881
1029
|
|
|
1030
|
+
// Routing-mode row (.routing-mode observability). Read at settle
|
|
1031
|
+
// time — by then a healthy inner pass has usually written this
|
|
1032
|
+
// boot's line; a degraded LiteLLM probe can still delay the write,
|
|
1033
|
+
// in which case the file mtime predates this gateway boot and the
|
|
1034
|
+
// reader flags it prevBoot (rendered "(prev boot)", never as
|
|
1035
|
+
// current). Absent/unreadable file → row omitted.
|
|
1036
|
+
const routingBootStartMs =
|
|
1037
|
+
opts.routingBootStartMs ??
|
|
1038
|
+
containerBootStartMs() ??
|
|
1039
|
+
Date.now() - process.uptime() * 1000
|
|
1040
|
+
let routing: RoutingModeInfo | null = null
|
|
1041
|
+
try {
|
|
1042
|
+
routing = readRoutingMode(opts.agentDir, routingBootStartMs)
|
|
1043
|
+
} catch {
|
|
1044
|
+
routing = null
|
|
1045
|
+
}
|
|
1046
|
+
|
|
882
1047
|
// Render with current probe state and edit if anything changed.
|
|
883
1048
|
let currentText = renderBootCard({
|
|
884
1049
|
agentName: opts.agentName,
|
|
@@ -892,6 +1057,7 @@ export async function startBootCard(
|
|
|
892
1057
|
...(snoozeRows.length > 0 ? { snoozeRows } : {}),
|
|
893
1058
|
...(opts.updateOutcomeLine ? { updateOutcomeLine: opts.updateOutcomeLine } : {}),
|
|
894
1059
|
...(configChanges.length > 0 ? { configChanges } : {}),
|
|
1060
|
+
...(routing ? { routing } : {}),
|
|
895
1061
|
})
|
|
896
1062
|
|
|
897
1063
|
if (currentText !== ackText) {
|
|
@@ -947,6 +1113,8 @@ export async function startBootCard(
|
|
|
947
1113
|
// (computed once in Phase 1). Pass them through unchanged so
|
|
948
1114
|
// the live-agent-status edits keep the config-diff rows.
|
|
949
1115
|
...(configChanges.length > 0 ? { configChanges } : {}),
|
|
1116
|
+
// Routing row is stable for the card lifetime (read once above).
|
|
1117
|
+
...(routing ? { routing } : {}),
|
|
950
1118
|
})
|
|
951
1119
|
|
|
952
1120
|
if (updatedText === currentText) continue
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/model` and `/effort` slash commands extracted verbatim from gateway.ts
|
|
3
|
+
* (switchroom#2996 P6 bot.command drain).
|
|
4
|
+
*
|
|
5
|
+
* Pure-logic helpers (parse/plan/menu/handle from model-command.ts,
|
|
6
|
+
* effort-command.ts, pending-session-command.ts) are imported directly;
|
|
7
|
+
* every gateway-local surface (busy resolver, deps factories, reply/queue
|
|
8
|
+
* plumbing, history recording) is injected via `deps` (repo factory-deps
|
|
9
|
+
* precedent, same shape as registerOpsInfoCommands). `bot` is injected so
|
|
10
|
+
* grammy registration order is preserved at the original call site.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Bot, Context, InlineKeyboard } from 'grammy'
|
|
14
|
+
import { richMessage } from '../rich-send.js'
|
|
15
|
+
import { hardenCardBreaks } from '../format.js'
|
|
16
|
+
import { escapeHtmlForTg } from '../shared/bot-runtime.js'
|
|
17
|
+
import {
|
|
18
|
+
parseModelCommand,
|
|
19
|
+
planModelCommand,
|
|
20
|
+
buildModelMenu,
|
|
21
|
+
handleModelCommand,
|
|
22
|
+
modelCommandReceiptLine,
|
|
23
|
+
type ModelMenuDeps,
|
|
24
|
+
type ModelCommandDeps,
|
|
25
|
+
type ModelMenuReply,
|
|
26
|
+
type ParsedModelCommand,
|
|
27
|
+
} from './model-command.js'
|
|
28
|
+
import {
|
|
29
|
+
parseEffortCommand,
|
|
30
|
+
handleEffortCommand,
|
|
31
|
+
buildEffortMenu,
|
|
32
|
+
type EffortCommandDeps,
|
|
33
|
+
type EffortMenuReply,
|
|
34
|
+
} from './effort-command.js'
|
|
35
|
+
import {
|
|
36
|
+
ackText as pendingCmdAckText,
|
|
37
|
+
type PendingSessionCommand,
|
|
38
|
+
} from './pending-session-command.js'
|
|
39
|
+
|
|
40
|
+
export interface ModelEffortCommandDeps {
|
|
41
|
+
isAuthorizedSender: (ctx: Context) => boolean
|
|
42
|
+
getMyAgentName: () => string
|
|
43
|
+
resolveThreadId: (chat_id: string, explicit?: string | number | null) => number | undefined
|
|
44
|
+
/** Stale-aware busy resolver (#3262) shared by /model and /effort. */
|
|
45
|
+
resolveModelEffortBusy: () => { currentTurnActive: boolean; turnInFlight: boolean }
|
|
46
|
+
historyEnabled: boolean
|
|
47
|
+
recordInbound: (args: {
|
|
48
|
+
chat_id: string
|
|
49
|
+
thread_id: number | null
|
|
50
|
+
message_id: number
|
|
51
|
+
user: string | null
|
|
52
|
+
user_id: string | null
|
|
53
|
+
ts: number
|
|
54
|
+
text: string
|
|
55
|
+
}) => void
|
|
56
|
+
buildModelDeps: (restartCtx?: { chatId: string; threadId: number | undefined }) => ModelMenuDeps & ModelCommandDeps
|
|
57
|
+
buildEffortDeps: () => EffortCommandDeps
|
|
58
|
+
modelMenuReplyMarkup: (reply: ModelMenuReply) => InlineKeyboard | undefined
|
|
59
|
+
effortMenuReplyMarkup: (reply: EffortMenuReply) => InlineKeyboard | undefined
|
|
60
|
+
enqueueSessionCommand: (cmd: PendingSessionCommand) => void
|
|
61
|
+
switchroomReply: (
|
|
62
|
+
ctx: Context,
|
|
63
|
+
text: string,
|
|
64
|
+
options?: { html?: boolean; reply_markup?: InlineKeyboard },
|
|
65
|
+
) => Promise<void>
|
|
66
|
+
modelMenuEnabled: () => boolean
|
|
67
|
+
/** stderr log sink. */
|
|
68
|
+
log: (line: string) => void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function registerModelEffortCommands(bot: Bot, deps: ModelEffortCommandDeps): void {
|
|
72
|
+
const {
|
|
73
|
+
isAuthorizedSender,
|
|
74
|
+
getMyAgentName,
|
|
75
|
+
resolveThreadId,
|
|
76
|
+
resolveModelEffortBusy,
|
|
77
|
+
historyEnabled,
|
|
78
|
+
recordInbound,
|
|
79
|
+
buildModelDeps,
|
|
80
|
+
buildEffortDeps,
|
|
81
|
+
modelMenuReplyMarkup,
|
|
82
|
+
effortMenuReplyMarkup,
|
|
83
|
+
enqueueSessionCommand,
|
|
84
|
+
switchroomReply,
|
|
85
|
+
modelMenuEnabled,
|
|
86
|
+
log,
|
|
87
|
+
} = deps
|
|
88
|
+
|
|
89
|
+
bot.command('model', async ctx => {
|
|
90
|
+
if (!isAuthorizedSender(ctx)) return
|
|
91
|
+
const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
|
|
92
|
+
const parsed: ParsedModelCommand = parseModelCommand(text) ?? { kind: 'show' as const }
|
|
93
|
+
const chatId = String(ctx.chat!.id)
|
|
94
|
+
const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
|
|
95
|
+
// #3177 — durable receipt FIRST. A typed /model must NEVER be invisible: even
|
|
96
|
+
// if every downstream reply is shed/dropped, or the session is in a
|
|
97
|
+
// phantom-idle window (turn atom cleared while claude is still busy), the
|
|
98
|
+
// command leaves a greppable log line + a history row before any branch. This
|
|
99
|
+
// is the fix for the finn 2026-07-12 zero-trace swallow (no log, no reply, no
|
|
100
|
+
// ack, no deferred apply). `busyNow` folds BOTH busy signals (turn atom AND
|
|
101
|
+
// the authoritative delivery-machine/approval gate) — but a STALE atom or a
|
|
102
|
+
// wedged approval older than the hard TTL is discounted as idle (#3262), so a
|
|
103
|
+
// phantom "active turn" on an idle session no longer blocks the switch. Resolve
|
|
104
|
+
// once (it may clear a dangling atom) and reuse for both the receipt log and
|
|
105
|
+
// the routing disposition.
|
|
106
|
+
const modelBusy = resolveModelEffortBusy()
|
|
107
|
+
const busyNow = modelBusy.currentTurnActive || modelBusy.turnInFlight
|
|
108
|
+
log(modelCommandReceiptLine(getMyAgentName(), parsed, busyNow) + '\n')
|
|
109
|
+
if (historyEnabled && ctx.message?.message_id != null) {
|
|
110
|
+
try {
|
|
111
|
+
recordInbound({
|
|
112
|
+
chat_id: chatId,
|
|
113
|
+
thread_id: threadId ?? null,
|
|
114
|
+
message_id: ctx.message.message_id,
|
|
115
|
+
user: ctx.from?.username ?? (ctx.from?.id != null ? String(ctx.from.id) : null),
|
|
116
|
+
user_id: ctx.from?.id != null ? String(ctx.from.id) : null,
|
|
117
|
+
ts: ctx.message.date ?? Math.floor(Date.now() / 1000),
|
|
118
|
+
text,
|
|
119
|
+
})
|
|
120
|
+
} catch (err) {
|
|
121
|
+
log(`telegram gateway: /model recordInbound failed: ${(err as Error)?.message ?? String(err)}\n`)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const cmdDeps = buildModelDeps({ chatId, threadId })
|
|
125
|
+
// Route on a pure disposition (#3177) that folds BOTH busy signals so a
|
|
126
|
+
// session busy by EITHER measure ack+queues instead of silently injecting
|
|
127
|
+
// into a busy pane. Every branch below produces a visible action.
|
|
128
|
+
const disposition = planModelCommand(parsed, {
|
|
129
|
+
currentTurnActive: modelBusy.currentTurnActive,
|
|
130
|
+
turnInFlight: modelBusy.turnInFlight,
|
|
131
|
+
menuEnabled: modelMenuEnabled(),
|
|
132
|
+
})
|
|
133
|
+
if (disposition.kind === 'menu') {
|
|
134
|
+
const menu = await buildModelMenu(cmdDeps)
|
|
135
|
+
await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) })
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
// Mid-turn (by either busy signal): instead of dead-ending ("Try again in a
|
|
139
|
+
// moment") or silently injecting into a busy pane, ACK + QUEUE + apply-on-idle
|
|
140
|
+
// + confirm (#3017/#3177). The typed set path either injects into claude's
|
|
141
|
+
// input box or triggers a carrier restart — both unsafe while busy.
|
|
142
|
+
if (disposition.kind === 'queue') {
|
|
143
|
+
const target = disposition.target
|
|
144
|
+
const sent = await ctx.replyWithRichMessage(
|
|
145
|
+
richMessage(hardenCardBreaks(pendingCmdAckText('model', target, escapeHtmlForTg))),
|
|
146
|
+
threadId != null ? { message_thread_id: threadId } : {},
|
|
147
|
+
)
|
|
148
|
+
enqueueSessionCommand({
|
|
149
|
+
kind: 'model',
|
|
150
|
+
origin: 'typed',
|
|
151
|
+
arg: target,
|
|
152
|
+
targetLabel: target,
|
|
153
|
+
chatId,
|
|
154
|
+
threadId,
|
|
155
|
+
ackChatId: chatId,
|
|
156
|
+
ackMessageId: (sent as { message_id: number }).message_id,
|
|
157
|
+
requestedAt: Date.now(),
|
|
158
|
+
})
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
// Rev 5: the handler relaunches through the carrier and owns every side effect
|
|
162
|
+
// (override write, carrier, premium-recovery clear). There is no post-hoc
|
|
163
|
+
// recording — /status is reconciled at boot from `.active-session-model`, so
|
|
164
|
+
// an unapplied switch can never be optimistically recorded here.
|
|
165
|
+
const reply = await handleModelCommand(parsed, cmdDeps)
|
|
166
|
+
await switchroomReply(ctx, reply.text, { html: reply.html })
|
|
167
|
+
})
|
|
168
|
+
bot.command('effort', async ctx => {
|
|
169
|
+
if (!isAuthorizedSender(ctx)) return
|
|
170
|
+
const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
|
|
171
|
+
const parsed = parseEffortCommand(text) ?? { kind: 'show' as const }
|
|
172
|
+
const cmdDeps = buildEffortDeps()
|
|
173
|
+
if (parsed.kind === 'show') {
|
|
174
|
+
const menu = buildEffortMenu(cmdDeps)
|
|
175
|
+
await switchroomReply(ctx, menu.text, { html: true, reply_markup: effortMenuReplyMarkup(menu) })
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
// Mid-turn: ACK + QUEUE + apply-on-idle + confirm (#3017) — parity with
|
|
179
|
+
// /model. `applyEffort` mid-turn silently maybe-failed ("couldn't confirm it
|
|
180
|
+
// applied") before this gate existed. Use the SAME stale-aware busy resolver
|
|
181
|
+
// as /model (#3262) so a dangling turn atom older than the hard TTL is
|
|
182
|
+
// discounted as idle and the switch applies instead of queuing forever on an
|
|
183
|
+
// idle session.
|
|
184
|
+
const effortBusy = resolveModelEffortBusy()
|
|
185
|
+
if ((parsed.kind === 'set' || parsed.kind === 'default') && effortBusy.currentTurnActive) {
|
|
186
|
+
const requestedLevel = parsed.kind === 'set' ? parsed.level : 'default'
|
|
187
|
+
const chatId = String(ctx.chat!.id)
|
|
188
|
+
const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
|
|
189
|
+
const sent = await ctx.replyWithRichMessage(
|
|
190
|
+
richMessage(hardenCardBreaks(pendingCmdAckText('effort', requestedLevel, escapeHtmlForTg))),
|
|
191
|
+
threadId != null ? { message_thread_id: threadId } : {},
|
|
192
|
+
)
|
|
193
|
+
enqueueSessionCommand({
|
|
194
|
+
kind: 'effort',
|
|
195
|
+
origin: 'typed',
|
|
196
|
+
arg: requestedLevel,
|
|
197
|
+
targetLabel: requestedLevel,
|
|
198
|
+
chatId,
|
|
199
|
+
threadId,
|
|
200
|
+
ackChatId: chatId,
|
|
201
|
+
ackMessageId: (sent as { message_id: number }).message_id,
|
|
202
|
+
requestedAt: Date.now(),
|
|
203
|
+
})
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
const reply = await handleEffortCommand(parsed, cmdDeps)
|
|
207
|
+
await switchroomReply(ctx, reply.text, { html: reply.html })
|
|
208
|
+
})
|
|
209
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Basic info slash commands extracted verbatim from gateway.ts
|
|
3
|
+
* (switchroom#2996 P6 bot.command drain): `/start`, `/help`, `/status`,
|
|
4
|
+
* `/agents`.
|
|
5
|
+
*
|
|
6
|
+
* Pure read-only info surfaces (welcome/help text, pairing status, agent
|
|
7
|
+
* list) with no send-path or turn-lifecycle coupling. Every gateway-local
|
|
8
|
+
* helper they close over is passed in via `deps` (repo factory-deps
|
|
9
|
+
* precedent, same shape as registerOpsInfoCommands) so the module never
|
|
10
|
+
* back-references the gateway singleton. `bot` is injected so grammy
|
|
11
|
+
* registration order is preserved at the original call site.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Bot, Context } from 'grammy'
|
|
15
|
+
import { richMessage } from '../rich-send.js'
|
|
16
|
+
import {
|
|
17
|
+
startText as buildStartText,
|
|
18
|
+
helpText as buildHelpText,
|
|
19
|
+
statusPairedText as buildStatusPairedText,
|
|
20
|
+
statusPendingText as buildStatusPendingText,
|
|
21
|
+
statusUnpairedText as buildStatusUnpairedText,
|
|
22
|
+
type AgentMetadata,
|
|
23
|
+
} from '../welcome-text.js'
|
|
24
|
+
import { escapeHtmlForTg } from '../shared/bot-runtime.js'
|
|
25
|
+
import type { Access } from './gateway.js'
|
|
26
|
+
|
|
27
|
+
export interface StartInfoCommandDeps {
|
|
28
|
+
/** #894 backport gate: silent drop on disabled / non-allowlisted senders. */
|
|
29
|
+
dmCommandGate: (ctx: Context) => { access: Access; senderId: string } | null
|
|
30
|
+
isAuthorizedSender: (ctx: Context) => boolean
|
|
31
|
+
getMyAgentName: () => string
|
|
32
|
+
getCommandArgs: (ctx: Context) => string
|
|
33
|
+
hasDemoFlag: (args: string) => boolean
|
|
34
|
+
buildAgentMetadata: (agentName: string) => Promise<AgentMetadata>
|
|
35
|
+
runSwitchroomCommandFormatted: (
|
|
36
|
+
ctx: Context,
|
|
37
|
+
args: string[],
|
|
38
|
+
label: string,
|
|
39
|
+
formatter: () => string | null,
|
|
40
|
+
) => Promise<void>
|
|
41
|
+
switchroomExecJson: <T = unknown>(args: string[]) => T | null
|
|
42
|
+
statusIcon: (status: string) => string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function registerStartInfoCommands(bot: Bot, deps: StartInfoCommandDeps): void {
|
|
46
|
+
const {
|
|
47
|
+
dmCommandGate,
|
|
48
|
+
isAuthorizedSender,
|
|
49
|
+
getMyAgentName,
|
|
50
|
+
getCommandArgs,
|
|
51
|
+
hasDemoFlag,
|
|
52
|
+
buildAgentMetadata,
|
|
53
|
+
runSwitchroomCommandFormatted,
|
|
54
|
+
switchroomExecJson,
|
|
55
|
+
statusIcon,
|
|
56
|
+
} = deps
|
|
57
|
+
|
|
58
|
+
bot.command('start', async ctx => {
|
|
59
|
+
// dmCommandGate (#894 backport): silent drop on disabled or
|
|
60
|
+
// non-allowlisted senders so the bot doesn't leak its existence.
|
|
61
|
+
const gated = dmCommandGate(ctx)
|
|
62
|
+
if (!gated) return
|
|
63
|
+
const disabled = gated.access.dmPolicy === 'disabled'
|
|
64
|
+
await ctx.replyWithRichMessage(richMessage(buildStartText(getMyAgentName(), disabled)))
|
|
65
|
+
})
|
|
66
|
+
bot.command('help', async ctx => {
|
|
67
|
+
if (!dmCommandGate(ctx)) return
|
|
68
|
+
await ctx.replyWithRichMessage(richMessage(buildHelpText(getMyAgentName())))
|
|
69
|
+
})
|
|
70
|
+
bot.command('status', async ctx => {
|
|
71
|
+
// dmCommandGate (#894 backport) drops disabled / non-allowlisted in
|
|
72
|
+
// allowlist mode. Pairing-mode users still fall through to either
|
|
73
|
+
// the pending-code branch or the unpaired branch.
|
|
74
|
+
const gated = dmCommandGate(ctx)
|
|
75
|
+
if (!gated) return
|
|
76
|
+
const { access, senderId } = gated
|
|
77
|
+
const from = ctx.from!
|
|
78
|
+
if (access.allowFrom.includes(senderId)) {
|
|
79
|
+
const demo = hasDemoFlag(getCommandArgs(ctx))
|
|
80
|
+
const userTag = from.username ? `@${from.username}` : senderId
|
|
81
|
+
const meta = await buildAgentMetadata(getMyAgentName())
|
|
82
|
+
await ctx.replyWithRichMessage(richMessage(buildStatusPairedText({ user: userTag, meta, demo })))
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
for (const [code, p] of Object.entries(access.pending)) {
|
|
86
|
+
if (p.senderId === senderId) {
|
|
87
|
+
await ctx.replyWithRichMessage(richMessage(buildStatusPendingText(code)))
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
await ctx.reply(buildStatusUnpairedText())
|
|
92
|
+
})
|
|
93
|
+
bot.command('agents', async ctx => {
|
|
94
|
+
if (!isAuthorizedSender(ctx)) return
|
|
95
|
+
await runSwitchroomCommandFormatted(ctx, ['agent', 'list'], 'agent list', () => {
|
|
96
|
+
type AgentListResp = { agents: Array<{ name: string; status: string; uptime: string; template: string; topic_name: string; topic_emoji?: string }> }
|
|
97
|
+
const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
|
|
98
|
+
if (!data) return null
|
|
99
|
+
if (data.agents.length === 0) return '_No agents defined_'
|
|
100
|
+
const lines = ['**Agents**']
|
|
101
|
+
for (const a of data.agents) {
|
|
102
|
+
lines.push(`${statusIcon(a.status)} **${escapeHtmlForTg(a.name)}** · ${escapeHtmlForTg(a.status)} · ${escapeHtmlForTg(a.uptime)}`)
|
|
103
|
+
lines.push(` _${escapeHtmlForTg(a.template)} → ${escapeHtmlForTg(a.topic_name)}${a.topic_emoji ? ' ' + a.topic_emoji : ''}_`)
|
|
104
|
+
}
|
|
105
|
+
return lines.join('\n')
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
}
|