switchroom 0.13.9 → 0.13.11

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 (29) hide show
  1. package/dist/cli/switchroom.js +38 -14
  2. package/dist/host-control/main.js +222 -7
  3. package/examples/switchroom.yaml +25 -7
  4. package/package.json +1 -1
  5. package/profiles/_shared/telegram-style.md.hbs +1 -1
  6. package/telegram-plugin/dist/bridge/bridge.js +23 -4
  7. package/telegram-plugin/dist/gateway/gateway.js +540 -147
  8. package/telegram-plugin/dist/server.js +23 -4
  9. package/telegram-plugin/gateway/config-approval-handler.test.ts +246 -0
  10. package/telegram-plugin/gateway/config-approval-handler.ts +284 -0
  11. package/telegram-plugin/gateway/gateway.ts +218 -25
  12. package/telegram-plugin/gateway/ipc-protocol.ts +72 -2
  13. package/telegram-plugin/gateway/ipc-server.ts +101 -0
  14. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +185 -0
  15. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +69 -0
  16. package/telegram-plugin/model-unavailable.ts +11 -1
  17. package/telegram-plugin/operator-events.fixtures.json +14 -24
  18. package/telegram-plugin/operator-events.ts +11 -2
  19. package/telegram-plugin/session-tail.ts +71 -4
  20. package/telegram-plugin/subagent-watcher.ts +39 -0
  21. package/telegram-plugin/tests/model-unavailable.test.ts +15 -2
  22. package/telegram-plugin/tests/operator-events-session-tail.test.ts +53 -2
  23. package/telegram-plugin/tests/operator-events.test.ts +14 -7
  24. package/telegram-plugin/tests/subagent-handback-decision.test.ts +112 -0
  25. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +105 -0
  26. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +61 -0
  27. package/telegram-plugin/tests/subagent-watcher.test.ts +67 -1
  28. package/telegram-plugin/uat/scenarios/jtbd-subagent-handback-dm.test.ts +95 -0
  29. package/profiles/default/CLAUDE.md +0 -193
@@ -17029,7 +17029,7 @@ function classifyInner(raw) {
17029
17029
  return "rate-limited";
17030
17030
  }
17031
17031
  if (errorType === "overloaded_error" || errorCode === "overloaded_error" || sdkCode === "overloaded_error" || message.toLowerCase().includes("overloaded_error") || message.toLowerCase().includes("overloaded")) {
17032
- return "quota-exhausted";
17032
+ return "rate-limited";
17033
17033
  }
17034
17034
  if (errorType === "agent-crashed" || errorCode === "agent-crashed") {
17035
17035
  return "agent-crashed";
@@ -17387,6 +17387,12 @@ function projectSubagentLine(line, agentId, state) {
17387
17387
  }
17388
17388
  return [];
17389
17389
  }
