switchroom 0.18.8 → 0.18.10
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/README.md +2 -2
- package/dist/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +198 -13
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +28 -4
- package/dist/host-control/main.js +3 -2
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +119 -37
- package/profiles/_shared/dev-protocol.md.hbs +42 -0
- package/skills/dev-protocol/SKILL.md +131 -0
- package/telegram-plugin/README.md +2 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
- package/telegram-plugin/admin-commands/index.ts +6 -1
- package/telegram-plugin/bridge/bridge.ts +23 -1
- package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
- package/telegram-plugin/chat-lock.ts +13 -0
- package/telegram-plugin/dist/bridge/bridge.js +24 -1
- package/telegram-plugin/dist/gateway/gateway.js +80236 -77465
- package/telegram-plugin/dist/server.js +29 -2
- package/telegram-plugin/fallback-card-collapse.ts +131 -0
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/effort-command.ts +47 -3
- package/telegram-plugin/gateway/gateway.ts +2051 -3180
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/model-command.ts +94 -8
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-session-command.ts +365 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
- package/telegram-plugin/gateway/session-model-file.ts +166 -23
- package/telegram-plugin/gateway/stop-command.ts +56 -0
- package/telegram-plugin/photo-precheck.ts +201 -0
- package/telegram-plugin/quota-watch.ts +141 -2
- package/telegram-plugin/registry/subagents-schema.ts +26 -3
- package/telegram-plugin/registry/subagents.test.ts +67 -0
- package/telegram-plugin/retry-api-call.ts +31 -0
- package/telegram-plugin/subagent-watcher.ts +392 -1
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
- package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
- package/telegram-plugin/tests/effort-command.test.ts +59 -2
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/model-command.test.ts +46 -3
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
- package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
- package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
- package/telegram-plugin/tests/quota-watch.test.ts +225 -0
- package/telegram-plugin/tests/session-model-file.test.ts +101 -2
- package/telegram-plugin/tests/stop-command.test.ts +234 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
- package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
- package/telegram-plugin/welcome-text.ts +4 -3
- package/telegram-plugin/worker-activity-feed.ts +27 -0
|
@@ -24286,6 +24286,25 @@ function createOutstandingPermissionLedger() {
|
|
|
24286
24286
|
};
|
|
24287
24287
|
}
|
|
24288
24288
|
|
|
24289
|
+
// bridge/crash-breadcrumb.ts
|
|
24290
|
+
import { appendFileSync as appendFileSync3, statSync as statSync6, renameSync as renameSync4 } from "node:fs";
|
|
24291
|
+
function appendCrashBreadcrumb(logPath, kind, err, now = new Date) {
|
|
24292
|
+
try {
|
|
24293
|
+
try {
|
|
24294
|
+
if (statSync6(logPath).size > MAX_LOG_BYTES)
|
|
24295
|
+
renameSync4(logPath, `${logPath}.1`);
|
|
24296
|
+
} catch {}
|
|
24297
|
+
const detail = err instanceof Error ? err.stack ?? err.message : String(err);
|
|
24298
|
+
const folded = detail.replace(/\s*\n\s*/g, " | ").slice(0, 4000);
|
|
24299
|
+
appendFileSync3(logPath, `${now.toISOString()} ${kind} pid=${process.pid} ${folded}
|
|
24300
|
+
`);
|
|
24301
|
+
} catch {}
|
|
24302
|
+
}
|
|
24303
|
+
var MAX_LOG_BYTES;
|
|
24304
|
+
var init_crash_breadcrumb = __esm(() => {
|
|
24305
|
+
MAX_LOG_BYTES = 1024 * 1024;
|
|
24306
|
+
});
|
|
24307
|
+
|
|
24289
24308
|
// bridge/bridge.ts
|
|
24290
24309
|
var exports_bridge = {};
|
|
24291
24310
|
import { dirname as dirname3, join as join5 } from "path";
|
|
@@ -24394,7 +24413,7 @@ async function main() {
|
|
|
24394
24413
|
}
|
|
24395
24414
|
await mcp.connect(new StdioServerTransport);
|
|
24396
24415
|
}
|
|
24397
|
-
var STATE_DIR, SOCKET_PATH, TOPIC_ID, AGENT_NAME, mcp, TOOL_SCHEMAS, EFFECTIVE_TOOL_SCHEMAS, sessionAllowRules, outstandingPermissions, permissionRearmEnabled, ipc = null, sessionTailEnabled, sessionTailHandle = null, ptyTailEnabled, ptyTailHandle = null;
|
|
24416
|
+
var STATE_DIR, SOCKET_PATH, TOPIC_ID, AGENT_NAME, mcp, TOOL_SCHEMAS, EFFECTIVE_TOOL_SCHEMAS, sessionAllowRules, outstandingPermissions, permissionRearmEnabled, ipc = null, sessionTailEnabled, sessionTailHandle = null, ptyTailEnabled, ptyTailHandle = null, CRASH_LOG_PATH;
|
|
24398
24417
|
var init_bridge = __esm(async () => {
|
|
24399
24418
|
init_server2();
|
|
24400
24419
|
init_stdio2();
|
|
@@ -24406,6 +24425,7 @@ var init_bridge = __esm(async () => {
|
|
|
24406
24425
|
init_ipc_client();
|
|
24407
24426
|
init_tool_filter();
|
|
24408
24427
|
init_permission_rule();
|
|
24428
|
+
init_crash_breadcrumb();
|
|
24409
24429
|
installPluginLogger2();
|
|
24410
24430
|
STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join5(homedir4(), ".claude", "channels", "telegram");
|
|
24411
24431
|
SOCKET_PATH = process.env.SWITCHROOM_GATEWAY_SOCKET ?? join5(STATE_DIR, "gateway.sock");
|
|
@@ -24454,7 +24474,7 @@ var init_bridge = __esm(async () => {
|
|
|
24454
24474
|
quote: { type: "boolean", description: "Opt out of the default quote-reply behavior. Default: true. Pass false to send a bare message with no quote reference. Ignored when reply_to is explicitly set." },
|
|
24455
24475
|
message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message in the same chat if not specified." },
|
|
24456
24476
|
origin_turn_id: { type: "string", description: "In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message's topic even if another topic's turn started meanwhile. Omit in DMs / single-topic chats." },
|
|
24457
|
-
files: { type: "array", items: { type: "string" }, description: "Absolute file paths to attach. Images send as photos; other types as documents. Max 50MB each." },
|
|
24477
|
+
files: { type: "array", items: { type: "string" }, description: "Absolute file paths to attach. Images send as photos; other types as documents. Max 50MB each. Telegram rejects photos with extreme dimensions (aspect ratio over ~10:1, width+height over 10000px, or over 10MB) \u2014 very tall images like full-page screenshots are auto-rerouted as documents; crop or split them first if the user should see them inline as photos." },
|
|
24458
24478
|
format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
|
|
24459
24479
|
disable_web_page_preview: { type: "boolean", description: "Disable link preview thumbnails. Default: true." },
|
|
24460
24480
|
protect_content: { type: "boolean", description: "When true, Telegram prevents the message from being forwarded or saved." },
|
|
@@ -25019,9 +25039,16 @@ var init_bridge = __esm(async () => {
|
|
|
25019
25039
|
cleanup();
|
|
25020
25040
|
setTimeout(() => process.exit(0), 500);
|
|
25021
25041
|
});
|
|
25042
|
+
CRASH_LOG_PATH = join5(STATE_DIR, "bridge-crash.log");
|
|
25022
25043
|
process.on("unhandledRejection", (err) => {
|
|
25023
25044
|
process.stderr.write(`telegram bridge: unhandled rejection: ${err}
|
|
25024
25045
|
`);
|
|
25046
|
+
appendCrashBreadcrumb(CRASH_LOG_PATH, "unhandledRejection", err);
|
|
25047
|
+
});
|
|
25048
|
+
process.on("uncaughtException", (err) => {
|
|
25049
|
+
process.stderr.write(`telegram bridge: uncaught exception (continuing): ${err?.stack ?? err}
|
|
25050
|
+
`);
|
|
25051
|
+
appendCrashBreadcrumb(CRASH_LOG_PATH, "uncaughtException", err);
|
|
25025
25052
|
});
|
|
25026
25053
|
await main().catch((err) => {
|
|
25027
25054
|
process.stderr.write(`telegram bridge: fatal: ${err}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive-path message collapse (#3031 PR 3).
|
|
3
|
+
*
|
|
4
|
+
* Pre-fix, a mid-turn 429 emitted up to three separate operator messages:
|
|
5
|
+
* 1. the ⚠️ model-unavailable card (sent BEFORE the fallback outcome is
|
|
6
|
+
* known — deliberate, see the promise-honesty note below),
|
|
7
|
+
* 2. the fleet-fallback success announcement ("Switched to <account> …"),
|
|
8
|
+
* 3. the resume/boot notice.
|
|
9
|
+
*
|
|
10
|
+
* This module owns the SAFE part of collapsing that flow: when the swap
|
|
11
|
+
* SUCCEEDS, the announcement is folded into an EDIT of the just-sent
|
|
12
|
+
* model-unavailable card (one evolving card) instead of a second message.
|
|
13
|
+
*
|
|
14
|
+
* CRITICAL constraint (2026-06-06→07 incident): the card is deliberately
|
|
15
|
+
* sent before the outcome is known, and a FAILURE notice must arrive as its
|
|
16
|
+
* own message on EVERY error path — the card promised an announcement, and
|
|
17
|
+
* an edit that never happens (or a swallowed failure) breaks that promise.
|
|
18
|
+
* Therefore:
|
|
19
|
+
* - `decideAnnouncementDelivery` returns 'edit' ONLY for a successful
|
|
20
|
+
* 'switched' outcome with a fresh, fallback-promising card on record.
|
|
21
|
+
* Every other outcome kind ('all-blocked', 'no-old-active',
|
|
22
|
+
* 'no-eligible-target', errors) is 'send' — a separate message, exactly
|
|
23
|
+
* the pre-fix behaviour.
|
|
24
|
+
* - The gateway falls back to a plain send when the edit itself throws.
|
|
25
|
+
*
|
|
26
|
+
* Pure module (injected clock, no Telegram/bot imports) so the decision
|
|
27
|
+
* logic is unit-testable at the seam.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** One recorded model-unavailable card, per chat. */
|
|
31
|
+
export interface ModelUnavailableCardRecord {
|
|
32
|
+
/** Telegram message id of the sent card (null until the send resolves —
|
|
33
|
+
* callers should record only after the send succeeds). */
|
|
34
|
+
messageId: number;
|
|
35
|
+
/** The rendered card text as sent, so the edit can append to it. */
|
|
36
|
+
text: string;
|
|
37
|
+
/** Unix ms the card send resolved. */
|
|
38
|
+
atMs: number;
|
|
39
|
+
/** Did the card promise an in-flight auto-failover? Only such cards may
|
|
40
|
+
* absorb the announcement — a manual-hint card never promised one. */
|
|
41
|
+
promisedFallback: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Freshness ceiling for folding into a card. The fallback dispatcher runs
|
|
46
|
+
* seconds after the card in the normal case; a card older than this belongs
|
|
47
|
+
* to a previous incident and must not absorb an unrelated announcement.
|
|
48
|
+
*
|
|
49
|
+
* Known accepted race (#3035 review, finding 4): the record lands only
|
|
50
|
+
* AFTER the card send resolves, and stays editable for this whole window —
|
|
51
|
+
* so a LATER 'switched' outcome (a second dispatch inside the window) can
|
|
52
|
+
* edit a card whose incident was already answered. Bounded by this 5-min
|
|
53
|
+
* ceiling plus the one-shot `take` semantics (a card absorbs at most one
|
|
54
|
+
* announcement); worst case is one announcement folded into a slightly
|
|
55
|
+
* older card instead of arriving as a separate message — never a lost
|
|
56
|
+
* announcement.
|
|
57
|
+
*/
|
|
58
|
+
export const CARD_COLLAPSE_MAX_AGE_MS = 5 * 60_000;
|
|
59
|
+
|
|
60
|
+
export interface ModelUnavailableCardRegistry {
|
|
61
|
+
/** Record the card most recently sent to `chatId` (replaces any prior). */
|
|
62
|
+
record(chatId: string, rec: ModelUnavailableCardRecord): void;
|
|
63
|
+
/**
|
|
64
|
+
* One-shot take: return the fresh, fallback-promising card for `chatId`
|
|
65
|
+
* and remove it (a card absorbs at most one announcement). Returns null —
|
|
66
|
+
* and clears the entry — when the record is stale or never promised a
|
|
67
|
+
* fallback.
|
|
68
|
+
*/
|
|
69
|
+
take(chatId: string, now: number): ModelUnavailableCardRecord | null;
|
|
70
|
+
/** Number of chats with a recorded card (test/introspection helper). */
|
|
71
|
+
size(): number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createModelUnavailableCardRegistry(
|
|
75
|
+
maxAgeMs: number = CARD_COLLAPSE_MAX_AGE_MS,
|
|
76
|
+
): ModelUnavailableCardRegistry {
|
|
77
|
+
const cards = new Map<string, ModelUnavailableCardRecord>();
|
|
78
|
+
return {
|
|
79
|
+
record(chatId, rec) {
|
|
80
|
+
cards.set(chatId, rec);
|
|
81
|
+
},
|
|
82
|
+
take(chatId, now) {
|
|
83
|
+
const rec = cards.get(chatId);
|
|
84
|
+
if (!rec) return null;
|
|
85
|
+
cards.delete(chatId);
|
|
86
|
+
if (!rec.promisedFallback) return null;
|
|
87
|
+
if (now - rec.atMs > maxAgeMs) return null;
|
|
88
|
+
return rec;
|
|
89
|
+
},
|
|
90
|
+
size() {
|
|
91
|
+
return cards.size;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The outcome kinds `runFleetAutoFallback` can produce, plus 'error' for
|
|
98
|
+
* the dispatcher-threw path (broker unreachable, listState/markExhausted
|
|
99
|
+
* throw). Kept as a plain string union so this module stays import-free.
|
|
100
|
+
*/
|
|
101
|
+
export type FallbackDeliveryOutcomeKind =
|
|
102
|
+
| 'switched'
|
|
103
|
+
| 'all-blocked'
|
|
104
|
+
| 'no-old-active'
|
|
105
|
+
| 'no-eligible-target'
|
|
106
|
+
| 'error';
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Should the announcement for `outcomeKind` be delivered by EDITING the
|
|
110
|
+
* recorded model-unavailable card, or by SENDING a separate message?
|
|
111
|
+
*
|
|
112
|
+
* 'edit' is returned ONLY when the swap succeeded AND a fresh
|
|
113
|
+
* fallback-promising card exists for the chat. Every failure / no-op
|
|
114
|
+
* outcome is 'send' — the promise-honesty contract: a failure notice must
|
|
115
|
+
* arrive as its own message on every error path.
|
|
116
|
+
*/
|
|
117
|
+
export function decideAnnouncementDelivery(
|
|
118
|
+
outcomeKind: FallbackDeliveryOutcomeKind,
|
|
119
|
+
card: ModelUnavailableCardRecord | null,
|
|
120
|
+
): 'edit' | 'send' {
|
|
121
|
+
if (outcomeKind !== 'switched') return 'send';
|
|
122
|
+
return card !== null ? 'edit' : 'send';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The single evolving card: original card text plus the announcement,
|
|
127
|
+
* separated by a rule so the "before/after" reads as one incident timeline.
|
|
128
|
+
*/
|
|
129
|
+
export function foldAnnouncementIntoCard(cardText: string, announcement: string): string {
|
|
130
|
+
return `${cardText}\n\n${announcement}`;
|
|
131
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory storage for the four AGENT-INITIATED approval-card families
|
|
3
|
+
* (vault_request_access / vault_request_save / request_secret /
|
|
4
|
+
* mental_model_propose), extracted from gateway.ts (Phase 3 step 1 of the
|
|
5
|
+
* gateway decomposition — see #2996). STORAGE ONLY: the per-card expiry LOGIC
|
|
6
|
+
* (card edits, timeout synthetics, missed-approval re-offers) still lives in
|
|
7
|
+
* gateway.ts and is injected here as `expire`. This module owns the Map
|
|
8
|
+
* lifecycle and CO-LOCATES the TTL sweep next to the storage it sweeps, so the
|
|
9
|
+
* gateway's authoritative `pendingStateReaper` calls a single `store.sweep(now)`
|
|
10
|
+
* delegation instead of open-coding a `sweepExpiredEntries(...)` per family.
|
|
11
|
+
*
|
|
12
|
+
* Why a Map-compatible surface (get/set/delete/has/iteration) rather than a
|
|
13
|
+
* bespoke API: every existing gateway call site — the callback-query handlers,
|
|
14
|
+
* the executor add-sites, and `restorePendingApprovalCards` — used the raw Map
|
|
15
|
+
* directly. Preserving the exact Map method surface keeps those call sites
|
|
16
|
+
* BYTE-IDENTICAL (the variable name and every `.get`/`.set`/`.delete`/`for..of`
|
|
17
|
+
* are unchanged), which is the invariant this phase must not break: the store
|
|
18
|
+
* moves WHERE the Map is constructed and WHERE its sweep lives, nothing about
|
|
19
|
+
* how the gateway reads or writes it. Restore semantics are therefore untouched.
|
|
20
|
+
*
|
|
21
|
+
* Race contract preserved (pinned by approval-card-stores.test.ts):
|
|
22
|
+
* - A verdict (operator tap → `.delete(stageId)`) arriving DURING an expiry
|
|
23
|
+
* sweep is single-shot-safe: the pure `expirePendingCard` core removes the
|
|
24
|
+
* entry before any fallible side effect, so the same card can never both
|
|
25
|
+
* expire and resolve. The store's `sweep` is a thin pass-through to that
|
|
26
|
+
* core via `sweepExpiredEntries`, so the ordering guarantee is unchanged.
|
|
27
|
+
* - A verdict arriving DURING restore is a plain `.set`/`.delete` on the same
|
|
28
|
+
* Map instance the restore populates — no separate storage to desync.
|
|
29
|
+
* - The shared reaper cadence is unchanged: `sweep(now)` performs exactly the
|
|
30
|
+
* work the old `sweepPendingX(now)` free functions did, in the same order.
|
|
31
|
+
*
|
|
32
|
+
* `expire` and `log` are supplied as THUNKS so a store constructed early in
|
|
33
|
+
* gateway module-eval can reference the hoisted `expire*Card` functions and the
|
|
34
|
+
* `const cardExpiryLog` arrow (which is in the temporal dead zone at
|
|
35
|
+
* construction time); both are resolved lazily at sweep time.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { sweepExpiredEntries } from './pending-card-expiry.js'
|
|
39
|
+
|
|
40
|
+
export interface SweepableCardStore<T> {
|
|
41
|
+
get(stageId: string): T | undefined
|
|
42
|
+
set(stageId: string, value: T): void
|
|
43
|
+
delete(stageId: string): boolean
|
|
44
|
+
has(stageId: string): boolean
|
|
45
|
+
clear(): void
|
|
46
|
+
readonly size: number
|
|
47
|
+
keys(): IterableIterator<string>
|
|
48
|
+
values(): IterableIterator<T>
|
|
49
|
+
entries(): IterableIterator<[string, T]>
|
|
50
|
+
forEach(cb: (value: T, key: string, map: Map<string, T>) => void): void
|
|
51
|
+
[Symbol.iterator](): IterableIterator<[string, T]>
|
|
52
|
+
/**
|
|
53
|
+
* Expire every entry past its TTL. Delegates to the same pure
|
|
54
|
+
* `sweepExpiredEntries` core the gateway used inline, so per-entry
|
|
55
|
+
* fault-isolation and single-shot ordering are byte-identical.
|
|
56
|
+
*/
|
|
57
|
+
sweep(now: number): void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SweepableCardStoreDeps<T> {
|
|
61
|
+
/** Predicate: is this entry past its TTL at `now`? Closes over the family TTL. */
|
|
62
|
+
isExpired: (value: T, now: number) => boolean
|
|
63
|
+
/**
|
|
64
|
+
* Per-entry expiry action (card strip + timeout synthetic + missed-approval
|
|
65
|
+
* re-offer). A THUNK so it can reference gateway functions that are hoisted /
|
|
66
|
+
* defined after this store is constructed. Called once per expired entry.
|
|
67
|
+
*/
|
|
68
|
+
expire: () => (stageId: string, value: T, now: number) => void
|
|
69
|
+
/** Error sink thunk (resolves the gateway's `cardExpiryLog` lazily). */
|
|
70
|
+
log: () => (msg: string) => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create a Map-backed, self-sweeping store for one approval-card family.
|
|
75
|
+
* The backing Map is the sole storage; the returned object forwards the Map
|
|
76
|
+
* surface the gateway uses plus a co-located `sweep`.
|
|
77
|
+
*/
|
|
78
|
+
export function createSweepableCardStore<T>(
|
|
79
|
+
deps: SweepableCardStoreDeps<T>,
|
|
80
|
+
): SweepableCardStore<T> {
|
|
81
|
+
const map = new Map<string, T>()
|
|
82
|
+
return {
|
|
83
|
+
get: (stageId) => map.get(stageId),
|
|
84
|
+
set: (stageId, value) => void map.set(stageId, value),
|
|
85
|
+
delete: (stageId) => map.delete(stageId),
|
|
86
|
+
has: (stageId) => map.has(stageId),
|
|
87
|
+
clear: () => map.clear(),
|
|
88
|
+
get size() {
|
|
89
|
+
return map.size
|
|
90
|
+
},
|
|
91
|
+
keys: () => map.keys(),
|
|
92
|
+
values: () => map.values(),
|
|
93
|
+
entries: () => map.entries(),
|
|
94
|
+
forEach: (cb) => map.forEach(cb),
|
|
95
|
+
[Symbol.iterator]: () => map[Symbol.iterator](),
|
|
96
|
+
sweep: (now) =>
|
|
97
|
+
sweepExpiredEntries(map, deps.isExpired, deps.expire(), now, deps.log()),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { Bot, Context, InlineKeyboard } from 'grammy'
|
|
2
|
+
import { hostdWillBeUsed } from './hostd-dispatch.js'
|
|
3
|
+
import { maskVaultKey } from '../demo-mask.js'
|
|
4
|
+
import { switchroomHelpText as buildSwitchroomHelpText } from '../welcome-text.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read-only ops/info slash commands extracted verbatim from gateway.ts
|
|
8
|
+
* (#2996 Phase 5 leaf move): `/doctor`, `/grant`, `/dangerous`,
|
|
9
|
+
* `/permissions`, `/version`, `/whoami`, `/commands`.
|
|
10
|
+
*
|
|
11
|
+
* These are pure info/ops surfaces (no send-path or turn-lifecycle
|
|
12
|
+
* coupling). Every gateway-local helper they close over is passed in via
|
|
13
|
+
* `deps` (repo factory-deps precedent) so the module never back-references
|
|
14
|
+
* the gateway singleton or reads `currentTurn`. `bot` is injected so
|
|
15
|
+
* grammy registration order is preserved at the original call site.
|
|
16
|
+
*/
|
|
17
|
+
export interface OpsInfoCommandDeps {
|
|
18
|
+
AGENT_ADMIN: boolean
|
|
19
|
+
isAuthorizedSender: (ctx: Context) => boolean
|
|
20
|
+
getMyAgentName: () => string
|
|
21
|
+
switchroomReply: (
|
|
22
|
+
ctx: Context,
|
|
23
|
+
text: string,
|
|
24
|
+
options?: {
|
|
25
|
+
html?: boolean
|
|
26
|
+
reply_markup?: InlineKeyboard
|
|
27
|
+
classification?: 'query' | 'mutation' | 'heavy'
|
|
28
|
+
},
|
|
29
|
+
) => Promise<void>
|
|
30
|
+
buildDoctorScopeKeyboard: () => InlineKeyboard
|
|
31
|
+
renderSelfDoctor: (ctx: Context) => Promise<void>
|
|
32
|
+
preBlock: (text: string) => string
|
|
33
|
+
formatSwitchroomOutput: (output: string, maxLen?: number) => string
|
|
34
|
+
getCommandArgs: (ctx: Context) => string
|
|
35
|
+
assertSafeAgentName: (name: string) => void
|
|
36
|
+
runSwitchroomCommand: (
|
|
37
|
+
ctx: Context,
|
|
38
|
+
args: string[],
|
|
39
|
+
label: string,
|
|
40
|
+
classification?: 'query' | 'mutation' | 'heavy',
|
|
41
|
+
) => Promise<void>
|
|
42
|
+
switchroomExecCombined: (args: string[], timeoutMs?: number) => string
|
|
43
|
+
stripAnsi: (text: string) => string
|
|
44
|
+
hasDemoFlag: (args: string) => boolean
|
|
45
|
+
escapeHtmlForTg: (text: string) => string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function registerOpsInfoCommands(bot: Bot, deps: OpsInfoCommandDeps): void {
|
|
49
|
+
const {
|
|
50
|
+
AGENT_ADMIN,
|
|
51
|
+
isAuthorizedSender,
|
|
52
|
+
getMyAgentName,
|
|
53
|
+
switchroomReply,
|
|
54
|
+
buildDoctorScopeKeyboard,
|
|
55
|
+
renderSelfDoctor,
|
|
56
|
+
preBlock,
|
|
57
|
+
formatSwitchroomOutput,
|
|
58
|
+
getCommandArgs,
|
|
59
|
+
assertSafeAgentName,
|
|
60
|
+
runSwitchroomCommand,
|
|
61
|
+
switchroomExecCombined,
|
|
62
|
+
stripAnsi,
|
|
63
|
+
hasDemoFlag,
|
|
64
|
+
escapeHtmlForTg,
|
|
65
|
+
} = deps
|
|
66
|
+
|
|
67
|
+
/** Compact HTML card from the `config whoami` JSON view. Names/booleans only.
|
|
68
|
+
* `demo` (the `/whoami demo` suffix) masks the vault key NAMES via maskVaultKey
|
|
69
|
+
* for screen recordings — agent/MCP/model/skills topology is left untouched
|
|
70
|
+
* (out of scope). Off by default. */
|
|
71
|
+
function formatWhoamiCard(v: {
|
|
72
|
+
name?: string; persona?: string | null; model?: string | null; tier?: string;
|
|
73
|
+
tools?: { allow?: string[]; deny?: string[] }; mcpServers?: string[]; skills?: string[];
|
|
74
|
+
vault?: { key: string; readable: boolean }[];
|
|
75
|
+
powers?: { admin?: boolean; root?: boolean; configEdit?: boolean; crossAgentHostVerbs?: boolean };
|
|
76
|
+
scheduleCount?: number; memoryBackend?: string | null;
|
|
77
|
+
}, demo = false): string {
|
|
78
|
+
const esc = escapeHtmlForTg
|
|
79
|
+
const yn = (b?: boolean) => (b ? '✓' : '✗')
|
|
80
|
+
const lines: string[] = []
|
|
81
|
+
lines.push(`👤 **${esc(v.name ?? '?')}** · ${esc(v.tier ?? 'standard')}`)
|
|
82
|
+
if (v.persona) lines.push(esc(v.persona))
|
|
83
|
+
if (v.model) lines.push(`Model: ${esc(v.model)}`)
|
|
84
|
+
const allow = v.tools?.allow ?? []
|
|
85
|
+
lines.push(`Tools: ${allow.length ? esc(allow.slice(0, 8).join(', ')) + (allow.length > 8 ? ` …(+${allow.length - 8})` : '') : '—'}`)
|
|
86
|
+
if ((v.tools?.deny ?? []).length) lines.push(`Denied: ${esc((v.tools!.deny!).join(', '))}`)
|
|
87
|
+
if ((v.mcpServers ?? []).length) lines.push(`MCP: ${esc(v.mcpServers!.join(', '))}`)
|
|
88
|
+
if ((v.skills ?? []).length) lines.push(`Skills: ${esc(v.skills!.join(', '))}`)
|
|
89
|
+
if ((v.vault ?? []).length) {
|
|
90
|
+
lines.push(`Vault keys (names only): ${v.vault!.map(k => `${esc(demo ? maskVaultKey(k.key) : k.key)} ${yn(k.readable)}`).join(', ')}`)
|
|
91
|
+
}
|
|
92
|
+
const p = v.powers ?? {}
|
|
93
|
+
lines.push(`Powers: admin ${yn(p.admin)} · root ${yn(p.root)} · config-edit ${yn(p.configEdit)} · cross-agent verbs ${yn(p.crossAgentHostVerbs)}`)
|
|
94
|
+
lines.push(`Schedule: ${v.scheduleCount ?? 0} cron · Memory: ${esc(v.memoryBackend ?? 'none')}`)
|
|
95
|
+
return lines.join('\n')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
bot.command('doctor', async ctx => {
|
|
99
|
+
if (!isAuthorizedSender(ctx)) return
|
|
100
|
+
try {
|
|
101
|
+
// Admin agents with hostd reachable choose scope (one tap, no
|
|
102
|
+
// approval card — doctor is read-only). Everyone else keeps the
|
|
103
|
+
// original zero-extra-tap in-container behaviour.
|
|
104
|
+
if (AGENT_ADMIN && hostdWillBeUsed(getMyAgentName())) {
|
|
105
|
+
await switchroomReply(ctx, '🩺 **Doctor** — which scope?', {
|
|
106
|
+
html: true,
|
|
107
|
+
reply_markup: buildDoctorScopeKeyboard(),
|
|
108
|
+
})
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
await renderSelfDoctor(ctx)
|
|
112
|
+
} catch (err: unknown) {
|
|
113
|
+
await switchroomReply(ctx, `**doctor failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
bot.command('grant', async ctx => {
|
|
118
|
+
if (!isAuthorizedSender(ctx)) return
|
|
119
|
+
const parts = getCommandArgs(ctx).split(/\s+/).filter(Boolean)
|
|
120
|
+
if (parts.length === 0) { await switchroomReply(ctx, 'Usage: /grant <tool> or /grant <agent> <tool>'); return }
|
|
121
|
+
let agentName: string; let tool: string
|
|
122
|
+
if (parts.length === 1) { agentName = getMyAgentName(); tool = parts[0] }
|
|
123
|
+
else { agentName = parts[0]; tool = parts.slice(1).join(' ') }
|
|
124
|
+
try { assertSafeAgentName(agentName) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
|
|
125
|
+
await runSwitchroomCommand(ctx, ['agent', 'grant', agentName, tool], `grant ${agentName} ${tool}`)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
bot.command('dangerous', async ctx => {
|
|
129
|
+
if (!isAuthorizedSender(ctx)) return
|
|
130
|
+
const parts = getCommandArgs(ctx).split(/\s+/).filter(Boolean)
|
|
131
|
+
let agentName: string; let off = false
|
|
132
|
+
if (parts.length === 0) { agentName = getMyAgentName() }
|
|
133
|
+
else if (parts.length === 1 && parts[0] === 'off') { agentName = getMyAgentName(); off = true }
|
|
134
|
+
else { agentName = parts[0]; if (parts[1] === 'off') off = true }
|
|
135
|
+
try { assertSafeAgentName(agentName) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
|
|
136
|
+
const args = ['agent', 'dangerous', agentName]; if (off) args.push('--off')
|
|
137
|
+
await runSwitchroomCommand(ctx, args, `dangerous ${agentName}${off ? ' off' : ''}`)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
bot.command('permissions', async ctx => {
|
|
141
|
+
if (!isAuthorizedSender(ctx)) return
|
|
142
|
+
const agentName = (typeof ctx.match === "string" ? ctx.match : "").trim() || getMyAgentName()
|
|
143
|
+
try { assertSafeAgentName(agentName) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
|
|
144
|
+
await runSwitchroomCommand(ctx, ['agent', 'permissions', agentName], `permissions ${agentName}`)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// Drive-by cleanup (#927): the dead /update handler that lived here
|
|
148
|
+
// was a pre-#919 stub. Grammy registers in order so the comprehensive
|
|
149
|
+
// /update handler at line ~6516 (added in #919, hardened in #924,
|
|
150
|
+
// docker-guarded in #934) fired first and this one never ran.
|
|
151
|
+
// Removed to avoid future confusion.
|
|
152
|
+
|
|
153
|
+
bot.command('version', async ctx => {
|
|
154
|
+
if (!isAuthorizedSender(ctx)) return
|
|
155
|
+
try {
|
|
156
|
+
let output: string
|
|
157
|
+
try { output = switchroomExecCombined(['version'], 10000) }
|
|
158
|
+
catch (err: unknown) { output = (err as any).stdout ?? (err as any).message ?? 'version failed' }
|
|
159
|
+
const trimmed = stripAnsi(output).trim()
|
|
160
|
+
if (!trimmed) { await switchroomReply(ctx, 'version: no output'); return }
|
|
161
|
+
await switchroomReply(ctx, preBlock(formatSwitchroomOutput(trimmed)), { html: true })
|
|
162
|
+
} catch (err: unknown) {
|
|
163
|
+
await switchroomReply(ctx, `**version failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
// /whoami — the operator's view of THIS agent's sandbox (the same
|
|
169
|
+
// `config whoami` the agent itself can call as an MCP tool, and the host CLI
|
|
170
|
+
// exposes). Read-only, isAuthorizedSender-gated like /version — surfaces
|
|
171
|
+
// tools / MCP / vault key-NAMES (never values) / powers so the operator can
|
|
172
|
+
// see at a glance what this agent is authorized for.
|
|
173
|
+
bot.command('whoami', async ctx => {
|
|
174
|
+
if (!isAuthorizedSender(ctx)) return
|
|
175
|
+
const demo = hasDemoFlag(getCommandArgs(ctx))
|
|
176
|
+
try {
|
|
177
|
+
let raw: string
|
|
178
|
+
try { raw = switchroomExecCombined(['config', 'whoami'], 10000) }
|
|
179
|
+
catch (err: unknown) { raw = (err as any).stdout ?? (err as any).message ?? 'whoami failed' }
|
|
180
|
+
const trimmed = stripAnsi(raw).trim()
|
|
181
|
+
let card: string
|
|
182
|
+
try { card = formatWhoamiCard(JSON.parse(trimmed.split('\n').pop() ?? trimmed), demo) }
|
|
183
|
+
catch { card = preBlock(formatSwitchroomOutput(trimmed || 'whoami: no output')) }
|
|
184
|
+
await switchroomReply(ctx, card, { html: true })
|
|
185
|
+
} catch (err: unknown) {
|
|
186
|
+
await switchroomReply(ctx, `**whoami failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
bot.command('commands', async ctx => {
|
|
191
|
+
if (!isAuthorizedSender(ctx)) return
|
|
192
|
+
await switchroomReply(ctx, buildSwitchroomHelpText(getMyAgentName()), { html: true })
|
|
193
|
+
})
|
|
194
|
+
}
|