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.
- package/dist/agent-scheduler/index.js +101 -84
- package/dist/auth-broker/index.js +100 -83
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +102 -85
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +62124 -60406
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +1000 -256
- package/dist/vault/approvals/kernel-server.js +141 -121
- package/dist/vault/broker/server.js +163 -143
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +59 -3
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
- package/telegram-plugin/dist/server.js +180 -163
- package/telegram-plugin/format.ts +551 -20
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1524 -129
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +33 -2
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +79 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +121 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +31 -116
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +474 -17
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +289 -0
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
|
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 (
|
|
478
|
-
lines.push('Claude models use your Max/Pro subscription. 🌐 models
|
|
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 {
|
|
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
|
-
/**
|
|
75
|
-
export const
|
|
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
|
|
81
|
-
*
|
|
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 {
|