switchroom 0.16.29 → 0.16.46

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.
Files changed (130) hide show
  1. package/dist/agent-scheduler/index.js +101 -84
  2. package/dist/auth-broker/index.js +100 -83
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +102 -85
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +62124 -60406
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +1000 -256
  10. package/dist/vault/approvals/kernel-server.js +141 -121
  11. package/dist/vault/broker/server.js +163 -143
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +3 -2
  15. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  16. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  17. package/skills/switchroom-status/SKILL.md +1 -1
  18. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  19. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  20. package/telegram-plugin/bridge/bridge.ts +2 -1
  21. package/telegram-plugin/card-format.ts +59 -3
  22. package/telegram-plugin/credits-watch.ts +4 -7
  23. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  24. package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
  25. package/telegram-plugin/dist/server.js +180 -163
  26. package/telegram-plugin/format.ts +551 -20
  27. package/telegram-plugin/gateway/approval-card.ts +7 -14
  28. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  29. package/telegram-plugin/gateway/auth-command.ts +35 -38
  30. package/telegram-plugin/gateway/boot-card.ts +7 -2
  31. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  32. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  33. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  34. package/telegram-plugin/gateway/gateway.ts +1524 -129
  35. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  36. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  37. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  38. package/telegram-plugin/gateway/model-command.ts +173 -19
  39. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  40. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  41. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  42. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  43. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  44. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  45. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  46. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  47. package/telegram-plugin/history.ts +17 -7
  48. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  49. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  50. package/telegram-plugin/idle-footer.ts +2 -2
  51. package/telegram-plugin/issues-card.ts +12 -7
  52. package/telegram-plugin/model-unavailable.ts +3 -6
  53. package/telegram-plugin/operator-events.ts +4 -6
  54. package/telegram-plugin/package.json +1 -1
  55. package/telegram-plugin/quota-check.ts +2 -2
  56. package/telegram-plugin/quota-watch.ts +7 -10
  57. package/telegram-plugin/server.ts +3 -1
  58. package/telegram-plugin/session-tail.ts +47 -1
  59. package/telegram-plugin/status-pin-driver.ts +102 -0
  60. package/telegram-plugin/status-pin.ts +76 -0
  61. package/telegram-plugin/stream-reply-handler.ts +33 -2
  62. package/telegram-plugin/subagent-watcher.ts +6 -3
  63. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  64. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  65. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  66. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  67. package/telegram-plugin/tests/card-format.test.ts +79 -0
  68. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  69. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  70. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  71. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  72. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  73. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  74. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  75. package/telegram-plugin/tests/history.test.ts +38 -2
  76. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  77. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  78. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  79. package/telegram-plugin/tests/model-command.test.ts +213 -47
  80. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  81. package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
  82. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  83. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  84. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  85. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  86. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  87. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  88. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  89. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  90. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  93. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  94. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  95. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  96. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  97. package/telegram-plugin/tests/telegram-format.test.ts +121 -8
  98. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  99. package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
  100. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  101. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  102. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  103. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  104. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  105. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  106. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  107. package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
  108. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  109. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  110. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  111. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  112. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  113. package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
  114. package/telegram-plugin/text-voice-scrub.ts +68 -18
  115. package/telegram-plugin/tool-activity-summary.ts +31 -116
  116. package/telegram-plugin/tts-normalize.ts +377 -0
  117. package/telegram-plugin/uat/driver.ts +474 -17
  118. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  119. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  120. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  121. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  122. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  123. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  124. package/telegram-plugin/voice-normalize-text.ts +340 -0
  125. package/telegram-plugin/voice-ondemand.ts +289 -0
  126. package/telegram-plugin/voice-presynth.ts +242 -0
  127. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  128. package/telegram-plugin/voice-synthesize.ts +128 -0
  129. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  130. package/telegram-plugin/worker-activity-feed.ts +22 -5
@@ -142,7 +142,8 @@ export type GatewayToClient =
142
142
  | ScheduleRestartResult
143
143
  | DriveApprovalPostedEvent
144
144
  | Ms365ApprovalPostedEvent
145
- | ConfigApprovalResolvedEvent;
145
+ | ConfigApprovalResolvedEvent
146
+ | RolloutStatusPostedEvent;
146
147
 
147
148
  // === Bridge (Client) -> Gateway messages ===
148
149
 
@@ -493,6 +494,67 @@ export interface PostSkillProposalMessage {
493
494
  draft: Record<string, string>;
494
495
  }
495
496
 
