switchroom 0.16.28 → 0.16.38

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 (93) hide show
  1. package/dist/agent-scheduler/index.js +13 -2
  2. package/dist/auth-broker/index.js +13 -2
  3. package/dist/cli/notion-write-pretool.mjs +13 -2
  4. package/dist/cli/switchroom.js +60648 -59800
  5. package/dist/host-control/main.js +730 -18
  6. package/dist/vault/approvals/kernel-server.js +51 -37
  7. package/dist/vault/broker/server.js +72 -58
  8. package/package.json +2 -1
  9. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  10. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  11. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  12. package/telegram-plugin/card-format.ts +52 -2
  13. package/telegram-plugin/credits-watch.ts +4 -7
  14. package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
  15. package/telegram-plugin/dist/server.js +0 -1
  16. package/telegram-plugin/format.ts +264 -7
  17. package/telegram-plugin/gateway/approval-card.ts +7 -14
  18. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  19. package/telegram-plugin/gateway/auth-command.ts +35 -38
  20. package/telegram-plugin/gateway/boot-card.ts +7 -2
  21. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  22. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  23. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  24. package/telegram-plugin/gateway/gateway.ts +1293 -97
  25. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  27. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  28. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  29. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  30. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  31. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  32. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  33. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  34. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  35. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  36. package/telegram-plugin/history.ts +17 -7
  37. package/telegram-plugin/idle-footer.ts +2 -2
  38. package/telegram-plugin/issues-card.ts +12 -7
  39. package/telegram-plugin/model-unavailable.ts +3 -6
  40. package/telegram-plugin/operator-events.ts +4 -6
  41. package/telegram-plugin/quota-check.ts +2 -2
  42. package/telegram-plugin/quota-watch.ts +7 -10
  43. package/telegram-plugin/server.ts +3 -1
  44. package/telegram-plugin/status-pin-driver.ts +102 -0
  45. package/telegram-plugin/status-pin.ts +76 -0
  46. package/telegram-plugin/stream-reply-handler.ts +14 -1
  47. package/telegram-plugin/subagent-watcher.ts +6 -3
  48. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  49. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  51. package/telegram-plugin/tests/card-format.test.ts +51 -0
  52. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  53. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  54. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  55. package/telegram-plugin/tests/history.test.ts +38 -2
  56. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  57. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  58. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  59. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  60. package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
  61. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  62. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  63. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  64. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  65. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  66. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  68. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  69. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  70. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  71. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  72. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  73. package/telegram-plugin/tests/telegram-format.test.ts +88 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
  75. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  76. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  77. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  78. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  79. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  80. package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
  81. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  82. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  83. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  84. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  85. package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
  86. package/telegram-plugin/tool-activity-summary.ts +11 -8
  87. package/telegram-plugin/uat/driver.ts +8 -1
  88. package/telegram-plugin/voice-normalize-text.ts +340 -0
  89. package/telegram-plugin/voice-ondemand.ts +138 -0
  90. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  91. package/telegram-plugin/voice-synthesize.ts +128 -0
  92. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  93. package/telegram-plugin/worker-activity-feed.ts +13 -4
@@ -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`
@@ -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 {