17390
+ function extractRetryState(obj) {
17391
+ return {
17392
+ retryAttempt: typeof obj.retryAttempt === "number" ? obj.retryAttempt : null,
17393
+ maxRetries: typeof obj.maxRetries === "number" ? obj.maxRetries : null
17394
+ };
17395
+ }
17390
17396
  function detectErrorInTranscriptLine(line) {
17391
17397
  if (!line || line.length > 2 * 1024 * 1024)
17392
17398
  return null;
@@ -17404,7 +17410,13 @@ function detectErrorInTranscriptLine(line) {
17404
17410
  const errStr = typeof obj.error === "string" ? obj.error : "";
17405
17411
  const text = extractAssistantText(obj);
17406
17412
  const kind2 = status === 429 ? "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
17407
- return { kind: kind2, raw: obj, detail: text || errStr || "api error" };
17413
+ return {
17414
+ kind: kind2,
17415
+ raw: obj,
17416
+ detail: text || errStr || "api error",
17417
+ transient: kind2 === "rate-limited",
17418
+ terminal: true
17419
+ };
17408
17420
  }
17409
17421
  const isErrorLine = type === "api_error" || type === "error";
17410
17422
  const embeddedError = typeof obj.error === "object" && obj.error != null ? obj.error : null;
@@ -17413,7 +17425,10 @@ function detectErrorInTranscriptLine(line) {
17413
17425
  const raw = embeddedError ?? obj;
17414
17426
  const kind = classifyClaudeError(embeddedError ?? obj);
17415
17427
  const detail = extractDetailMessage(embeddedError) ?? extractDetailMessage(obj) ?? String(type ?? "");
17416
- return { kind, raw, detail };
17428
+ const transient = kind === "rate-limited";
17429
+ const retry = extractRetryState(obj);
17430
+ const terminal = !transient ? true : retry.retryAttempt != null && retry.maxRetries != null ? retry.retryAttempt >= retry.maxRetries : isErrorLine;
17431
+ return { kind, raw, detail, transient, terminal };
17417
17432
  }
17418
17433
  function extractDetailMessage(obj) {
17419
17434
  if (!obj)
@@ -17535,7 +17550,11 @@ function startSessionTail(config2) {
17535
17550
  try {
17536
17551
  const errEvent = detectErrorInTranscriptLine(line);
17537
17552
  if (errEvent) {
17538
- onOperatorEvent(errEvent);
17553
+ if (errEvent.terminal || !errEvent.transient) {
17554
+ onOperatorEvent(errEvent);
17555
+ } else {
17556
+ log?.(`session-tail: transient overload suppressed (in-flight retry) kind=${errEvent.kind}`);
17557
+ }
17539
17558
  }
17540
17559
  } catch (err) {
17541
17560
  log?.(`session-tail: onOperatorEvent threw: ${err.message}`);
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Gateway-side `request_config_approval` handler tests (#1623).
3
+ *
4
+ * Focuses on the load-bearing transitions, not exhaustive plumbing:
5
+ * - Happy path: card posted, callback resolved, finalize edits.
6
+ * - Cross-agent rejection.
7
+ * - Double-tap is a no-op (second `resolvePendingConfigApproval`
8
+ * returns false and does not send a second verdict).
9
+ * - Timeout fires `verdict: "timeout"` automatically.
10
+ * - parseConfigApprovalCallback parses + rejects malformed input.
11
+ */
12
+
13
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
14
+ import {
15
+ buildConfigApprovalCardBody,
16
+ handleRequestConfigApproval,
17
+ handleRequestConfigFinalize,
18
+ parseConfigApprovalCallback,
19
+ resolvePendingConfigApproval,
20
+ _resetPendingConfigApprovalsForTest,
21
+ _peekPendingConfigApprovalForTest,
22
+ } from "./config-approval-handler.js";
23
+ import type { RequestConfigApprovalMessage } from "./ipc-protocol.js";
24
+
25
+ const baseMsg: RequestConfigApprovalMessage = {
26
+ type: "request_config_approval",
27
+ requestId: "req-1",
28
+ agentName: "klanker",
29
+ reason: "tighten doctor schedule",
30
+ unifiedDiff: "--- a/x\n+++ b/x\n@@\n-a\n+b\n",
31
+ timeoutMs: 60_000,
32
+ };
33
+
34
+ function fakeDeps(overrides: Partial<Parameters<typeof handleRequestConfigApproval>[2]> = {}) {
35
+ const sent: Array<{ type: string; [k: string]: unknown }> = [];
36
+ const client = {
37
+ send: (m: { type: string; [k: string]: unknown }) => {
38
+ sent.push(m);
39
+ },
40
+ };
41
+ const editCalls: Array<{
42
+ chatId: number | string;
43
+ messageId: number;
44
+ text: string;
45
+ }> = [];
46
+ const deps = {
47
+ agentName: "klanker",
48
+ loadTargetChat: () => ({ chatId: 42 }),
49
+ postCard: vi.fn(async () => ({ messageId: 1001 })),
50
+ buildKeyboard: () => ({ inline_keyboard: [] }),
51
+ editCard: async (a: { chatId: number | string; messageId: number; text: string }) => {
52
+ editCalls.push(a);
53
+ },
54
+ log: () => {},
55
+ ...overrides,
56
+ };
57
+ return { client, sent, deps, editCalls };
58
+ }
59
+
60
+ beforeEach(() => {
61
+ _resetPendingConfigApprovalsForTest();
62
+ });
63
+ afterEach(() => {
64
+ _resetPendingConfigApprovalsForTest();
65
+ });
66
+
67
+ describe("buildConfigApprovalCardBody", () => {
68
+ it("HTML-escapes the diff body so `<` / `&` can't break out of the <pre> block", () => {
69
+ const body = buildConfigApprovalCardBody({
70
+ agentName: "klanker",
71
+ reason: "<script>",
72
+ unifiedDiff: "a & b <c>",
73
+ });
74
+ expect(body).toContain("&lt;script&gt;");
75
+ expect(body).toContain("a &amp; b &lt;c&gt;");
76
+ });
77
+ });
78
+
79
+ describe("handleRequestConfigApproval", () => {
80
+ it("posts the card, registers a pending entry, and stays open until resolved", async () => {
81
+ const { client, deps } = fakeDeps();
82
+ await handleRequestConfigApproval(client, baseMsg, deps);
83
+ expect(deps.postCard).toHaveBeenCalledTimes(1);
84
+ const pending = _peekPendingConfigApprovalForTest("req-1");
85
+ expect(pending).toBeDefined();
86
+ expect(pending!.messageId).toBe(1001);
87
+ });
88
+
89
+ it("rejects a cross-agent request without posting a card", async () => {
90
+ const { client, sent, deps } = fakeDeps();
91
+ await handleRequestConfigApproval(
92
+ client,
93
+ { ...baseMsg, agentName: "evilpeer" },
94
+ deps,
95
+ );
96
+ expect(deps.postCard).not.toHaveBeenCalled();
97
+ expect(sent).toEqual([
98
+ {
99
+ type: "config_approval_resolved",
100
+ requestId: "req-1",
101
+ verdict: "deny",
102
+ reason: expect.stringContaining("gateway serves 'klanker'"),
103
+ },
104
+ ]);
105
+ });
106
+
107
+ it("rejects when no target chat is paired", async () => {
108
+ const { client, sent, deps } = fakeDeps({ loadTargetChat: () => null });
109
+ await handleRequestConfigApproval(client, baseMsg, deps);
110
+ expect(sent[0]!.verdict).toBe("deny");
111
+ expect(sent[0]!.reason).toMatch(/not paired/);
112
+ });
113
+
114
+ it("rejects when postCard fails (Telegram down)", async () => {
115
+ const { client, sent, deps } = fakeDeps({
116
+ postCard: vi.fn(async () => null),
117
+ });
118
+ await handleRequestConfigApproval(client, baseMsg, deps);
119
+ expect(sent[0]!.verdict).toBe("deny");
120
+ expect(sent[0]!.reason).toMatch(/sendMessage failed/);
121
+ });
122
+ });
123
+
124
+ describe("resolvePendingConfigApproval — double-tap and verdict propagation", () => {
125
+ it("first tap resolves; second tap is a no-op", async () => {
126
+ const { client, sent, deps, editCalls } = fakeDeps();
127
+ await handleRequestConfigApproval(client, baseMsg, deps);
128
+ const first = await resolvePendingConfigApproval("req-1", "approve", deps);
129
+ expect(first).toBe(true);
130
+ const second = await resolvePendingConfigApproval("req-1", "deny", deps);
131
+ expect(second).toBe(false);
132
+ // Only one verdict crossed the wire to hostd.
133
+ const verdicts = sent.filter((s) => s.type === "config_approval_resolved");
134
+ expect(verdicts.length).toBe(1);
135
+ expect(verdicts[0]!.verdict).toBe("approve");
136
+ // Card edited once to the interim 'Applying' state.
137
+ expect(editCalls.length).toBe(1);
138
+ expect(editCalls[0]!.text).toMatch(/Applying/);
139
+ });
140
+
141
+ it("returns false when no entry exists (unknown requestId)", async () => {
142
+ const { deps } = fakeDeps();
143
+ const r = await resolvePendingConfigApproval("unknown", "approve", deps);
144
+ expect(r).toBe(false);
145
+ });
146
+ });
147
+
148
+ describe("timeout path", () => {
149
+ it("auto-fires verdict 'timeout' after timeoutMs and edits card to '⏱ Expired'", async () => {
150
+ vi.useFakeTimers();
151
+ try {
152
+ const { client, sent, deps, editCalls } = fakeDeps();
153
+ await handleRequestConfigApproval(
154
+ client,
155
+ { ...baseMsg, timeoutMs: 1000 },
156
+ deps,
157
+ );
158
+ vi.advanceTimersByTime(1500);
159
+ // Allow microtasks scheduled inside the timer callback to flush.
160
+ // NOT vi.runAllTimersAsync() — that is unimplemented under bun's
161
+ // vitest-compat shim and this suite also runs under `bun test`
162
+ // (CLAUDE.md § "Import the right runner"). advanceTimersByTime
163
+ // already fired the timer synchronously; we only need to drain
164
+ // the microtask queue the timer's async callback scheduled.
165
+ for (let i = 0; i < 8; i++) await Promise.resolve();
166
+ const verdicts = sent.filter((s) => s.type === "config_approval_resolved");
167
+ expect(verdicts.length).toBe(1);
168
+ expect(verdicts[0]!.verdict).toBe("timeout");
169
+ expect(editCalls[0]!.text).toMatch(/Expired/);
170
+ } finally {
171
+ vi.useRealTimers();
172
+ }
173
+ });
174
+ });
175
+
176
+ describe("handleRequestConfigFinalize", () => {
177
+ it("edits the card to '✅ Applied' on success", async () => {
178
+ const { client, deps, editCalls } = fakeDeps();
179
+ await handleRequestConfigApproval(client, baseMsg, deps);
180
+ await resolvePendingConfigApproval("req-1", "approve", deps);
181
+ await handleRequestConfigFinalize(
182
+ client,
183
+ {
184
+ type: "request_config_finalize",
185
+ requestId: "req-1",
186
+ outcome: "applied",
187
+ },
188
+ deps,
189
+ );
190
+ const last = editCalls[editCalls.length - 1]!;
191
+ expect(last.text).toMatch(/Applied/);
192
+ });
193
+
194
+ it("edits to '⚠️ Reconcile failed; rolled back' with detail", async () => {
195
+ const { client, deps, editCalls } = fakeDeps();
196
+ await handleRequestConfigApproval(client, baseMsg, deps);
197
+ await resolvePendingConfigApproval("req-1", "approve", deps);
198
+ await handleRequestConfigFinalize(
199
+ client,
200
+ {
201
+ type: "request_config_finalize",
202
+ requestId: "req-1",
203
+ outcome: "reconcile_failed_rolled_back",
204
+ detail: "rolled back successfully",
205
+ },
206
+ deps,
207
+ );
208
+ const last = editCalls[editCalls.length - 1]!;
209
+ expect(last.text).toMatch(/Reconcile failed/);
210
+ expect(last.text).toMatch(/rolled back successfully/);
211
+ });
212
+
213
+ it("is a no-op when no pending entry exists for the requestId", async () => {
214
+ const { client, deps, editCalls } = fakeDeps();
215
+ await handleRequestConfigFinalize(
216
+ client,
217
+ {
218
+ type: "request_config_finalize",
219
+ requestId: "missing",
220
+ outcome: "applied",
221
+ },
222
+ deps,
223
+ );
224
+ expect(editCalls.length).toBe(0);
225
+ });
226
+ });
227
+
228
+ describe("parseConfigApprovalCallback", () => {
229
+ it("parses well-formed callbacks", () => {
230
+ expect(parseConfigApprovalCallback("cfg:abc:approve")).toEqual({
231
+ requestId: "abc",
232
+ choice: "approve",
233
+ });
234
+ expect(parseConfigApprovalCallback("cfg:deadbeef:deny")).toEqual({
235
+ requestId: "deadbeef",
236
+ choice: "deny",
237
+ });
238
+ });
239
+
240
+ it("rejects malformed input", () => {
241
+ expect(parseConfigApprovalCallback("apv:abc:once")).toBeNull();
242
+ expect(parseConfigApprovalCallback("cfg:")).toBeNull();
243
+ expect(parseConfigApprovalCallback("cfg:abc:bogus")).toBeNull();
244
+ expect(parseConfigApprovalCallback("cfg::approve")).toBeNull();
245
+ });
246
+ });
@@ -0,0 +1,284 @@
1
+ // hostd config-edit approval handler (#1623 / RFC §3.3): posts an approval
2
+ // card, resolves the verdict back to hostd over IPC, and flips the card to
3
+ // a terminal state on finalize.
4
+
5
+ import type { IpcClient } from "./ipc-server.js";
6
+ import type {
7
+ RequestConfigApprovalMessage,
8
+ RequestConfigFinalizeMessage,
9
+ } from "./ipc-protocol.js";
10
+
11
+ /** Pending approval state — in-memory only (no SQLite per RFC §3.4). */
12
+ interface PendingConfigApproval {
13
+ requestId: string;
14
+ client: Pick<IpcClient, "send">;
15
+ chatId: number | string;
16
+ threadId?: number;
17
+ messageId: number;
18
+ /** node:Timeout — set when the timer is armed; cleared once the
19
+ * request resolves (approve/deny/timeout) to make resolve idempotent. */
20
+ timer: ReturnType<typeof setTimeout> | null;
21
+ /** Has a verdict already been sent? Guards against double-tap. */
22
+ resolved: boolean;
23
+ }
24
+
25
+ const pending = new Map<string, PendingConfigApproval>();
26
+
27
+ // Injected deps — gateway.ts wires these from the existing surface.
28
+
29
+ export interface ConfigApprovalHandlerDeps {
30
+ /** This gateway's agent name — cross-agent requests rejected. */
31
+ agentName: string;
32
+ /** Operator's primary chat for the card. Returns null if not paired. */
33
+ loadTargetChat: () => {
34
+ chatId: number | string;
35
+ threadId?: number;
36
+ } | null;
37
+ /** Post the Telegram card. Returns the posted message id on success. */
38
+ postCard: (args: {
39
+ chatId: number | string;
40
+ threadId?: number;
41
+ text: string;
42
+ /** grammy InlineKeyboard, passed through verbatim. */
43
+ replyMarkup: unknown;
44
+ }) => Promise<{ messageId: number } | null>;
45
+ /** Build the inline keyboard with [✅ Approve] [🚫 Deny] buttons. */
46
+ buildKeyboard: (requestId: string) => unknown;
47
+ /** Edit a posted card to a new body. Best-effort — failures logged. */
48
+ editCard: (args: {
49
+ chatId: number | string;
50
+ messageId: number;
51
+ text: string;
52
+ }) => Promise<void>;
53
+ log?: (msg: string) => void;
54
+ }
55
+
56
+ /**
57
+ * Build the card body (HTML). Renders the full diff in a `<pre>`
58
+ * code block — Telegram caps messages at 4096 chars, so very large
59
+ * diffs may be truncated by the API; the validator already caps
60
+ * unified_diff at ~63 KiB so practical fleet edits fit comfortably.
61
+ */
62
+ export function buildConfigApprovalCardBody(args: {
63
+ agentName: string;
64
+ reason: string;
65
+ unifiedDiff: string;
66
+ }): string {
67
+ const esc = (s: string) =>
68
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
69
+ return (
70
+ `🛠 <b>Config edit proposed</b>\n` +
71
+ `Agent: <code>${esc(args.agentName)}</code>\n` +
72
+ `Reason: ${esc(args.reason)}\n\n` +
73
+ `<pre>${esc(args.unifiedDiff)}</pre>`
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Top-level handler — called by the IPC dispatcher.
79
+ */
80
+ export async function handleRequestConfigApproval(
81
+ client: Pick<IpcClient, "send">,
82
+ msg: RequestConfigApprovalMessage,
83
+ deps: ConfigApprovalHandlerDeps,
84
+ ): Promise<void> {
85
+ const reply = (
86
+ verdict: "approve" | "deny" | "timeout",
87
+ reason?: string,
88
+ ) => {
89
+ try {
90
+ client.send({
91
+ type: "config_approval_resolved",
92
+ requestId: msg.requestId,
93
+ verdict,
94
+ ...(reason ? { reason } : {}),
95
+ });
96
+ } catch (err) {
97
+ deps.log?.(
98
+ `config_approval_resolved send failed (requestId=${msg.requestId}): ${(err as Error).message}`,
99
+ );
100
+ }
101
+ };
102
+
103
+ if (msg.agentName !== deps.agentName) {
104
+ reply("deny", `gateway serves '${deps.agentName}', not '${msg.agentName}'`);
105
+ return;
106
+ }
107
+
108
+ const target = deps.loadTargetChat();
109
+ if (target === null) {
110
+ reply("deny", "no target chat available — operator not paired?");
111
+ return;
112
+ }
113
+
114
+ const body = buildConfigApprovalCardBody({
115
+ agentName: msg.agentName,
116
+ reason: msg.reason,
117
+ unifiedDiff: msg.unifiedDiff,
118
+ });
119
+ const replyMarkup = deps.buildKeyboard(msg.requestId);
120
+
121
+ const posted = await deps.postCard({
122
+ chatId: target.chatId,
123
+ ...(target.threadId !== undefined ? { threadId: target.threadId } : {}),
124
+ text: body,
125
+ replyMarkup,
126
+ });
127
+ if (posted === null) {
128
+ reply("deny", "Telegram sendMessage failed");
129
+ return;
130
+ }
131
+
132
+ const entry: PendingConfigApproval = {
133
+ requestId: msg.requestId,
134
+ client,
135
+ chatId: target.chatId,
136
+ ...(target.threadId !== undefined ? { threadId: target.threadId } : {}),
137
+ messageId: posted.messageId,
138
+ timer: null,
139
+ resolved: false,
140
+ };
141
+ entry.timer = setTimeout(() => {
142
+ void resolvePendingConfigApproval(msg.requestId, "timeout", deps).catch(
143
+ (err) =>
144
+ deps.log?.(
145
+ `config approval timeout handler threw (requestId=${msg.requestId}): ${(err as Error).message}`,
146
+ ),
147
+ );
148
+ }, msg.timeoutMs);
149
+ pending.set(msg.requestId, entry);
150
+
151
+ deps.log?.(
152
+ `config_approval_posted requestId=${msg.requestId} agent=${msg.agentName} messageId=${posted.messageId}`,
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Called by the `cfg:` callback dispatcher in gateway.ts on an
158
+ * operator tap, by the per-request timer on expiry, OR by the
159
+ * finalize path defensively before edit. Sends a single
160
+ * `config_approval_resolved` reply over the original client
161
+ * connection and edits the card to the interim state. Double-tap
162
+ * safe — subsequent calls for the same requestId are no-ops.
163
+ *
164
+ * Returns true if THIS call resolved the request (first call wins),
165
+ * false if it was already resolved.
166
+ */
167
+ export async function resolvePendingConfigApproval(
168
+ requestId: string,
169
+ verdict: "approve" | "deny" | "timeout",
170
+ deps: Pick<ConfigApprovalHandlerDeps, "editCard" | "log">,
171
+ ): Promise<boolean> {
172
+ const entry = pending.get(requestId);
173
+ if (!entry || entry.resolved) return false;
174
+ entry.resolved = true;
175
+ if (entry.timer !== null) {
176
+ clearTimeout(entry.timer);
177
+ entry.timer = null;
178
+ }
179
+
180
+ // Send the verdict back to hostd. Best effort — if the IPC
181
+ // connection has dropped, hostd's own timeout will fire.
182
+ try {
183
+ entry.client.send({
184
+ type: "config_approval_resolved",
185
+ requestId,
186
+ verdict,
187
+ });
188
+ } catch (err) {
189
+ deps.log?.(
190
+ `config_approval_resolved send failed (requestId=${requestId}): ${(err as Error).message}`,
191
+ );
192
+ }
193
+
194
+ // Edit the card to an interim/terminal state.
195
+ const interim =
196
+ verdict === "approve"
197
+ ? "👀 <b>Applying…</b>"
198
+ : verdict === "deny"
199
+ ? "🚫 <b>Denied</b>"
200
+ : "⏱ <b>Expired</b>";
201
+ try {
202
+ await deps.editCard({
203
+ chatId: entry.chatId,
204
+ messageId: entry.messageId,
205
+ text: interim,
206
+ });
207
+ } catch (err) {
208
+ deps.log?.(
209
+ `config approval card edit failed (requestId=${requestId}): ${(err as Error).message}`,
210
+ );
211
+ }
212
+ return true;
213
+ }
214
+
215
+ /** IPC `request_config_finalize` handler — edits the card to the terminal outcome. */
216
+ export async function handleRequestConfigFinalize(
217
+ _client: Pick<IpcClient, "send">,
218
+ msg: RequestConfigFinalizeMessage,
219
+ deps: Pick<ConfigApprovalHandlerDeps, "editCard" | "log">,
220
+ ): Promise<void> {
221
+ const entry = pending.get(msg.requestId);
222
+ if (!entry) {
223
+ deps.log?.(
224
+ `config_finalize: no pending entry for requestId=${msg.requestId} (likely already cleaned up)`,
225
+ );
226
+ return;
227
+ }
228
+ // Clean up the pending entry — finalize is the terminal transition.
229
+ pending.delete(msg.requestId);
230
+
231
+ const body =
232
+ msg.outcome === "applied"
233
+ ? `✅ <b>Applied</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}`
234
+ : `⚠️ <b>Reconcile failed; rolled back</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}`;
235
+ try {
236
+ await deps.editCard({
237
+ chatId: entry.chatId,
238
+ messageId: entry.messageId,
239
+ text: body,
240
+ });
241
+ } catch (err) {
242
+ deps.log?.(
243
+ `config finalize card edit failed (requestId=${msg.requestId}): ${(err as Error).message}`,
244
+ );
245
+ }
246
+ }
247
+
248
+ function escapeHtml(s: string): string {
249
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
250
+ }
251
+
252
+ // Test-only: clear the in-memory pending map between cases.
253
+ export function _resetPendingConfigApprovalsForTest(): void {
254
+ for (const entry of pending.values()) {
255
+ if (entry.timer !== null) clearTimeout(entry.timer);
256
+ }
257
+ pending.clear();
258
+ }
259
+
260
+ // Test-only: peek at a pending entry.
261
+ export function _peekPendingConfigApprovalForTest(
262
+ requestId: string,
263
+ ): Readonly<PendingConfigApproval> | undefined {
264
+ return pending.get(requestId);
265
+ }
266
+
267
+ /**
268
+ * Parse `cfg:<requestId>:<choice>` callback data. Returns null on
269
+ * malformed input. The callback handler in gateway.ts uses this +
270
+ * resolvePendingConfigApproval to drive the tap → resolve flow.
271
+ */
272
+ export function parseConfigApprovalCallback(
273
+ data: string,
274
+ ): { requestId: string; choice: "approve" | "deny" } | null {
275
+ if (!data.startsWith("cfg:")) return null;
276
+ const rest = data.slice(4);
277
+ const colon = rest.lastIndexOf(":");
278
+ if (colon < 0) return null;
279
+ const requestId = rest.slice(0, colon);
280
+ const choice = rest.slice(colon + 1);
281
+ if (requestId.length === 0 || requestId.length > 64) return null;
282
+ if (choice !== "approve" && choice !== "deny") return null;
283
+ return { requestId, choice };
284
+ }