497
+ /**
498
+ * #2726 Part 1 — hostd asks the caller agent's gateway to POST one ordinary
499
+ * operator-DM message narrating a rollout's terminal outcome. This is a NORMAL
500
+ * message, NOT a pinned card and NOT a bespoke widget — the framework speaking
501
+ * a plain progress line in the chat, which keeps it clear of
502
+ * `chat-is-the-single-source-of-truth` (in-chat narration, not a parallel
503
+ * pinned mirror).
504
+ *
505
+ * The gateway posts `text` to the operator chat (`allowFrom[0]`) and — for the
506
+ * Part 2 narration surface — replies with a `rollout_status_posted` event
507
+ * carrying the message_id so hostd can EDIT it as later phases arrive. Part 1
508
+ * uses only the terminal post and ignores the reply (fire-and-forget).
509
+ *
510
+ * Trust model: same as request_config_approval — the gateway socket lives in
511
+ * the agent container; hostd reaches it via the per-agent state-dir bind mount.
512
+ * `agentName` is validated server-side; the chat target is the gateway's OWN
513
+ * operator, never a caller-supplied chat.
514
+ */
515
+ export interface RolloutStatusPostMessage {
516
+ type: "rollout_status_post";
517
+ /** hostd request_id of the roll (binds the surface to a real request). */
518
+ requestId: string;
519
+ /** The admin agent whose gateway relays the message (the rollout caller). */
520
+ agentName: string;
521
+ /** Fully-rendered message body. */
522
+ text: string;
523
+ }
524
+
525
+ /**
526
+ * #2726 Part 2 — hostd asks the gateway to EDIT the rollout status message it
527
+ * previously posted (identified by `messageId`, returned in the
528
+ * `rollout_status_posted` reply). Best-effort, fire-and-forget: an edit failure
529
+ * (incl. Telegram 429) is handled gateway-side and never surfaced back toward
530
+ * the roll.
531
+ */
532
+ export interface RolloutStatusEditMessage {
533
+ type: "rollout_status_edit";
534
+ requestId: string;
535
+ agentName: string;
536
+ /** message_id of the status message to edit (from rollout_status_posted). */
537
+ messageId: number;
538
+ /** New fully-rendered body. */
539
+ text: string;
540
+ }
541
+
542
+ /**
543
+ * #2726 — gateway → hostd reply after a `rollout_status_post` was posted, so
544
+ * hostd learns the message_id to EDIT for subsequent phases. `ok:false` when
545
+ * the post failed (hostd then simply won't edit — the durable log + terminal
546
+ * push remain the record).
547
+ */
548
+ export interface RolloutStatusPostedEvent {
549
+ type: "rollout_status_posted";
550
+ requestId: string;
551
+ ok: boolean;
552
+ /** Telegram message_id of the posted status message (present when ok). */
553
+ messageId?: number;
554
+ /** Diagnostic detail on failure. */
555
+ reason?: string;
556
+ }
557
+
496
558
  export type ClientToGateway =
497
559
  | RegisterMessage
498
560
  | ToolCallMessage
@@ -510,4 +572,6 @@ export type ClientToGateway =
510
572
  | RequestConfigFinalizeMessage
511
573
  | QuotaWallDetectedMessage
512
574
  | SendOutboundMessage
