switchroom 0.5.0 → 0.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +142 -121
  2. package/bin/autoaccept.exp +29 -6
  3. package/dist/agent-scheduler/index.js +12261 -0
  4. package/dist/cli/autoaccept-poll.js +10 -0
  5. package/dist/cli/switchroom.js +27250 -25324
  6. package/dist/vault/approvals/kernel-server.js +12709 -0
  7. package/dist/vault/broker/server.js +15724 -0
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +133 -0
  10. package/profiles/_shared/telegram-style.md.hbs +3 -3
  11. package/profiles/default/CLAUDE.md +3 -3
  12. package/profiles/default/CLAUDE.md.hbs +2 -2
  13. package/profiles/default/workspace/CLAUDE.md.hbs +9 -0
  14. package/skills/docx/VENDORED.md +1 -1
  15. package/skills/mcp-builder/VENDORED.md +1 -1
  16. package/skills/pdf/VENDORED.md +1 -1
  17. package/skills/pptx/VENDORED.md +1 -1
  18. package/skills/skill-creator/VENDORED.md +1 -1
  19. package/skills/switchroom-architecture/SKILL.md +8 -7
  20. package/skills/switchroom-cli/SKILL.md +23 -15
  21. package/skills/switchroom-health/SKILL.md +7 -7
  22. package/skills/switchroom-install/SKILL.md +36 -39
  23. package/skills/switchroom-manage/SKILL.md +4 -4
  24. package/skills/switchroom-status/SKILL.md +1 -1
  25. package/skills/webapp-testing/VENDORED.md +1 -1
  26. package/skills/xlsx/VENDORED.md +1 -1
  27. package/telegram-plugin/admin-commands/dispatch.test.ts +119 -1
  28. package/telegram-plugin/admin-commands/index.ts +71 -0
  29. package/telegram-plugin/ask-user.ts +1 -0
  30. package/telegram-plugin/card-event-log.ts +138 -0
  31. package/telegram-plugin/dist/bridge/bridge.js +178 -31
  32. package/telegram-plugin/dist/foreman/foreman.js +6875 -6526
  33. package/telegram-plugin/dist/gateway/gateway.js +13862 -11834
  34. package/telegram-plugin/dist/server.js +202 -40
  35. package/telegram-plugin/fleet-state.ts +25 -10
  36. package/telegram-plugin/foreman/foreman.ts +38 -3
  37. package/telegram-plugin/gateway/approval-callback.ts +126 -0
  38. package/telegram-plugin/gateway/approval-card.test.ts +90 -0
  39. package/telegram-plugin/gateway/approval-card.ts +127 -0
  40. package/telegram-plugin/gateway/approvals-commands.ts +126 -0
  41. package/telegram-plugin/gateway/boot-card.ts +31 -6
  42. package/telegram-plugin/gateway/boot-probes.ts +510 -72
  43. package/telegram-plugin/gateway/gateway.ts +822 -94
  44. package/telegram-plugin/gateway/ipc-protocol.ts +34 -1
  45. package/telegram-plugin/gateway/ipc-server.ts +35 -0
  46. package/telegram-plugin/gateway/startup-mutex.ts +110 -2
  47. package/telegram-plugin/hooks/hooks.json +19 -0
  48. package/telegram-plugin/hooks/tool-label-pretool.mjs +216 -0
  49. package/telegram-plugin/hooks/tool-label-stop.mjs +63 -0
  50. package/telegram-plugin/package.json +4 -1
  51. package/telegram-plugin/plugin-logger.ts +20 -1
  52. package/telegram-plugin/progress-card-driver.ts +202 -13
  53. package/telegram-plugin/progress-card.ts +2 -2
  54. package/telegram-plugin/quota-check.ts +1 -0
  55. package/telegram-plugin/registry/subagents-schema.ts +37 -0
  56. package/telegram-plugin/registry/subagents.test.ts +64 -0
  57. package/telegram-plugin/session-tail.ts +58 -5
  58. package/telegram-plugin/shared/bot-runtime.ts +48 -2
  59. package/telegram-plugin/subagent-watcher.ts +139 -7
  60. package/telegram-plugin/tests/_progress-card-harness.ts +4 -0
  61. package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +201 -0
  62. package/telegram-plugin/tests/boot-card-probe-target.test.ts +10 -34
  63. package/telegram-plugin/tests/boot-card-render.test.ts +6 -5
  64. package/telegram-plugin/tests/boot-probes.test.ts +564 -0
  65. package/telegram-plugin/tests/card-event-log.test.ts +145 -0
  66. package/telegram-plugin/tests/gateway-startup-mutex.test.ts +102 -0
  67. package/telegram-plugin/tests/ipc-server-validate-inject-inbound.test.ts +134 -0
  68. package/telegram-plugin/tests/progress-card-delay-842.test.ts +160 -0
  69. package/telegram-plugin/tests/quota-check.test.ts +37 -1
  70. package/telegram-plugin/tests/subagent-registry-bugs.test.ts +5 -0
  71. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +104 -1
  72. package/telegram-plugin/tests/subagent-watcher.test.ts +5 -0
  73. package/telegram-plugin/tests/tool-label-sidecar.test.ts +114 -0
  74. package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +5 -3
  75. package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +10 -0
  76. package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +58 -14
  77. package/telegram-plugin/tests/welcome-text.test.ts +57 -0
  78. package/telegram-plugin/tool-label-sidecar.ts +140 -0
  79. package/telegram-plugin/tool-labels.ts +55 -0
  80. package/telegram-plugin/two-zone-card.ts +27 -7
  81. package/telegram-plugin/uat/SETUP.md +160 -0
  82. package/telegram-plugin/uat/assertions.ts +140 -0
  83. package/telegram-plugin/uat/driver.ts +174 -0
  84. package/telegram-plugin/uat/harness.ts +161 -0
  85. package/telegram-plugin/uat/login.ts +134 -0
  86. package/telegram-plugin/uat/port-allocator.ts +71 -0
  87. package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +61 -0
  88. package/telegram-plugin/welcome-text.ts +44 -2
  89. package/bin/bridge-watchdog.sh +0 -967
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Generic `apv:` callback handler (RFC B §6.1, §8).
3
+ *
4
+ * Owns the post-tap state-machine for every approval card, regardless of
5
+ * which surface (secret/vault/MCP) opened the request:
6
+ *
7
+ * 1. Parse the callback_data via parseApprovalCallback.
8
+ * 2. Round-trip to the broker: approval_consume.
9
+ * - If consumed=false (already-tapped, expired, unknown): show a
10
+ * toast and edit the card to its post-tap text.
11
+ * 3. On `deny`: approval_record(granted=false).
12
+ * On `once`: approval_record(granted=true, ttl_ms=undefined) — the
13
+ * record exists for /approvals revoke targeting; it's harmless if
14
+ * the agent only ever calls approval_lookup once.
15
+ * On `always`: approval_record(granted=true, ttl_ms=null).
16
+ * On `ttl:1h|24h|7d`: approval_record(granted=true, ttl_ms=<n>).
17
+ * 4. Edit the card text in-place to reflect the decision; remove the
18
+ * inline keyboard so the buttons can't be re-tapped.
19
+ *
20
+ * The agent that opened the request is responsible for short-polling
21
+ * approval_lookup (RFC §10) to discover the outcome and proceed.
22
+ */
23
+
24
+ import type { Context } from "grammy";
25
+ import { parseApprovalCallback, ttlMsFromToken } from "./approval-card.js";
26
+ import {
27
+ approvalConsume,
28
+ approvalRecord,
29
+ } from "../../src/vault/approvals/client.js";
30
+ import type { ApprovalDecisionMode } from "../../src/vault/approvals/schema.js";
31
+
32
+ export async function handleApprovalCallback(
33
+ ctx: Context,
34
+ data: string,
35
+ ): Promise<void> {
36
+ const parsed = parseApprovalCallback(data);
37
+ if (parsed === null) {
38
+ await ctx.answerCallbackQuery({ text: "malformed approval callback" });
39
+ return;
40
+ }
41
+
42
+ const consumed = await approvalConsume(parsed.request_id);
43
+ if (consumed === null) {
44
+ await ctx.answerCallbackQuery({ text: "approval kernel unreachable" });
45
+ return;
46
+ }
47
+ if (!consumed.consumed) {
48
+ // Single-use enforcement: someone already tapped, or the nonce
49
+ // expired/unknown. Match the RFC §8.1 wording.
50
+ await ctx.answerCallbackQuery({ text: "this prompt expired" });
51
+ return;
52
+ }
53
+
54
+ // Compute decision + ttl from the choice variant.
55
+ let decision: ApprovalDecisionMode;
56
+ let granted: boolean;
57
+ let ttl_ms: number | null = null;
58
+ let displayMode: string;
59
+ switch (parsed.choice.kind) {
60
+ case "deny":
61
+ decision = "deny";
62
+ granted = false;
63
+ displayMode = "denied";
64
+ break;
65
+ case "once":
66
+ decision = "allow_once";
67
+ granted = true;
68
+ // No expiry — recorded as a one-shot grant; the agent calls
69
+ // approval_lookup at most once, then proceeds. /approvals revoke
70
+ // can still target the row by id.
71
+ displayMode = "granted once";
72
+ break;
73
+ case "always":
74
+ decision = "allow_always";
75
+ granted = true;
76
+ displayMode = "granted always";
77
+ break;
78
+ case "ttl": {
79
+ decision = "allow_ttl";
80
+ granted = true;
81
+ const ms = ttlMsFromToken(parsed.choice.param);
82
+ if (ms === null) {
83
+ await ctx.answerCallbackQuery({ text: "bad ttl token" });
84
+ return;
85
+ }
86
+ ttl_ms = ms;
87
+ displayMode = `granted for ${parsed.choice.param}`;
88
+ break;
89
+ }
90
+ }
91
+
92
+ const granted_by_user_id = ctx.from?.id ?? 0;
93
+ // Approver set at decision time = the chat that received the card. We
94
+ // store the singleton for now; the gateway-side approver-set lookup
95
+ // (drift detection input) will widen this in the per-callsite wire-up
96
+ // when each surface migrates and starts passing access.allowFrom.
97
+ const approver_set = [String(granted_by_user_id)];
98
+
99
+ const decision_id = await approvalRecord({
100
+ request_id: parsed.request_id,
101
+ decision,
102
+ approver_set,
103
+ granted_by_user_id,
104
+ ttl_ms,
105
+ });
106
+
107
+ if (decision_id === null) {
108
+ await ctx.answerCallbackQuery({ text: "kernel record failed" });
109
+ return;
110
+ }
111
+
112
+ // Edit the original card to its post-tap state and drop the keyboard.
113
+ const icon = granted ? "✅" : "🚫";
114
+ const newBody =
115
+ `${icon} ${displayMode}` +
116
+ (granted
117
+ ? ` · /approvals revoke <code>${decision_id}</code>`
118
+ : "");
119
+
120
+ try {
121
+ await ctx.editMessageText(newBody, { parse_mode: "HTML", reply_markup: undefined });
122
+ } catch {
123
+ // Best-effort: card may have been edited or deleted under us.
124
+ }
125
+ await ctx.answerCallbackQuery({ text: granted ? "Approved" : "Denied" });
126
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Tests for the approval card primitive (RFC B §8).
3
+ */
4
+
5
+ import { describe, it, expect } from "vitest";
6
+ import {
7
+ buildApprovalCard,
8
+ parseApprovalCallback,
9
+ ttlMsFromToken,
10
+ } from "./approval-card.js";
11
+
12
+ describe("approval card", () => {
13
+ it("renders the pristine card with default buttons", () => {
14
+ const card = buildApprovalCard({
15
+ request_id: "a3f1b9c2",
16
+ agent: "klanker",
17
+ scope_humanized: "secret:OPENAI_API_KEY",
18
+ why: "needs to call OpenAI",
19
+ });
20
+ expect(card.text).toContain("klanker");
21
+ expect(card.text).toContain("secret:OPENAI_API_KEY");
22
+ expect(card.text).toContain("needs to call OpenAI");
23
+
24
+ // Validate the inline_keyboard structure carries our four callback shapes
25
+ const flat = card.reply_markup.inline_keyboard.flat();
26
+ const datas = flat.map((b) => ("callback_data" in b ? b.callback_data : ""));
27
+ expect(datas).toContain("apv:a3f1b9c2:once");
28
+ expect(datas).toContain("apv:a3f1b9c2:deny");
29
+ expect(datas).toContain("apv:a3f1b9c2:always");
30
+ });
31
+
32
+ it("respects offer_always=false and offer_ttl=true", () => {
33
+ const card = buildApprovalCard({
34
+ request_id: "a3f1b9c2",
35
+ agent: "k",
36
+ scope_humanized: "x",
37
+ offer_always: false,
38
+ offer_ttl: true,
39
+ });
40
+ const datas = card.reply_markup.inline_keyboard
41
+ .flat()
42
+ .map((b) => ("callback_data" in b ? b.callback_data : ""));
43
+ expect(datas).not.toContain("apv:a3f1b9c2:always");
44
+ expect(datas).toContain("apv:a3f1b9c2:ttl:1h");
45
+ });
46
+
47
+ it("escapes HTML metacharacters in agent and scope", () => {
48
+ const card = buildApprovalCard({
49
+ request_id: "deadbeef",
50
+ agent: "<script>",
51
+ scope_humanized: "a&b",
52
+ });
53
+ expect(card.text).not.toContain("<script>");
54
+ expect(card.text).toContain("&lt;script&gt;");
55
+ expect(card.text).toContain("a&amp;b");
56
+ });
57
+ });
58
+
59
+ describe("parseApprovalCallback", () => {
60
+ it("parses every choice variant", () => {
61
+ expect(parseApprovalCallback("apv:a3f1b9c2:once"))
62
+ .toEqual({ request_id: "a3f1b9c2", choice: { kind: "once" } });
63
+ expect(parseApprovalCallback("apv:a3f1b9c2:always"))
64
+ .toEqual({ request_id: "a3f1b9c2", choice: { kind: "always" } });
65
+ expect(parseApprovalCallback("apv:a3f1b9c2:deny"))
66
+ .toEqual({ request_id: "a3f1b9c2", choice: { kind: "deny" } });
67
+ expect(parseApprovalCallback("apv:a3f1b9c2:ttl:1h"))
68
+ .toEqual({ request_id: "a3f1b9c2", choice: { kind: "ttl", param: "1h" } });
69
+ });
70
+
71
+ it("rejects malformed prefixes and bad ids", () => {
72
+ expect(parseApprovalCallback("perm:more:abc")).toBeNull();
73
+ expect(parseApprovalCallback("apv:NOTHEX12:once")).toBeNull();
74
+ expect(parseApprovalCallback("apv:a3f1b9c2:bogus")).toBeNull();
75
+ expect(parseApprovalCallback("apv:a3f1b9c2:ttl")).toBeNull();
76
+ });
77
+ });
78
+
79
+ describe("ttlMsFromToken", () => {
80
+ it("parses h and d units", () => {
81
+ expect(ttlMsFromToken("1h")).toBe(60 * 60 * 1000);
82
+ expect(ttlMsFromToken("24h")).toBe(24 * 60 * 60 * 1000);
83
+ expect(ttlMsFromToken("7d")).toBe(7 * 24 * 60 * 60 * 1000);
84
+ });
85
+ it("rejects garbage", () => {
86
+ expect(ttlMsFromToken("0h")).toBeNull();
87
+ expect(ttlMsFromToken("1m")).toBeNull();
88
+ expect(ttlMsFromToken("h")).toBeNull();
89
+ });
90
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Generic Telegram approval card primitive (RFC B §8).
3
+ *
4
+ * One shape, every surface (secrets / vault grants / MCP tools). Builds
5
+ * the inline keyboard with [Allow once] [Allow always] [Deny] (and an
6
+ * optional [⏱ For 1h] when ttl is offered) and the matching `apv:` callback
7
+ * data. The `apv:` namespace is reserved for the approval kernel; the
8
+ * gateway dispatch table routes those callbacks into kernel.consumeNonce
9
+ * + kernel.recordDecision.
10
+ *
11
+ * Callback wire format (RFC §6.1, fits Telegram's 64-byte cap):
12
+ * apv:<8-hex request_id>:<choice>[:<param>]
13
+ * choice = once | always | deny | ttl
14
+ * param = (for ttl) 1h | 24h | 7d
15
+ */
16
+
17
+ import { InlineKeyboard } from "grammy";
18
+
19
+ export interface ApprovalCardOptions {
20
+ request_id: string; // 8-hex from kernel.requestApproval
21
+ agent: string; // shown in the title
22
+ scope_humanized: string; // human-readable scope (resolver may patch later)
23
+ why?: string; // optional context paragraph
24
+ offer_always?: boolean; // hide [Allow always] when scope is too narrow to bind a rule
25
+ offer_ttl?: boolean; // show [⏱ For 1h] secondary button
26
+ }
27
+
28
+ export interface BuiltApprovalCard {
29
+ text: string;
30
+ reply_markup: InlineKeyboard;
31
+ }
32
+
33
+ /**
34
+ * Build the pristine approval card. Granted/denied/expired states are
35
+ * rendered by the gateway after the user taps — those use editMessageText
36
+ * with a fresh body, no buttons.
37
+ */
38
+ export function buildApprovalCard(opts: ApprovalCardOptions): BuiltApprovalCard {
39
+ const lines: string[] = [];
40
+ lines.push(`🔐 <b>${escapeHtml(opts.agent)}</b> wants approval`);
41
+ lines.push(`<code>${escapeHtml(opts.scope_humanized)}</code>`);
42
+ if (opts.why && opts.why.trim().length > 0) {
43
+ lines.push("");
44
+ lines.push(escapeHtml(opts.why.trim()));
45
+ }
46
+ const text = lines.join("\n");
47
+
48
+ const kb = new InlineKeyboard()
49
+ .text("✅ Allow once", `apv:${opts.request_id}:once`)
50
+ .text("🚫 Deny", `apv:${opts.request_id}:deny`);
51
+
52
+ // Secondary row — Always + TTL when offered
53
+ const secondary: Array<[string, string]> = [];
54
+ if (opts.offer_always !== false) {
55
+ secondary.push(["🔁 Always", `apv:${opts.request_id}:always`]);
56
+ }
57
+ if (opts.offer_ttl === true) {
58
+ secondary.push(["⏱ For 1h", `apv:${opts.request_id}:ttl:1h`]);
59
+ }
60
+ if (secondary.length > 0) {
61
+ kb.row();
62
+ for (const [label, data] of secondary) {
63
+ kb.text(label, data);
64
+ }
65
+ }
66
+
67
+ return { text, reply_markup: kb };
68
+ }
69
+
70
+ /**
71
+ * Parse an `apv:` callback. Returns null on a malformed string so the
72
+ * caller can fall through to whatever generic-callback handling exists.
73
+ */
74
+ export type ApprovalChoice =
75
+ | { kind: "once" }
76
+ | { kind: "always" }
77
+ | { kind: "deny" }
78
+ | { kind: "ttl"; param: string };
79
+
80
+ export interface ParsedApprovalCallback {
81
+ request_id: string;
82
+ choice: ApprovalChoice;
83
+ }
84
+
85
+ export function parseApprovalCallback(data: string): ParsedApprovalCallback | null {
86
+ if (!data.startsWith("apv:")) return null;
87
+ const parts = data.split(":");
88
+ // apv:<id>:<choice>[:<param>]
89
+ if (parts.length < 3) return null;
90
+ const request_id = parts[1];
91
+ const choiceStr = parts[2];
92
+ if (!/^[0-9a-f]{8}$/.test(request_id ?? "")) return null;
93
+ switch (choiceStr) {
94
+ case "once":
95
+ return { request_id: request_id as string, choice: { kind: "once" } };
96
+ case "always":
97
+ return { request_id: request_id as string, choice: { kind: "always" } };
98
+ case "deny":
99
+ return { request_id: request_id as string, choice: { kind: "deny" } };
100
+ case "ttl": {
101
+ const param = parts[3];
102
+ if (!param) return null;
103
+ return { request_id: request_id as string, choice: { kind: "ttl", param } };
104
+ }
105
+ default:
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /** Map a TTL token like '1h' / '24h' / '7d' to milliseconds. */
111
+ export function ttlMsFromToken(token: string): number | null {
112
+ const m = /^(\d+)([hd])$/.exec(token);
113
+ if (!m) return null;
114
+ const n = parseInt(m[1] ?? "", 10);
115
+ if (!Number.isFinite(n) || n <= 0) return null;
116
+ const unit = m[2];
117
+ if (unit === "h") return n * 60 * 60 * 1000;
118
+ if (unit === "d") return n * 24 * 60 * 60 * 1000;
119
+ return null;
120
+ }
121
+
122
+ function escapeHtml(s: string): string {
123
+ return s
124
+ .replace(/&/g, "&amp;")
125
+ .replace(/</g, "&lt;")
126
+ .replace(/>/g, "&gt;");
127
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * `/approvals` command surface (RFC B §9).
3
+ *
4
+ * Self-contained module that registers `/approvals list` and
5
+ * `/approvals revoke <id>` against a grammy Bot. Wired in from
6
+ * `gateway.ts` with one line:
7
+ *
8
+ * import { registerApprovalsCommands } from './approvals-commands.js'
9
+ * registerApprovalsCommands(bot, { allowFromCheck })
10
+ *
11
+ * Out of scope for this commit: `/approvals add` (grant wizard) and
12
+ * `/approvals stats` (RFC §9). Those are Phase 1.5 — straightforward to
13
+ * add on top of the same client. Tracked in the migration TODO inline.
14
+ */
15
+
16
+ import type { Bot, Context } from "grammy";
17
+ import {
18
+ approvalList,
19
+ approvalRevoke,
20
+ } from "../../src/vault/approvals/client.js";
21
+
22
+ export interface RegisterApprovalsCommandsOpts {
23
+ /**
24
+ * Caller-provided gate that returns true if the message sender is
25
+ * permitted to run /approvals commands. Mirrors the existing pattern
26
+ * used by other administrative commands (e.g. /pending) — the gateway
27
+ * already has its `allowFrom` check; we don't duplicate the policy
28
+ * lookup here.
29
+ */
30
+ isApprover: (ctx: Context) => boolean | Promise<boolean>;
31
+ }
32
+
33
+ export function registerApprovalsCommands(
34
+ bot: Bot,
35
+ opts: RegisterApprovalsCommandsOpts,
36
+ ): void {
37
+ bot.command("approvals", async (ctx) => {
38
+ if (!(await opts.isApprover(ctx))) {
39
+ await ctx.reply("Not authorized to view approvals.", { reply_markup: undefined });
40
+ return;
41
+ }
42
+
43
+ const args = (ctx.match ?? "").toString().trim().split(/\s+/).filter(Boolean);
44
+ const sub = args[0]?.toLowerCase();
45
+
46
+ // /approvals → list
47
+ if (sub === undefined || sub === "list") {
48
+ const agentFilter = sub === "list" ? args[1] : undefined;
49
+ const decisions = await approvalList(agentFilter);
50
+ if (decisions === null) {
51
+ await ctx.reply("Approval kernel unreachable (vault broker not running?).");
52
+ return;
53
+ }
54
+ if (decisions.length === 0) {
55
+ await ctx.reply(
56
+ agentFilter
57
+ ? `No active approvals for <code>${escapeHtml(agentFilter)}</code>.`
58
+ : "No active approvals.",
59
+ { parse_mode: "HTML" },
60
+ );
61
+ return;
62
+ }
63
+ // Top-level summary by agent, mirroring the GitHub-style permissions UI
64
+ // referenced in RFC §9.
65
+ const byAgent = new Map<string, number>();
66
+ for (const d of decisions) byAgent.set(d.agent_unit, (byAgent.get(d.agent_unit) ?? 0) + 1);
67
+ const summary = Array.from(byAgent.entries())
68
+ .map(([a, n]) => `• <b>${escapeHtml(a)}</b>: ${n}`)
69
+ .join("\n");
70
+ const detail = decisions
71
+ .slice(0, 20)
72
+ .map((d) => {
73
+ const ttl =
74
+ d.ttl_expires_at === null
75
+ ? "always"
76
+ : `until ${new Date(d.ttl_expires_at).toISOString().slice(0, 16).replace("T", " ")}`;
77
+ return (
78
+ `<code>${escapeHtml(d.id.slice(0, 8))}</code> ` +
79
+ `${escapeHtml(d.agent_unit)} → ` +
80
+ `<code>${escapeHtml(d.scope)}</code> ` +
81
+ `(${escapeHtml(d.action)}, ${ttl}) ` +
82
+ `· /approvals revoke ${escapeHtml(d.id)}`
83
+ );
84
+ })
85
+ .join("\n");
86
+ await ctx.reply(`<b>Active approvals</b>\n\n${summary}\n\n${detail}`, {
87
+ parse_mode: "HTML",
88
+ });
89
+ return;
90
+ }
91
+
92
+ // /approvals revoke <id>
93
+ if (sub === "revoke") {
94
+ const id = args[1];
95
+ if (!id) {
96
+ await ctx.reply("Usage: <code>/approvals revoke &lt;id&gt;</code>", {
97
+ parse_mode: "HTML",
98
+ });
99
+ return;
100
+ }
101
+ const actor = ctx.from?.id?.toString() ?? "unknown";
102
+ const ok = await approvalRevoke(id, actor, "manual /approvals revoke");
103
+ if (ok === null) {
104
+ await ctx.reply("Approval kernel unreachable.");
105
+ return;
106
+ }
107
+ await ctx.reply(
108
+ ok ? `Revoked <code>${escapeHtml(id)}</code>.` : `No such active decision <code>${escapeHtml(id)}</code>.`,
109
+ { parse_mode: "HTML" },
110
+ );
111
+ return;
112
+ }
113
+
114
+ // /approvals add and /approvals stats — TODO Phase 1.5
115
+ await ctx.reply(
116
+ `Unknown subcommand <code>${escapeHtml(sub)}</code>. ` +
117
+ `Use <code>/approvals list</code> or <code>/approvals revoke &lt;id&gt;</code>. ` +
118
+ `(<code>add</code> and <code>stats</code> are coming in a follow-up.)`,
119
+ { parse_mode: "HTML" },
120
+ );
121
+ });
122
+ }
123
+
124
+ function escapeHtml(s: string): string {
125
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
126
+ }
@@ -41,7 +41,10 @@ import {
41
41
  probeGateway,
42
42
  probeQuota,
43
43
  probeHindsight,
44
- probeCronTimers,
44
+ probeScheduler,
45
+ probeBroker,
46
+ probeKernel,
47
+ probeSkills,
45
48
  watchAgentProcess,
46
49
  AGENT_LIVE_WINDOW_MS,
47
50
  AGENT_LIVE_POLL_INTERVAL_MS,
@@ -89,7 +92,16 @@ export function resolvePersonaName(
89
92
 
90
93
  export type RestartReason = 'planned' | 'graceful' | 'crash' | 'fresh'
91
94
 
92
- export type ProbeKey = 'account' | 'agent' | 'gateway' | 'quota' | 'hindsight' | 'crons'
95
+ export type ProbeKey =
96
+ | 'account'
97
+ | 'agent'
98
+ | 'gateway'
99
+ | 'quota'
100
+ | 'hindsight'
101
+ | 'scheduler'
102
+ | 'broker'
103
+ | 'kernel'
104
+ | 'skills'
93
105
 
94
106
  export type ProbeMap = Partial<Record<ProbeKey, ProbeResult | null>>
95
107
 
@@ -204,11 +216,15 @@ const PROBE_LABELS: Record<ProbeKey, string> = {
204
216
  gateway: 'Gateway',
205
217
  quota: 'Quota',
206
218
  hindsight: 'Hindsight',
207
- crons: 'Crons',
219
+ scheduler: 'Scheduler',
220
+ broker: 'Broker',
221
+ kernel: 'Kernel',
222
+ skills: 'Skills',
208
223
  }
209
224
 
210
225
  const PROBE_KEYS: ReadonlyArray<ProbeKey> = [
211
- 'account', 'agent', 'gateway', 'quota', 'hindsight', 'crons',
226
+ 'account', 'agent', 'gateway', 'quota', 'hindsight',
227
+ 'scheduler', 'broker', 'kernel', 'skills',
212
228
  ]
213
229
 
214
230
  const REASON_EMOJI: Record<RestartReason, string> = {
@@ -390,6 +406,11 @@ export interface RunProbesOpts {
390
406
  /** When true, resolve the agent PID via cgroup walk instead of MainPID
391
407
  * (which is the tmux server pid under tmux supervisor). */
392
408
  tmuxSupervisor?: boolean
409
+ /** When true, the gateway is running inside an agent docker container.
410
+ * Probes that depend on systemctl (Agent, Crons) switch to /proc walks
411
+ * and externally-managed surface text instead of execing systemctl
412
+ * (which doesn't exist in the agent image). See `runtime-mode.ts`. */
413
+ dockerMode?: boolean
393
414
  }
394
415
 
395
416
  /** Run all six probes concurrently with their own per-probe timeouts.
@@ -404,11 +425,14 @@ export async function runAllProbes(opts: RunProbesOpts): Promise<ProbeMap> {
404
425
 
405
426
  await Promise.allSettled([
406
427
  probeAccount(opts.agentDir).then(r => { probes.account = r }),
407
- probeAgentProcess(slug, { execFileImpl: opts.probeExecFileImpl, tmuxSupervisor: opts.tmuxSupervisor }).then(r => { probes.agent = r }),
428
+ probeAgentProcess(slug, { execFileImpl: opts.probeExecFileImpl, tmuxSupervisor: opts.tmuxSupervisor, dockerMode: opts.dockerMode }).then(r => { probes.agent = r }),
408
429
  probeGateway(opts.gatewayInfo).then(r => { probes.gateway = r }),
409
430
  probeQuota(claudeDir, opts.agentDir, opts.fetchImpl).then(r => { probes.quota = r }),
410
431
  probeHindsight(opts.bankName, opts.fetchImpl).then(r => { probes.hindsight = r }),
411
- probeCronTimers(slug, { execFileImpl: opts.probeExecFileImpl }).then(r => { probes.crons = r }),
432
+ probeScheduler(slug, { dockerMode: opts.dockerMode }).then(r => { probes.scheduler = r }),
433
+ probeBroker(undefined, { dockerMode: opts.dockerMode }).then(r => { probes.broker = r }),
434
+ probeKernel(undefined, { dockerMode: opts.dockerMode }).then(r => { probes.kernel = r }),
435
+ probeSkills(opts.agentDir).then(r => { probes.skills = r }),
412
436
  ])
413
437
 
414
438
  return probes
@@ -530,6 +554,7 @@ export async function startBootCard(
530
554
  sleepImpl: opts.agentLiveSleepImpl,
531
555
  execFileImpl: opts.agentLiveExecFileImpl,
532
556
  tmuxSupervisor: opts.tmuxSupervisor,
557
+ dockerMode: opts.dockerMode,
533
558
  })
534
559
 
535
560
  for await (const agentResult of watcher) {