switchroom 0.17.6 → 0.18.3
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/bin/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +65 -5
- package/dist/auth-broker/index.js +6623 -514
- package/dist/cli/notion-write-pretool.mjs +64 -4
- package/dist/cli/switchroom.js +1888 -1162
- package/dist/host-control/main.js +6306 -162
- package/dist/vault/approvals/kernel-server.js +6014 -202
- package/dist/vault/broker/server.js +6741 -940
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +218 -25
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +116 -0
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/auth-snapshot-format.ts +22 -24
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +369 -0
- package/telegram-plugin/gateway/gateway.ts +1861 -172
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +135 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +217 -10
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Missed-approvals re-offer digest card (#2862).
|
|
3
|
+
*
|
|
4
|
+
* Pure builders — card text, inline keyboard, callback shape, and the
|
|
5
|
+
* synthetic inbound the gateway injects on Retry — kept out of gateway.ts so
|
|
6
|
+
* they're pinned by tests independent of the bot plumbing (mirrors
|
|
7
|
+
* `skill-proposal-card.ts`).
|
|
8
|
+
*
|
|
9
|
+
* The digest lists the approvals that TTL-expired while the operator was away
|
|
10
|
+
* and offers two taps:
|
|
11
|
+
* • Ask agent to retry → inject a synthetic inbound asking the live agent to
|
|
12
|
+
* re-attempt the actions. This rides the SAME `inject_inbound` /
|
|
13
|
+
* synthesized-inbound path cron uses (claude-native): the agent starts a
|
|
14
|
+
* fresh turn and naturally re-raises a new approval card. It is a QUESTION
|
|
15
|
+
* to the agent — never a re-execution, never a synthesized verdict
|
|
16
|
+
* (no-self-escalation intact).
|
|
17
|
+
* • Dismiss → clear the list and edit the card closed.
|
|
18
|
+
*
|
|
19
|
+
* callback_data shape (fits Telegram's 64-byte limit):
|
|
20
|
+
* missre:retry:<digestId>
|
|
21
|
+
* missre:dismiss:<digestId>
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
25
|
+
import type { MissedApproval } from './missed-approvals-store.js'
|
|
26
|
+
|
|
27
|
+
export const MISSED_APPROVAL_CALLBACK_PREFIX = 'missre:'
|
|
28
|
+
/** How many entries to list in the card before collapsing to "…and N more". */
|
|
29
|
+
export const MISSED_APPROVAL_RENDER_CAP = 10
|
|
30
|
+
|
|
31
|
+
function escapeHtml(s: string): string {
|
|
32
|
+
return s
|
|
33
|
+
.replace(/&/g, '&')
|
|
34
|
+
.replace(/</g, '<')
|
|
35
|
+
.replace(/>/g, '>')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Compact HH:MM (UTC) stamp for a miss. */
|
|
39
|
+
function fmtTime(ms: number): string {
|
|
40
|
+
const d = new Date(ms)
|
|
41
|
+
const hh = String(d.getUTCHours()).padStart(2, '0')
|
|
42
|
+
const mm = String(d.getUTCMinutes()).padStart(2, '0')
|
|
43
|
+
return `${hh}:${mm}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Render the digest card text (HTML parse mode). Lists up to
|
|
48
|
+
* `cap` entries, then a "…and N more" overflow line.
|
|
49
|
+
*/
|
|
50
|
+
export function renderMissedApprovalsDigest(
|
|
51
|
+
entries: MissedApproval[],
|
|
52
|
+
opts?: { cap?: number; agentName?: string | null },
|
|
53
|
+
): string {
|
|
54
|
+
const cap = opts?.cap ?? MISSED_APPROVAL_RENDER_CAP
|
|
55
|
+
const who = opts?.agentName ? escapeHtml(opts.agentName) : 'the agent'
|
|
56
|
+
const shown = entries.slice(0, cap)
|
|
57
|
+
const overflow = entries.length - shown.length
|
|
58
|
+
|
|
59
|
+
const lines: string[] = []
|
|
60
|
+
lines.push('⏳ <b>Missed approvals while you were away</b>')
|
|
61
|
+
lines.push('')
|
|
62
|
+
lines.push('These approval requests timed out before you could respond:')
|
|
63
|
+
for (const e of shown) {
|
|
64
|
+
lines.push(`• ${escapeHtml(e.action)} <i>(${fmtTime(e.timedOutAt)} UTC)</i>`)
|
|
65
|
+
}
|
|
66
|
+
if (overflow > 0) {
|
|
67
|
+
lines.push(`…and ${overflow} more`)
|
|
68
|
+
}
|
|
69
|
+
lines.push('')
|
|
70
|
+
lines.push(
|
|
71
|
+
`<i>Tap “Ask agent to retry” to have ${who} re-attempt them — you’ll be ` +
|
|
72
|
+
'asked to approve again as normal. Dismiss to clear.</i>',
|
|
73
|
+
)
|
|
74
|
+
return lines.join('\n')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Inline keyboard for the digest card. */
|
|
78
|
+
export function missedApprovalsKeyboard(digestId: string): {
|
|
79
|
+
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
|
|
80
|
+
} {
|
|
81
|
+
return {
|
|
82
|
+
inline_keyboard: [
|
|
83
|
+
[
|
|
84
|
+
{
|
|
85
|
+
text: '🔁 Ask agent to retry',
|
|
86
|
+
callback_data: `${MISSED_APPROVAL_CALLBACK_PREFIX}retry:${digestId}`,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
text: '🚫 Dismiss',
|
|
90
|
+
callback_data: `${MISSED_APPROVAL_CALLBACK_PREFIX}dismiss:${digestId}`,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
],
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Parse a `missre:` callback into { action, digestId }, or null if not ours. */
|
|
98
|
+
export function parseMissedApprovalCallback(
|
|
99
|
+
data: string,
|
|
100
|
+
): { action: 'retry' | 'dismiss'; digestId: string } | null {
|
|
101
|
+
if (!data.startsWith(MISSED_APPROVAL_CALLBACK_PREFIX)) return null
|
|
102
|
+
const rest = data.slice(MISSED_APPROVAL_CALLBACK_PREFIX.length)
|
|
103
|
+
const idx = rest.indexOf(':')
|
|
104
|
+
if (idx < 0) return null
|
|
105
|
+
const action = rest.slice(0, idx)
|
|
106
|
+
const digestId = rest.slice(idx + 1)
|
|
107
|
+
if ((action !== 'retry' && action !== 'dismiss') || digestId.length === 0) return null
|
|
108
|
+
return { action, digestId }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface MissedApprovalInboundContext {
|
|
112
|
+
agent: string
|
|
113
|
+
chat_id: string
|
|
114
|
+
threadId?: number
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build the synthetic inbound injected when the operator taps "Ask agent to
|
|
119
|
+
* retry". It ASKS the agent to re-attempt the listed actions — it never
|
|
120
|
+
* re-executes them and never carries a verdict (no-self-escalation). The
|
|
121
|
+
* agent starts a fresh turn and naturally re-raises approval cards.
|
|
122
|
+
*
|
|
123
|
+
* `meta.source = "missed_approval_retry"` so the bridge renders it as
|
|
124
|
+
* `<channel source="missed_approval_retry">`.
|
|
125
|
+
*/
|
|
126
|
+
export function buildMissedApprovalRetryInbound(opts: {
|
|
127
|
+
ctx: MissedApprovalInboundContext
|
|
128
|
+
actions: string[]
|
|
129
|
+
operatorId: string
|
|
130
|
+
nowMs?: number
|
|
131
|
+
}): InboundMessage {
|
|
132
|
+
const ts = opts.nowMs ?? Date.now()
|
|
133
|
+
const list =
|
|
134
|
+
opts.actions.length === 1
|
|
135
|
+
? opts.actions[0]
|
|
136
|
+
: opts.actions.map(a => `\n• ${a}`).join('')
|
|
137
|
+
const body =
|
|
138
|
+
opts.actions.length === 1
|
|
139
|
+
? `Please re-attempt it now: ${list}.`
|
|
140
|
+
: `Please re-attempt them now:${list}`
|
|
141
|
+
return {
|
|
142
|
+
type: 'inbound',
|
|
143
|
+
chatId: opts.ctx.chat_id,
|
|
144
|
+
...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
|
|
145
|
+
messageId: ts,
|
|
146
|
+
user: 'missed-approvals',
|
|
147
|
+
userId: 0,
|
|
148
|
+
ts,
|
|
149
|
+
text:
|
|
150
|
+
`🔁 Operator asked you to retry ${opts.actions.length === 1 ? 'an action' : 'some actions'} ` +
|
|
151
|
+
`that timed out earlier while they were away. ${body} You'll be asked to ` +
|
|
152
|
+
`approve again as normal — do not assume the earlier timeout was a denial.`,
|
|
153
|
+
meta: {
|
|
154
|
+
source: 'missed_approval_retry',
|
|
155
|
+
agent: opts.ctx.agent,
|
|
156
|
+
...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
|
|
157
|
+
operator_id: opts.operatorId,
|
|
158
|
+
action_count: String(opts.actions.length),
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence store for approvals that TTL-expired while the operator was
|
|
3
|
+
* away (#2862, "approval cards don't stick — re-offer digest").
|
|
4
|
+
*
|
|
5
|
+
* Problem: when a permission card auto-denies on TTL (the #2411 timeout path)
|
|
6
|
+
* with the operator absent, the gated work is silently dropped and NOTHING
|
|
7
|
+
* re-offers it when the operator returns. For cron-fired work (marko's brevo
|
|
8
|
+
* nurture-drip batches) the batch just doesn't go out and the operator finds
|
|
9
|
+
* out a day later.
|
|
10
|
+
*
|
|
11
|
+
* Fix: at TTL auto-deny the gateway appends the missed request to this store
|
|
12
|
+
* (persisted so a gateway restart — which happens ~14× on a fleet-roll day —
|
|
13
|
+
* doesn't lose the digest). On the FIRST operator-activity event afterwards,
|
|
14
|
+
* the gateway posts ONE compact digest card ("While you were away these
|
|
15
|
+
* approvals timed out: … [Ask agent to retry] [Dismiss]") to the origin
|
|
16
|
+
* surface, then promotes the pending entries into a delivered-digest record
|
|
17
|
+
* (the posted card is now the durable record in chat). Retry synthesizes an
|
|
18
|
+
* inbound asking the agent to re-attempt the actions; Dismiss clears the
|
|
19
|
+
* record and edits the card closed.
|
|
20
|
+
*
|
|
21
|
+
* File format: a single JSON object `{ pending, delivered }`, written
|
|
22
|
+
* synchronously to avoid interleaving on concurrent auto-denies, mode 0o600.
|
|
23
|
+
* Mirrors the `permission-card-store.ts` pattern. Both lists are hard-capped
|
|
24
|
+
* so the file stays tiny under a runaway loop.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
28
|
+
import { join } from 'node:path'
|
|
29
|
+
|
|
30
|
+
export interface MissedApproval {
|
|
31
|
+
/** The permission request_id that timed out (dedup key). */
|
|
32
|
+
requestId: string
|
|
33
|
+
/** Raw claude tool name, e.g. `mcp__brevo__post`. */
|
|
34
|
+
toolName: string
|
|
35
|
+
/** Natural-language action (from `naturalAction()`), e.g. "post to Brevo". */
|
|
36
|
+
action: string
|
|
37
|
+
/** Origin surface the card was posted to. */
|
|
38
|
+
chatId: string
|
|
39
|
+
threadId?: number | null
|
|
40
|
+
/** When the TTL auto-deny fired (epoch ms). */
|
|
41
|
+
timedOutAt: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DeliveredDigest {
|
|
45
|
+
/** Short id embedded in the card's callback_data (retry/dismiss routing). */
|
|
46
|
+
digestId: string
|
|
47
|
+
/** Origin surface the digest card was posted to. */
|
|
48
|
+
chatId: string
|
|
49
|
+
threadId?: number | null
|
|
50
|
+
/** The missed entries this digest covers (for the Retry synthesis). */
|
|
51
|
+
entries: MissedApproval[]
|
|
52
|
+
deliveredAt: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface FileShape {
|
|
56
|
+
pending: MissedApproval[]
|
|
57
|
+
delivered: DeliveredDigest[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface MissedApprovalsStore {
|
|
61
|
+
/** Record a newly-missed approval. Idempotent on requestId. */
|
|
62
|
+
add(entry: MissedApproval): void
|
|
63
|
+
/** Undelivered misses, oldest first. */
|
|
64
|
+
listPending(): MissedApproval[]
|
|
65
|
+
pendingCount(): number
|
|
66
|
+
/**
|
|
67
|
+
* Move a set of pending entries into a delivered-digest record. The entries
|
|
68
|
+
* carried on `digest` are removed from `pending` (matched by requestId).
|
|
69
|
+
*/
|
|
70
|
+
promoteToDigest(digest: DeliveredDigest): void
|
|
71
|
+
/** Look up a delivered digest by its id (for a retry/dismiss tap). */
|
|
72
|
+
getDigest(digestId: string): DeliveredDigest | undefined
|
|
73
|
+
/** Drop a delivered digest (retry consumed it, or dismiss cleared it). */
|
|
74
|
+
removeDigest(digestId: string): void
|
|
75
|
+
/** Delete the backing file entirely. */
|
|
76
|
+
clear(): void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Bound the on-disk file even under a runaway auto-deny loop. */
|
|
80
|
+
export const MAX_PENDING = 50
|
|
81
|
+
export const MAX_DELIVERED = 20
|
|
82
|
+
|
|
83
|
+
export function createMissedApprovalsStore(stateDir: string): MissedApprovalsStore {
|
|
84
|
+
const filePath = join(stateDir, 'missed-approvals.json')
|
|
85
|
+
|
|
86
|
+
function read(): FileShape {
|
|
87
|
+
try {
|
|
88
|
+
const raw = readFileSync(filePath, 'utf-8')
|
|
89
|
+
const parsed = JSON.parse(raw) as Partial<FileShape>
|
|
90
|
+
return {
|
|
91
|
+
pending: Array.isArray(parsed?.pending) ? parsed.pending : [],
|
|
92
|
+
delivered: Array.isArray(parsed?.delivered) ? parsed.delivered : [],
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
return { pending: [], delivered: [] }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function write(f: FileShape): void {
|
|
100
|
+
try {
|
|
101
|
+
writeFileSync(filePath, JSON.stringify(f), { encoding: 'utf-8', mode: 0o600 })
|
|
102
|
+
} catch (err) {
|
|
103
|
+
process.stderr.write(
|
|
104
|
+
`telegram gateway: missed-approvals-store write failed: ${(err as Error).message}\n`,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
add(entry) {
|
|
111
|
+
const f = read()
|
|
112
|
+
const idx = f.pending.findIndex(e => e.requestId === entry.requestId)
|
|
113
|
+
if (idx >= 0) {
|
|
114
|
+
f.pending[idx] = entry
|
|
115
|
+
} else {
|
|
116
|
+
f.pending.push(entry)
|
|
117
|
+
}
|
|
118
|
+
// Keep only the most-recent MAX_PENDING (drop oldest by insertion order).
|
|
119
|
+
if (f.pending.length > MAX_PENDING) {
|
|
120
|
+
f.pending = f.pending.slice(f.pending.length - MAX_PENDING)
|
|
121
|
+
}
|
|
122
|
+
write(f)
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
listPending() {
|
|
126
|
+
return read().pending
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
pendingCount() {
|
|
130
|
+
return read().pending.length
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
promoteToDigest(digest) {
|
|
134
|
+
const f = read()
|
|
135
|
+
const ids = new Set(digest.entries.map(e => e.requestId))
|
|
136
|
+
f.pending = f.pending.filter(e => !ids.has(e.requestId))
|
|
137
|
+
// Replace any existing record with the same id, then append.
|
|
138
|
+
f.delivered = f.delivered.filter(d => d.digestId !== digest.digestId)
|
|
139
|
+
f.delivered.push(digest)
|
|
140
|
+
if (f.delivered.length > MAX_DELIVERED) {
|
|
141
|
+
f.delivered = f.delivered.slice(f.delivered.length - MAX_DELIVERED)
|
|
142
|
+
}
|
|
143
|
+
write(f)
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
getDigest(digestId) {
|
|
147
|
+
return read().delivered.find(d => d.digestId === digestId)
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
removeDigest(digestId) {
|
|
151
|
+
const f = read()
|
|
152
|
+
const filtered = f.delivered.filter(d => d.digestId !== digestId)
|
|
153
|
+
if (filtered.length !== f.delivered.length) {
|
|
154
|
+
f.delivered = filtered
|
|
155
|
+
write(f)
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
clear() {
|
|
160
|
+
try {
|
|
161
|
+
unlinkSync(filePath)
|
|
162
|
+
} catch {
|
|
163
|
+
// File may not exist — fine.
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -52,11 +52,14 @@ export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'fable', 'default'] as
|
|
|
52
52
|
* Shape gate for the model argument. This string is typed literally
|
|
53
53
|
* into the agent's tmux pane, so the gate is strict by construction:
|
|
54
54
|
* one token, alphanumeric start, then alphanumerics plus the chars
|
|
55
|
-
* that appear in real model ids (`.` `_` `-` and the `[1m]`-style
|
|
56
|
-
* variant brackets).
|
|
55
|
+
* that appear in real model ids (`.` `_` `-` `/` and the `[1m]`-style
|
|
56
|
+
* variant brackets). `/` is allowed for OpenRouter-style
|
|
57
|
+
* `sr-vendor/model` ids; it is not a shell metachar inside the
|
|
58
|
+
* double-quoted `claude --model "$_EFFECTIVE_MODEL"` usage, so it can't
|
|
59
|
+
* break the launch. No whitespace means no second token can ride
|
|
57
60
|
* along; no control characters means no newline/Enter smuggling.
|
|
58
61
|
*/
|
|
59
|
-
const MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._
|
|
62
|
+
const MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._/\[\]-]{0,99}$/
|
|
60
63
|
|
|
61
64
|
export function isValidModelArg(arg: string): boolean {
|
|
62
65
|
return MODEL_ARG_RE.test(arg)
|
|
@@ -133,6 +136,18 @@ export interface ModelCommandDeps {
|
|
|
133
136
|
* mechanism as the `/restart` command (hostd-first, SIGTERM fallback).
|
|
134
137
|
*/
|
|
135
138
|
scheduleRestart: (reason: string) => Promise<void>
|
|
139
|
+
/**
|
|
140
|
+
* Schedule a session-only switch TO a non-Claude (`sr-*` LiteLLM/OpenRouter)
|
|
141
|
+
* model. claude's in-REPL `/model` picker rejects unknown `sr-*` ids, so an
|
|
142
|
+
* inject can't set them. Instead the gateway writes the chosen token to the
|
|
143
|
+
* `.session-model-override` carrier file and gracefully restarts the agent;
|
|
144
|
+
* the next boot launches `claude --model <token>` directly (LiteLLM routes
|
|
145
|
+
* it, no picker validation). Session-only: reverts to the configured default
|
|
146
|
+
* on the following restart. Wired to the same restart dispatch as
|
|
147
|
+
* `scheduleRestart`, plus the carrier write. `model` is the full `sr-*` id
|
|
148
|
+
* (already alias-expanded); `reason` is stamped as the restart reason.
|
|
149
|
+
*/
|
|
150
|
+
scheduleModelRelaunch: (model: string, reason: string) => Promise<void>
|
|
136
151
|
}
|
|
137
152
|
|
|
138
153
|
export interface ModelCommandReply {
|
|
@@ -152,6 +167,7 @@ function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
|
|
|
152
167
|
'\`/model\` — show the configured model',
|
|
153
168
|
`\`/model <name>\` — switch the live session (${MODEL_ALIASES.map(a => `\`${a}\``).join(' · ')} or a full model id)`,
|
|
154
169
|
`_OpenRouter shortcuts:_ ${srAliasExamples}`,
|
|
170
|
+
'_OpenRouter (sr-\\*) switches restart the session (~30s); Claude switches apply instantly._',
|
|
155
171
|
PERSIST_NOTE,
|
|
156
172
|
)
|
|
157
173
|
return { text: lines.join('\n'), html: true }
|
|
@@ -214,6 +230,30 @@ export async function handleModelCommand(
|
|
|
214
230
|
}
|
|
215
231
|
}
|
|
216
232
|
|
|
233
|
+
// Claude → sr-*: an in-place inject can't set a non-Anthropic model — claude's
|
|
234
|
+
// native `/model` picker rejects the unknown `sr-*` id ("Model not found").
|
|
235
|
+
// Carry the token across a graceful restart and relaunch `claude --model
|
|
236
|
+
// sr-*` directly (LiteLLM routes it). Session-only: reverts to the configured
|
|
237
|
+
// default on the next restart. The sr-* → Claude direction is handled above.
|
|
238
|
+
if (isSrModel(model)) {
|
|
239
|
+
try {
|
|
240
|
+
await deps.scheduleModelRelaunch(model, `user: /model ${model} (session-only relaunch)`)
|
|
241
|
+
} catch (err) {
|
|
242
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
243
|
+
return {
|
|
244
|
+
text: `❌ Could not schedule model switch: ${deps.escapeHtml(msg)}`,
|
|
245
|
+
html: true,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
text: [
|
|
250
|
+
`Switching to \`${deps.escapeHtml(model)}\` — restarting session (~30s).`,
|
|
251
|
+
'_Session-only — reverts to the configured default on the next restart._',
|
|
252
|
+
].join('\n'),
|
|
253
|
+
html: true,
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
217
257
|
const verbHtml = `\`/model ${deps.escapeHtml(model)}\``
|
|
218
258
|
let result: InjectResult
|
|
219
259
|
try {
|
|
@@ -358,16 +398,31 @@ export const EXTRA_CLAUDE_ALIASES: ReadonlyArray<{ alias: string; label: string
|
|
|
358
398
|
* Friendly display names for sr-* synthetic model names. An sr-* model in
|
|
359
399
|
* LiteLLM has no entry in `model_group_settings.*.forward_client_headers_to_llm_api`
|
|
360
400
|
* so the Anthropic OAuth credential is NEVER forwarded — safe to route to
|
|
361
|
-
* OpenRouter. Names here are display-only
|
|
362
|
-
*
|
|
401
|
+
* OpenRouter. Names here are display-only (used by srFriendlyLabel for /status
|
|
402
|
+
* and switch confirmations); the raw `sr-*` id is what gets injected into the
|
|
403
|
+
* agent's session. This table is a SUPERSET of the menu-reachable set
|
|
404
|
+
* (SR_MODEL_ALIASES): it also labels models that are only reachable by typing
|
|
405
|
+
* the full `/model sr-<name>` (manual passthrough), so those still get a
|
|
406
|
+
* friendly name without appearing as a keyboard button.
|
|
407
|
+
* See reference/rfcs/litellm-max-subscription-invariants.md § I6.
|
|
363
408
|
*/
|
|
364
409
|
export const SR_MODEL_LABELS: Record<string, string> = {
|
|
365
410
|
'sr-gemini-2.5-pro': 'Gemini 2.5 Pro',
|
|
366
411
|
'sr-gemini-2.5-flash': 'Gemini 2.5 Flash',
|
|
367
412
|
'sr-deepseek-r1': 'DeepSeek R1',
|
|
368
413
|
'sr-deepseek-v3': 'DeepSeek V3',
|
|
369
|
-
|
|
414
|
+
// sr-glm-5 now targets glm-5.2 in the live litellm config — label bumped to match.
|
|
415
|
+
'sr-glm-5': 'GLM-5.2',
|
|
370
416
|
'sr-codex-5.5': 'Codex 5.5',
|
|
417
|
+
// OpenRouter coverage (pairs with the live litellm sr-* model_name additions).
|
|
418
|
+
'sr-gpt-oss-20b': 'GPT-OSS 20B',
|
|
419
|
+
'sr-gpt-oss-120b': 'GPT-OSS 120B',
|
|
420
|
+
'sr-gpt-5.5': 'GPT-5.5',
|
|
421
|
+
'sr-gpt-5-codex': 'GPT-5 Codex',
|
|
422
|
+
'sr-gpt-5.2-codex': 'GPT-5.2 Codex',
|
|
423
|
+
'sr-gemini-flash-lite': 'Gemini 3.1 Flash Lite',
|
|
424
|
+
'sr-minimax-m3': 'MiniMax M3',
|
|
425
|
+
'sr-deepseek-v4-flash': 'DeepSeek V4 Flash',
|
|
371
426
|
}
|
|
372
427
|
|
|
373
428
|
/**
|
|
@@ -448,10 +503,15 @@ function headerRow(label: string): ModelMenuKeyboardButton[] {
|
|
|
448
503
|
* requires ANTHROPIC_CUSTOM_HEADERS (a litellm key) to be set on the gateway
|
|
449
504
|
* process. switchroom never sets that env on the gateway, so in production
|
|
450
505
|
* discoverSrModels() always returns [] and the external group was silently
|
|
451
|
-
* empty. The six SR_MODEL_ALIASES targets are the sr-*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
506
|
+
* empty. The six SR_MODEL_ALIASES targets are the CURATED main sr-* set, so
|
|
507
|
+
* seeding from them makes the group reliable without the missing env — while
|
|
508
|
+
* still merging any live results on hosts that do configure discovery.
|
|
509
|
+
*
|
|
510
|
+
* Deliberately a SUBSET: the litellm config exposes more sr-* models than this
|
|
511
|
+
* (the full OpenRouter catalogue). Those extras are display-labelled in
|
|
512
|
+
* SR_MODEL_LABELS and remain typeable via `/model <full-sr-name>` (manual
|
|
513
|
+
* passthrough — the set path is a shape gate, no whitelist), but they are kept
|
|
514
|
+
* OUT of SR_MODEL_ALIASES on purpose so the keyboard stays small and curated.
|
|
455
515
|
*
|
|
456
516
|
* Subscription-honest: ONLY the curated sr-* aliases surface as buttons. Raw
|
|
457
517
|
* gpt-4o / openrouter/* dupes / voyage-* embeddings never do.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decision helpers for permission-card re-arm across gateway restarts
|
|
3
|
+
* (#2861 / umbrella #2859).
|
|
4
|
+
*
|
|
5
|
+
* gateway.ts has top-level side effects and can't be unit-imported, so the
|
|
6
|
+
* discriminable logic lives here where it can be tested in isolation. The
|
|
7
|
+
* gateway wires these into `onPermissionRequest` and the boot-sweep.
|
|
8
|
+
*
|
|
9
|
+
* The mechanism, end to end:
|
|
10
|
+
*
|
|
11
|
+
* 1. The bridge keeps an outstanding-request ledger and re-sends every
|
|
12
|
+
* unresolved `permission_request` to the gateway on each IPC (re)connect
|
|
13
|
+
* (see `bridge/permission-ledger.ts`). So the gateway MUST treat repeat
|
|
14
|
+
* `permission_request`s for the same request_id as idempotent.
|
|
15
|
+
*
|
|
16
|
+
* 2. `classifyPermissionRequest` decides what a `permission_request` is:
|
|
17
|
+
* - `duplicate` — already live in `pendingPermissions` → ignore (no
|
|
18
|
+
* second card). The bridge re-sends on every reconnect.
|
|
19
|
+
* - `rearm` — not in memory, but the persisted card store still has
|
|
20
|
+
* entries for it → a gateway restart orphaned it. Restore the
|
|
21
|
+
* pending entry with the ORIGINAL `startedAt` (the TTL clock keeps
|
|
22
|
+
* running from the original ask; an already-expired re-arm falls
|
|
23
|
+
* straight into the existing TTL auto-deny sweep, which correctly
|
|
24
|
+
* unwedges claude) and re-attach the keyboard on the EXISTING
|
|
25
|
+
* message(s) via editMessageText — never a new card.
|
|
26
|
+
* - `fresh` — a brand-new ask; post a card as usual.
|
|
27
|
+
*
|
|
28
|
+
* 3. The boot-sweep becomes a grace-period strip. At boot, instead of
|
|
29
|
+
* stripping persisted cards immediately, wait ~90 s, then strip only the
|
|
30
|
+
* cards whose request_id is NOT currently live in `pendingPermissions`.
|
|
31
|
+
* A live entry means either a bridge re-send re-armed the card, OR a fresh
|
|
32
|
+
* approval was posted during the window — both are legitimately-pending
|
|
33
|
+
* asks. Only a genuinely dead session's card (container recreate → new
|
|
34
|
+
* bridge, empty ledger, never re-sends → no live entry) gets today's
|
|
35
|
+
* "Gateway restarted" strip.
|
|
36
|
+
*
|
|
37
|
+
* NEVER auto-allows anything: re-arm restores only the QUESTION, never a
|
|
38
|
+
* verdict (no-self-escalation invariant). No model callsite is involved —
|
|
39
|
+
* verdicts still travel only over the existing IPC/MCP channel.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import type { PersistedPermCard } from './permission-card-store.js'
|
|
43
|
+
|
|
44
|
+
/** Default grace window before the boot-sweep strips an un-re-claimed card. */
|
|
45
|
+
export const PERMISSION_REARM_GRACE_MS = 90_000
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Kill switch. `SWITCHROOM_PERMISSION_REARM=0` reverts to the legacy
|
|
49
|
+
* immediate-strip (gateway) / drop-when-disconnected (bridge) behavior.
|
|
50
|
+
*/
|
|
51
|
+
export function isPermissionRearmEnabled(
|
|
52
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
53
|
+
): boolean {
|
|
54
|
+
return env.SWITCHROOM_PERMISSION_REARM !== '0'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the boot-sweep grace window (ms). `SWITCHROOM_PERMISSION_REARM_GRACE_MS`
|
|
59
|
+
* overrides the default (used by tests to collapse the wait). A non-finite or
|
|
60
|
+
* negative override falls back to the default.
|
|
61
|
+
*/
|
|
62
|
+
export function permissionRearmGraceMs(
|
|
63
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
64
|
+
): number {
|
|
65
|
+
const raw = env.SWITCHROOM_PERMISSION_REARM_GRACE_MS
|
|
66
|
+
if (raw == null || raw === '') return PERMISSION_REARM_GRACE_MS
|
|
67
|
+
const n = Number(raw)
|
|
68
|
+
return Number.isFinite(n) && n >= 0 ? n : PERMISSION_REARM_GRACE_MS
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type PermissionRequestDisposition = 'duplicate' | 'rearm' | 'fresh'
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Classify an incoming `permission_request` (pure).
|
|
75
|
+
*
|
|
76
|
+
* @param hasPending is the request_id already live in `pendingPermissions`?
|
|
77
|
+
* @param persistedCount how many persisted card entries exist for this request_id?
|
|
78
|
+
*/
|
|
79
|
+
export function classifyPermissionRequest(opts: {
|
|
80
|
+
hasPending: boolean
|
|
81
|
+
persistedCount: number
|
|
82
|
+
}): PermissionRequestDisposition {
|
|
83
|
+
if (opts.hasPending) return 'duplicate'
|
|
84
|
+
if (opts.persistedCount > 0) return 'rearm'
|
|
85
|
+
return 'fresh'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Which persisted cards should the boot-sweep strip at the grace deadline?
|
|
90
|
+
* Everything whose request_id is NOT currently live.
|
|
91
|
+
*
|
|
92
|
+
* `liveRequestIds` is the set of request_ids present in `pendingPermissions`
|
|
93
|
+
* at the deadline — which covers BOTH:
|
|
94
|
+
* - re-armed cards (a bridge re-send restored the pending entry), and
|
|
95
|
+
* - fresh cards born during the grace window (a new approval posted after
|
|
96
|
+
* boot sets a pending entry the normal way).
|
|
97
|
+
* Both are legitimately-pending asks a claude session is still suspended on;
|
|
98
|
+
* stripping either would re-wedge the turn. Only a genuinely dead session's
|
|
99
|
+
* card — persisted but with no live pending entry (the bridge never
|
|
100
|
+
* reconnected to re-arm it) — is stripped. Keying on liveness rather than a
|
|
101
|
+
* "was re-claimed" flag is what makes fresh-during-grace cards safe.
|
|
102
|
+
*/
|
|
103
|
+
export function computeBootSweepStripTargets(
|
|
104
|
+
stale: readonly PersistedPermCard[],
|
|
105
|
+
liveRequestIds: ReadonlySet<string>,
|
|
106
|
+
): PersistedPermCard[] {
|
|
107
|
+
return stale.filter(card => !liveRequestIds.has(card.requestId))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Distinct request_ids in a persisted-card list (for per-request store removal). */
|
|
111
|
+
export function distinctRequestIds(
|
|
112
|
+
cards: readonly PersistedPermCard[],
|
|
113
|
+
): string[] {
|
|
114
|
+
return [...new Set(cards.map(c => c.requestId))]
|
|
115
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence for the gateway's 30-min scoped-approval windows
|
|
3
|
+
* (`scopedGrants: ScopedGrantStore`, see ../scoped-approval.ts).
|
|
4
|
+
*
|
|
5
|
+
* Problem: the store is in-memory. Any gateway restart (fleet roll, config
|
|
6
|
+
* apply, failover probe — marko saw ~14 boots on 2026-07-05) wipes it, so an
|
|
7
|
+
* action the operator allowed with a 30-min scope minutes ago re-cards
|
|
8
|
+
* immediately. It reads as "my approval didn't stick."
|
|
9
|
+
*
|
|
10
|
+
* Fix: mirror the store to a tiny JSON file in STATE_DIR (same shape as
|
|
11
|
+
* permission-card-store.ts — synchronous writes, mode 0o600, one small file).
|
|
12
|
+
* Write-through on every grant and on sweep-expiry removal; reload at boot,
|
|
13
|
+
* dropping entries already past their ABSOLUTE expiry.
|
|
14
|
+
*
|
|
15
|
+
* This only widens WHEN a still-valid window survives a restart — never WHAT
|
|
16
|
+
* qualifies. Expiry is absolute wall-clock, captured at the original tap, so a
|
|
17
|
+
* restart can never extend a window. All fail-closed semantics
|
|
18
|
+
* (destructive-Bash never scoped, expiry lookup fails closed) live in
|
|
19
|
+
* scoped-approval.ts and are untouched — a reloaded grant runs the identical
|
|
20
|
+
* `lookupScopedGrant` gate as an in-memory one.
|
|
21
|
+
*
|
|
22
|
+
* Kill switch: SWITCHROOM_SCOPED_GRANT_PERSIST=0 → boot with an empty store
|
|
23
|
+
* and never write (any pre-existing file is ignored).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
27
|
+
import { join } from 'node:path'
|
|
28
|
+
import {
|
|
29
|
+
serializeScopedGrants,
|
|
30
|
+
deserializeScopedGrants,
|
|
31
|
+
type ScopedGrantStore,
|
|
32
|
+
} from '../scoped-approval.js'
|
|
33
|
+
|
|
34
|
+
export interface ScopedGrantPersistence {
|
|
35
|
+
/** True unless the kill switch disabled persistence. */
|
|
36
|
+
readonly enabled: boolean
|
|
37
|
+
/** Load surviving grants (absolute expiry > now). Empty when disabled. */
|
|
38
|
+
load(now: number): ScopedGrantStore
|
|
39
|
+
/** Write-through the current store. No-op when disabled. */
|
|
40
|
+
save(store: ScopedGrantStore): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Persistence is on unless SWITCHROOM_SCOPED_GRANT_PERSIST is exactly "0". */
|
|
44
|
+
export function scopedGrantPersistEnabled(
|
|
45
|
+
env: Record<string, string | undefined> = process.env,
|
|
46
|
+
): boolean {
|
|
47
|
+
return env.SWITCHROOM_SCOPED_GRANT_PERSIST !== '0'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createScopedGrantStore(
|
|
51
|
+
stateDir: string,
|
|
52
|
+
env: Record<string, string | undefined> = process.env,
|
|
53
|
+
): ScopedGrantPersistence {
|
|
54
|
+
const filePath = join(stateDir, 'scoped-grants.json')
|
|
55
|
+
const enabled = scopedGrantPersistEnabled(env)
|
|
56
|
+
|
|
57
|
+
function read(): unknown[] {
|
|
58
|
+
try {
|
|
59
|
+
const raw = readFileSync(filePath, 'utf-8')
|
|
60
|
+
const parsed = JSON.parse(raw)
|
|
61
|
+
return Array.isArray(parsed) ? parsed : []
|
|
62
|
+
} catch {
|
|
63
|
+
return []
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
enabled,
|
|
69
|
+
|
|
70
|
+
load(now) {
|
|
71
|
+
if (!enabled) return new Map()
|
|
72
|
+
return deserializeScopedGrants(read(), now)
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
save(store) {
|
|
76
|
+
if (!enabled) return
|
|
77
|
+
try {
|
|
78
|
+
writeFileSync(filePath, JSON.stringify(serializeScopedGrants(store)), {
|
|
79
|
+
encoding: 'utf-8',
|
|
80
|
+
mode: 0o600,
|
|
81
|
+
})
|
|
82
|
+
} catch (err) {
|
|
83
|
+
process.stderr.write(
|
|
84
|
+
`telegram gateway: scoped-grant-store write failed: ${(err as Error).message}\n`,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|