513
- | PostSkillProposalMessage;
575
+ | PostSkillProposalMessage
576
+ | RolloutStatusPostMessage
577
+ | RolloutStatusEditMessage;
@@ -1,4 +1,4 @@
1
- import { renameSync, unlinkSync } from "fs";
1
+ import { renameSync, unlinkSync, chmodSync } from "fs";
2
2
  import type {
3
3
  ClientToGateway,
4
4
  GatewayToClient,
@@ -15,6 +15,8 @@ import type {
15
15
  RequestConfigFinalizeMessage,
16
16
  RequestDriveApprovalMessage,
17
17
  RequestMs365ApprovalMessage,
18
+ RolloutStatusPostMessage,
19
+ RolloutStatusEditMessage,
18
20
  ScheduleRestartMessage,
19
21
  SessionEventForward,
20
22
  ToolCallMessage,
@@ -113,6 +115,25 @@ export interface IpcServerOptions {
113
115
  client: IpcClient,
114
116
  msg: RequestConfigFinalizeMessage,
115
117
  ) => Promise<void>;
118
+ /**
119
+ * #2726 Part 1 — hostd asks the gateway to POST one ordinary operator-DM
120
+ * message narrating a rollout's terminal outcome (Part 1) or its first phase
121
+ * (Part 2). Handler posts to the agent's own operator chat and — for Part 2 —
122
+ * replies with a `rollout_status_posted` event carrying the message_id.
123
+ * Optional: gateways without the hostd integration ignore it.
124
+ */
125
+ onRolloutStatusPost?: (
126
+ client: IpcClient,
127
+ msg: RolloutStatusPostMessage,
128
+ ) => Promise<void>;
129
+ /**
130
+ * #2726 Part 2 — hostd asks the gateway to EDIT a previously-posted rollout
131
+ * status message as later phases arrive. Best-effort, no reply. Optional.
132
+ */
133
+ onRolloutStatusEdit?: (
134
+ client: IpcClient,
135
+ msg: RolloutStatusEditMessage,
136
+ ) => void;
116
137
  log?: (msg: string) => void;
117
138
  /**
118
139
  * How long (in ms) to wait without a heartbeat before force-closing the
@@ -381,6 +402,33 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
381
402
  || (m.ttlMs as number) < 0)) return false;
382
403
  return true;
383
404
  }
405
+ case "rollout_status_post": {
406
+ // #2726 Part 1 — hostd-initiated terminal ping / status post. Wire shape
407
+ // only; the gateway handler fences the chat to the agent's own operator.
408
+ if (typeof m.requestId !== "string"
409
+ || (m.requestId as string).length === 0
410
+ || (m.requestId as string).length > 64) return false;
411
+ if (typeof m.agentName !== "string"
412
+ || !AGENT_NAME_RE.test(m.agentName as string)) return false;
413
+ if (typeof m.text !== "string"
414
+ || (m.text as string).length === 0
415
+ || (m.text as string).length > RICH_MESSAGE_MAX_CHARS) return false;
416
+ return true;
417
+ }
418
+ case "rollout_status_edit": {
419
+ // #2726 Part 2 — hostd-initiated status-message edit.
420
+ if (typeof m.requestId !== "string"
421
+ || (m.requestId as string).length === 0
422
+ || (m.requestId as string).length > 64) return false;
423
+ if (typeof m.agentName !== "string"
424
+ || !AGENT_NAME_RE.test(m.agentName as string)) return false;
425
+ if (typeof m.messageId !== "number"
426
+ || !Number.isInteger(m.messageId as number)) return false;
427
+ if (typeof m.text !== "string"
428
+ || (m.text as string).length === 0
429
+ || (m.text as string).length > RICH_MESSAGE_MAX_CHARS) return false;
430
+ return true;
431
+ }
384
432
  default:
385
433
  return false;
386
434
  }
@@ -406,6 +454,8 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
406
454
  onRequestMs365Approval,
407
455
  onRequestConfigApproval,
408
456
  onRequestConfigFinalize,
457
+ onRolloutStatusPost,
458
+ onRolloutStatusEdit,
409
459
  log = () => {},
410
460
  heartbeatTimeoutMs = 30_000,
411
461
  } = options;
@@ -637,6 +687,42 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
637
687
  }
638
688
  // No reply expected.
639
689
  break;
690
+ case "rollout_status_post":
691
+ if (onRolloutStatusPost) {
692
+ onRolloutStatusPost(client, msg as RolloutStatusPostMessage).catch(
693
+ (err) => {
694
+ log(
695
+ `rollout_status_post handler threw (client=${client.id}): ${(err as Error).message}`,
696
+ );
697
+ // Best-effort failure reply so hostd's Part 2 renderer knows the
698
+ // post failed (it just won't edit). Part 1 ignores the reply.
699
+ try {
700
+ client.send({
701
+ type: "rollout_status_posted",
702
+ requestId: (msg as RolloutStatusPostMessage).requestId,
703
+ ok: false,
704
+ reason: `gateway handler error: ${(err as Error).message}`,
705
+ });
706
+ } catch {
707
+ /* best effort */
708
+ }
709
+ },
710
+ );
711
+ }
712
+ // No reply required when unwired — hostd falls back to the durable log.
713
+ break;
714
+ case "rollout_status_edit":
715
+ if (onRolloutStatusEdit) {
716
+ try {
717
+ onRolloutStatusEdit(client, msg as RolloutStatusEditMessage);
718
+ } catch (err) {
719
+ log(
720
+ `rollout_status_edit handler threw (client=${client.id}): ${(err as Error).message}`,
721
+ );
722
+ }
723
+ }
724
+ // Fire-and-forget; no reply.
725
+ break;
640
726
  case "update_placeholder":
641
727
  // Legacy recall.py IPC — placeholder UX was removed in #553 PR 5.
642
728
  // Soft-accepted so recall.py keeps working without modifying
@@ -786,6 +872,10 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
786
872
  },
787
873
  });
788
874
 
875
+ // Allow the web container (uid=1000, operator) to inject prompts via
876
+ // injectInbound without needing root — the socket is inside the per-agent
877
+ // state directory which is already operator-accessible.
878
+ try { chmodSync(socketPath, 0o666); } catch { /* best-effort */ }
789
879
  log(`listening on ${socketPath}`);
790
880
 
791
881
  // ─── Heartbeat watchdog (issue #71) ─────────────────────────────────────
@@ -15,6 +15,7 @@
15
15
  * agent to `vault_request_access` for that key rather than failing opaquely.
16
16
  */
17
17
 
18
+ import { escapeMarkdown } from '../format.js'
18
19
  import {
19
20
  getViaBrokerStructured,
20
21
  putViaBroker,
@@ -30,10 +31,6 @@ export type LinearAuthDeadReason = 'no_bundle' | 'revoked'
30
31
  /** Minimal GFM-markdown escape (#2669). Kept local so the message builder is
31
32
  * self-contained + unit-testable without reaching into a gateway-only
32
33
  * escaper (the bug that shipped the first cut of this alert). */
33
- function escapeMarkdownMin(s: string): string {
34
- return s.replace(/([\\`*_~=\[\]|])/g, '\\$1')
35
- }
36
-
37
34
  /**
38
35
  * Build the operator-facing Telegram alert (GFM markdown) for an un-healable
39
36
  * Linear auth failure. Pure + self-escaping so it can be unit-tested directly.
@@ -42,7 +39,7 @@ function escapeMarkdownMin(s: string): string {
42
39
  export function buildLinearAuthDeadMessage(agent: string, reason: LinearAuthDeadReason): string {
43
40
  // Inside `code` spans the agent slug is literal (no escaping); in prose it
44
41
  // is markdown-escaped.
45
- const aEsc = escapeMarkdownMin(agent)
42
+ const aEsc = escapeMarkdown(agent)
46
43
  const why =
47
44
  reason === 'no_bundle'
48
45
  ? `no refresh credentials are stored (\`linear/${agent}/oauth\` is missing), so its daily-expiring token can't renew`
@@ -308,6 +308,32 @@ export const MODEL_CALLBACK_REFRESH = 'mdl:r'
308
308
  export const MODEL_CALLBACK_SR = 'mdl:sr:'
309
309
  /** Callback for section-header rows — shows an informational toast, no action. */
310
310
  export const MODEL_CALLBACK_HEADER = 'mdl:h'
311
+ /**
312
+ * Callback prefix for Claude aliases that the CLI picker doesn't render but
313
+ * the CLI resolves natively (e.g. `fable`). Carries the alias verbatim; its
314
+ * handler INJECTS `/model <alias>` — the same mechanism MODEL_CALLBACK_SR
315
+ * uses — because the cursor-nav select path can only pick rows claude's own
316
+ * picker actually renders.
317
+ */
318
+ export const MODEL_CALLBACK_ALIAS = 'mdl:alias:'
319
+ /** Callback: open the nested "External models" keyboard page. */
320
+ export const MODEL_CALLBACK_PAGE_EXTERNAL = 'mdl:page:ext'
321
+ /** Callback: return from the External page to the main keyboard page. */
322
+ export const MODEL_CALLBACK_PAGE_MAIN = 'mdl:page:main'
323
+
324
+ /** Which keyboard page the model menu is currently rendering. */
325
+ export type ModelMenuPage = 'main' | 'external'
326
+
327
+ /**
328
+ * Static Claude aliases appended to the scraped Claude group. The claude CLI's
329
+ * own `/model` picker (deps.discover) does NOT list `fable`, but the CLI
330
+ * resolves the alias natively, so we render it as an extra button that selects
331
+ * by injecting `/model fable` (MODEL_CALLBACK_ALIAS). Extend this list to
332
+ * surface further CLI-resolvable aliases the picker omits.
333
+ */
334
+ export const EXTRA_CLAUDE_ALIASES: ReadonlyArray<{ alias: string; label: string }> = [
335
+ { alias: 'fable', label: 'Fable' },
336
+ ]
311
337
 
312
338
  /**
313
339
  * Friendly display names for sr-* synthetic model names. An sr-* model in
@@ -394,14 +420,42 @@ function headerRow(label: string): ModelMenuKeyboardButton[] {
394
420
  return [{ text: label, callback_data: MODEL_CALLBACK_HEADER }]
395
421
  }
396
422
 
397
- function menuKeyboard(
423
+ /**
424
+ * The external (🌐 non-Anthropic) model list, sourced from the static
425
+ * SR_MODEL_ALIASES values UNION-ed with any live discoverSrModels() results,
426
+ * deduped and sorted.
427
+ *
428
+ * Why the static union: discoverSrModels() reads LiteLLM's /model/info, which
429
+ * requires ANTHROPIC_CUSTOM_HEADERS (a litellm key) to be set on the gateway
430
+ * process. switchroom never sets that env on the gateway, so in production
431
+ * discoverSrModels() always returns [] and the external group was silently
432
+ * empty. The six SR_MODEL_ALIASES targets are the sr-* names the litellm
433
+ * config actually exposes, so seeding from them makes the group reliable
434
+ * without the missing env — while still merging any live results on hosts
435
+ * that do configure discovery.
436
+ *
437
+ * Subscription-honest: ONLY the curated sr-* aliases surface as buttons. Raw
438
+ * gpt-4o / openrouter/* dupes / voyage-* embeddings never do.
439
+ */
440
+ export function externalModelNames(discovered: string[]): string[] {
441
+ const set = new Set<string>(Object.values(SR_MODEL_ALIASES))
442
+ for (const n of discovered) {
443
+ if (isSrModel(n)) set.add(n)
444
+ }
445
+ return [...set].sort()
446
+ }
447
+
448
+ /**
449
+ * Main keyboard page: scraped Claude buttons + static Fable alias, then (only
450
+ * when the external list is non-empty) a single "🌐 External models ▸" row that
451
+ * opens the nested page, then Refresh.
452
+ */
453
+ function mainPageKeyboard(
398
454
  claudeOptions: ModelPickerOption[],
399
- srOptions: ModelPickerOption[],
455
+ hasExternal: boolean,
400
456
  ): ModelMenuKeyboardButton[][] {
401
- const hasBothGroups = claudeOptions.length > 0 && srOptions.length > 0
402
457
  const rows: ModelMenuKeyboardButton[][] = []
403
458
 
404
- if (hasBothGroups) rows.push(headerRow('── Claude (Max / Pro subscription) ──'))
405
459
  for (const o of claudeOptions) {
406
460
  rows.push([{
407
461
  text: o.current ? `✅ ${o.label}` : o.label,
@@ -409,23 +463,44 @@ function menuKeyboard(
409
463
  }])
410
464
  }
411
465
 
412
- // sr-* models are non-Anthropic (routed via LiteLLM → OpenRouter).
413
- // Selection uses text-inject rather than cursor-nav — more reliable
414
- // when the picker has many models (GATEWAY_MODEL_DISCOVERY=1).
415
- if (srOptions.length > 0) {
416
- rows.push(headerRow('── OpenRouter / external ──'))
417
- for (const o of srOptions) {
418
- rows.push([{
419
- text: `🌐 ${srFriendlyLabel(o.label)}`,
420
- callback_data: `${MODEL_CALLBACK_SR}${o.label}`,
421
- }])
422
- }
466
+ // Static Claude aliases the CLI picker omits (e.g. Fable). Deduped: if the
467
+ // scraped Claude options already include a matching row, don't render the
468
+ // static one too.
469
+ for (const { alias, label } of EXTRA_CLAUDE_ALIASES) {
470
+ const already = claudeOptions.some(
471
+ (o) => o.label.toLowerCase() === label.toLowerCase() ||
472
+ o.label.toLowerCase() === alias.toLowerCase(),
473
+ )
474
+ if (already) continue
475
+ rows.push([{ text: label, callback_data: `${MODEL_CALLBACK_ALIAS}${alias}` }])
476
+ }
477
+
478
+ if (hasExternal) {
479
+ rows.push([{ text: '🌐 External models ▸', callback_data: MODEL_CALLBACK_PAGE_EXTERNAL }])
423
480
  }
424
481
 
425
482
  rows.push([{ text: '🔄 Refresh', callback_data: MODEL_CALLBACK_REFRESH }])
426
483
  return rows
427
484
  }
428
485
 
486
+ /**
487
+ * External keyboard page: a labelled header, one 🌐 button per external model
488
+ * (reusing the existing MODEL_CALLBACK_SR select handler), then Back + Refresh.
489
+ */
490
+ function externalPageKeyboard(externalNames: string[]): ModelMenuKeyboardButton[][] {
491
+ const rows: ModelMenuKeyboardButton[][] = []
492
+ rows.push(headerRow('── External (billed separately) ──'))
493
+ for (const name of externalNames) {
494
+ rows.push([{
495
+ text: `🌐 ${srFriendlyLabel(name)}`,
496
+ callback_data: `${MODEL_CALLBACK_SR}${name}`,
497
+ }])
498
+ }
499
+ rows.push([{ text: '◂ Back', callback_data: MODEL_CALLBACK_PAGE_MAIN }])
500
+ rows.push([{ text: '🔄 Refresh', callback_data: MODEL_CALLBACK_REFRESH }])
501
+ return rows
502
+ }
503
+
429
504
  /**
430
505
  * Build the `/model` dashboard: live model + quota brief + tap menu.
431
506
  * Returns a keyboard-less fallback (v1-shaped static text) when the
@@ -433,6 +508,7 @@ function menuKeyboard(
433
508
  */
434
509
  export async function buildModelMenu(
435
510
  deps: ModelMenuDeps & ModelCommandDeps,
511
+ page: ModelMenuPage = 'main',
436
512
  ): Promise<ModelMenuReply> {
437
513
  if (deps.isBusy()) return busyReply(deps)
438
514
 
@@ -460,7 +536,26 @@ export async function buildModelMenu(
460
536
  // sr-* models come from LiteLLM (/model/info via discoverSrModels), not the
461
537
  // claude picker — the CLI only knows Anthropic models.
462
538
  const { claude: claudeOptions } = classifyDiscoveredOptions(discovered.options)
463
- const srOptions: ModelPickerOption[] = srNames.map((name, i) => ({ index: i, label: name, detail: '', current: false }))
539
+ const externalNames = externalModelNames(srNames)
540
+
541
+ // External page: a focused list of the 🌐 (billed-separately) models with a
542
+ // Back button. It never switches the model itself — the page callbacks just
543
+ // re-render with the other page's keyboard.
544
+ if (page === 'external') {
545
+ const lines: string[] = [`**Model — ${deps.escapeHtml(deps.getAgentName())}** · 🌐 External`]
546
+ lines.push(
547
+ '',
548
+ 'These models are **billed separately** via OpenRouter — they do NOT use your Claude Max/Pro subscription. Tap one to switch the **live session**:',
549
+ PERSIST_NOTE,
550
+ )
551
+ return { text: lines.join('\n'), html: true, keyboard: externalPageKeyboard(externalNames) }
552
+ }
553
+
554
+ // claude's ✔ marks the DEFAULT FOR NEW SESSIONS, which is a different axis
555
+ // from the model the agent is running right now (set via --model at launch
556
+ // or a prior session switch). Labelling the ✔ row "Now:" was misleading —
557
+ // it could read "Opus 4.8" while the live session is on Fable. Call it what
558
+ // it is, and tell the operator a switch applies to the live session.
464
559
  const current = claudeOptions.find((o) => o.current)
465
560
  const lines: string[] = [`**Model — ${deps.escapeHtml(deps.getAgentName())}**`]
466
561
  if (discovered.dismissFailed) {
@@ -474,12 +569,16 @@ export async function buildModelMenu(
474
569
  }
475
570
  if (quota) lines.push(`Quota: ${deps.escapeHtml(quota)}`)
476
571
  lines.push('', 'Tap a model to switch the **live session**:')
477
- if (srOptions.length > 0) {
478
- lines.push('Claude models use your Max/Pro subscription. 🌐 models are billed separately via OpenRouter.')
572
+ if (externalNames.length > 0) {
573
+ lines.push('Claude models use your Max/Pro subscription. Tap 🌐 External models for models billed separately via OpenRouter.')
479
574
  }
480
575
  lines.push(PERSIST_NOTE)
481
576
 
482
- return { text: lines.join('\n'), html: true, keyboard: menuKeyboard(claudeOptions, srOptions) }
577
+ return {
578
+ text: lines.join('\n'),
579
+ html: true,
580
+ keyboard: mainPageKeyboard(claudeOptions, externalNames.length > 0),
581
+ }
483
582
  }
484
583
 
485
584
  export interface ModelCallbackOutcome {
@@ -519,6 +618,61 @@ export async function handleModelMenuCallback(
519
618
  return { answer: 'Refreshed', reply: await buildModelMenu(deps) }
520
619
  }
521
620
 
621
+ // Page navigation — these DO NOT switch the model. They just re-render the
622
+ // menu with the other page's keyboard + body text (mirrors the REFRESH shape).
623
+ if (data === MODEL_CALLBACK_PAGE_EXTERNAL) {
624
+ return { answer: 'External models', reply: await buildModelMenu(deps, 'external') }
625
+ }
626
+ if (data === MODEL_CALLBACK_PAGE_MAIN) {
627
+ return { answer: 'Back', reply: await buildModelMenu(deps, 'main') }
628
+ }
629
+
630
+ // Claude-alias tap (e.g. Fable): the CLI resolves the alias but its picker
631
+ // doesn't render it, so select by injecting `/model <alias>` — same path as
632
+ // the sr-* handler below, no cursor-nav.
633
+ if (data.startsWith(MODEL_CALLBACK_ALIAS)) {
634
+ const alias = data.slice(MODEL_CALLBACK_ALIAS.length)
635
+ if (!isValidModelArg(alias)) {
636
+ return { answer: 'Invalid model name', reply: await buildModelMenu(deps) }
637
+ }
638
+ if (deps.isBusy()) {
639
+ return {
640
+ answer: '⏳ Agent is mid-turn — tap again when it’s idle',
641
+ reply: busyReply(deps),
642
+ toastOnly: true,
643
+ }
644
+ }
645
+ let aliasResult: InjectResult
646
+ try {
647
+ aliasResult = await deps.inject(deps.getAgentName(), `/model ${alias}`)
648
+ } catch (err) {
649
+ const msg = err instanceof Error ? err.message : String(err)
650
+ return {
651
+ answer: 'Switch failed',
652
+ reply: await menuWithBanner(deps, `❌ Switch to **${deps.escapeHtml(alias)}** failed: ${deps.escapeHtml(msg)}`),
653
+ }
654
+ }
655
+ if (aliasResult.outcome === 'ok') {
656
+ const confirmation =
657
+ aliasResult.output
658
+ .split('\n')
659
+ .map((l) => l.trim())
660
+ .find((l) => /set model|switched/i.test(l)) ?? `Switched to ${alias} (session)`
661
+ return {
662
+ answer: confirmation,
663
+ reply: await menuWithBannerStatic(deps, `✅ ${deps.escapeHtml(confirmation)}`),
664
+ selectedModel: sessionModelFromConfirmation(confirmation) ?? alias,
665
+ }
666
+ }
667
+ return {
668
+ answer: 'Switch failed',
669
+ reply: await menuWithBanner(
670
+ deps,
671
+ `❌ Switch to **${deps.escapeHtml(alias)}** failed — agent may be mid-turn`,
672
+ ),
673
+ }
674
+ }
675
+
522
676
  if (data === MODEL_CALLBACK_HEADER) {
523
677
  // Section-header row — the gateway handles this with a direct answerCallbackQuery
524
678
  // before calling this function, so this branch is dead in practice. Guard
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Pure decision for what a turn_end does to the ending turn's delivery
3
+ * obligation (#2624).
4
+ *
5
+ * Two branches, both must be pinned by tests:
6
+ *
7
+ * - **Normal reply** — the turn either delivered a genuine final answer
8
+ * (`finalAnswerDelivered`) OR the model explicitly called reply
9
+ * (`replyCalled`, true even for a short LiteLLM sr-* `reply("OK",
10
+ * {disable_notification:true})` that `isFinalAnswerReply` demoted to
11
+ * non-final). Either way the obligation is satisfied → close it at
12
+ * turn_end.
13
+ *
14
+ * - **Ack-then-ghost / no-reply** — the turn ended with no reply at all
15
+ * (no ack, no answer). The obligation must NOT close at turn_end; it
16
+ * stays open and the idle sweep later ends it via the silence_fallback
17
+ * path. At turn_end we only stamp the grace clock (`note-ended`).
18
+ *
19
+ * Returns the action the caller applies to the ledger. Pure — no ledger
20
+ * mutation, no module state.
21
+ */
22
+ export function decideObligationTurnEnd(
23
+ finalAnswerDelivered: boolean,
24
+ replyCalled: boolean,
25
+ ): 'close' | 'note-ended' {
26
+ return finalAnswerDelivered || replyCalled ? 'close' : 'note-ended'
27
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Persistence store for in-flight permission card references.
3
+ *
4
+ * Problem: `pendingPermissions` is in-memory. When the gateway restarts
5
+ * (gateway crash OR container restart), all entries are lost. Old permission
6
+ * cards in Telegram keep their [Allow][Deny] inline keyboard buttons.
7
+ * When the operator taps one, they get STALE_TAP_NOTICE ("already resolved
8
+ * (timed out)"), which is misleading — the request wasn't resolved, it was
9
+ * lost. Operators interpret this as "my approval didn't work."
10
+ *
11
+ * Fix: persist the (chatId, messageId) references for every posted card to a
12
+ * JSON file in STATE_DIR. On each resolution (allow/deny/TTL-expire), remove
13
+ * the entry. On gateway boot, load any surviving entries (they were never
14
+ * resolved — the gateway crashed) and strip their inline keyboards + add a
15
+ * "gateway restarted" footer so the operator sees a clear explanation instead
16
+ * of a tappable-but-dead button.
17
+ *
18
+ * File format: JSON array of PersistedPermCard objects. Kept small (one file,
19
+ * written synchronously to avoid interleaving on concurrent card posts).
20
+ * Production rate: a few cards per permission request; file stays tiny.
21
+ */
22
+
23
+ import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
24
+ import { join } from 'node:path'
25
+
26
+ export interface PersistedPermCard {
27
+ requestId: string
28
+ chatId: string
29
+ messageId: number
30
+ startedAt: number
31
+ toolName: string
32
+ cardText: string
33
+ }
34
+
35
+ export interface PermissionCardStore {
36
+ /** Record a newly-posted card. Idempotent on requestId+messageId. */
37
+ add(entry: PersistedPermCard): void
38
+ /** Remove all entries for this request (resolved — allow, deny, TTL). */
39
+ remove(requestId: string): void
40
+ /** Return all persisted entries (for boot-time stale-strip sweep). */
41
+ loadAll(): PersistedPermCard[]
42
+ /** Delete the backing file entirely (after boot sweep completes). */
43
+ clear(): void
44
+ }
45
+
46
+ export function createPermissionCardStore(stateDir: string): PermissionCardStore {
47
+ const filePath = join(stateDir, 'pending-perm-cards.json')
48
+
49
+ function read(): PersistedPermCard[] {
50
+ try {
51
+ const raw = readFileSync(filePath, 'utf-8')
52
+ const parsed = JSON.parse(raw)
53
+ return Array.isArray(parsed) ? (parsed as PersistedPermCard[]) : []
54
+ } catch {
55
+ return []
56
+ }
57
+ }
58
+
59
+ function write(entries: PersistedPermCard[]): void {
60
+ try {
61
+ writeFileSync(filePath, JSON.stringify(entries), { encoding: 'utf-8', mode: 0o600 })
62
+ } catch (err) {
63
+ process.stderr.write(
64
+ `telegram gateway: permission-card-store write failed: ${(err as Error).message}\n`,
65
+ )
66
+ }
67
+ }
68
+
69
+ return {
70
+ add(entry) {
71
+ const entries = read()
72
+ // Replace existing entry for same (requestId, messageId) if present
73
+ const idx = entries.findIndex(
74
+ e => e.requestId === entry.requestId && e.messageId === entry.messageId,
75
+ )
76
+ if (idx >= 0) {
77
+ entries[idx] = entry
78
+ } else {
79
+ entries.push(entry)
80
+ }
81
+ write(entries)
82
+ },
83
+
84
+ remove(requestId) {
85
+ const entries = read()
86
+ const filtered = entries.filter(e => e.requestId !== requestId)
87
+ if (filtered.length !== entries.length) {
88
+ write(filtered)
89
+ }
90
+ },
91
+
92
+ loadAll() {
93
+ return read()
94
+ },
95
+
96
+ clear() {
97
+ try {
98
+ unlinkSync(filePath)
99
+ } catch {
100
+ // File may not exist — that's fine
101
+ }
102
+ },
103
+ }
104
+ }
@@ -71,17 +71,36 @@ export function isRecentTimeoutDuplicate(
71
71
 
72
72
  // ─── Bug 2 — per-tool TTL + timed-out card hygiene + stale-tap honesty ──────
73
73
 
74
- /** Default operator approval-card lifetime. */
75
- export const PERMISSION_TTL_MS = 10 * 60_000
74
+ /** Fallback operator approval-card lifetime when nothing is configured. */
75
+ export const PERMISSION_TTL_DEFAULT_MS = 60 * 60_000
76
+
77
+ /**
78
+ * Resolve the operator approval-card lifetime (ms) from the environment.
79
+ * Threaded from config as `channels.telegram.approval_timeout_minutes` →
80
+ * `SWITCHROOM_TG_APPROVAL_TIMEOUT_MS` (see scaffold.ts `channelsToEnv`). A
81
+ * blank/garbage value falls back to the 60-min default; `0` (or negative) is
82
+ * clamped to the default so a card can never be born already-expired.
83
+ */
84
+ export function approvalTtlMs(
85
+ env: Record<string, string | undefined> = process.env,
86
+ ): number {
87
+ const raw = env.SWITCHROOM_TG_APPROVAL_TIMEOUT_MS
88
+ if (raw === undefined || raw.trim() === '') return PERMISSION_TTL_DEFAULT_MS
89
+ const n = Number(raw)
90
+ if (!Number.isFinite(n) || n <= 0) return PERMISSION_TTL_DEFAULT_MS
91
+ return Math.floor(n)
92
+ }
93
+
94
+ /** Default operator approval-card lifetime (config-driven, 60 min default). */
95
+ export const PERMISSION_TTL_MS = approvalTtlMs()
76
96
 
77
97
  /**
78
98
  * hostd gated fleet-mutation verbs (rollout / update_apply / agent_* /
79
99
  * config_propose_edit) surface an OPERATOR approval card and demand a
80
- * human-scale decision window — 10 min is too tight when the operator is
81
- * mid-task. The `mcp__hostd__*` family gets 30 min; everything else keeps
82
- * the 10-min default.
100
+ * human-scale decision window. The `mcp__hostd__*` family gets at least
101
+ * 30 min; if the configured approval window is longer, it wins.
83
102
  */
84
- export const HOSTD_PERMISSION_TTL_MS = 30 * 60_000
103
+ export const HOSTD_PERMISSION_TTL_MS = Math.max(30 * 60_000, PERMISSION_TTL_MS)
85
104
 
86
105
  /** Per-tool approval-card TTL. */
87
106
  export function ttlForTool(toolName: string | undefined): number {