@pi-archimedes/core 2.7.3 → 2.8.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/core",
3
- "version": "2.7.3",
3
+ "version": "2.8.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -16,6 +16,7 @@
16
16
  "main": "./src/index.ts",
17
17
  "exports": {
18
18
  ".": "./src/index.ts",
19
+ "./bridge": "./src/bridge/index.ts",
19
20
  "./bus": "./src/bus.ts",
20
21
  "./chrome": "./src/chrome.ts",
21
22
  "./text": "./src/text.ts",
@@ -0,0 +1,243 @@
1
+ // ── Bridge channel (connection layer — herdr + subagent precedents) ──────
2
+ //
3
+ // The Client (desktop) is the server (a 0600 Unix socket / Windows named
4
+ // pipe); the suite is the client (an ephemeral net.createConnection per
5
+ // message, herdr-style). This module owns the socket lifecycle:
6
+ // - sendEvent: best-effort push (initial + 2 retries at 500/1500 ms, then
7
+ // drop; coalesced to at most one in-flight connection per event type).
8
+ // - request: interactive (a single request/response over one connection;
9
+ // 5-minute timeout (unref'd) → immediate reject + socket destroy;
10
+ // connection close = immediate cancel; unreachable channel → fail fast).
11
+ // Returns a cancellable handle ({ promise, cancel }).
12
+ //
13
+ // The `active` flag does NOT depend on a successful connection (lazy,
14
+ // per-message connect). A failed connect is a no-op.
15
+
16
+ import * as net from "node:net";
17
+ import { randomUUID } from "node:crypto";
18
+
19
+ let active = false;
20
+ let socketPath: string | undefined;
21
+ let seq = 0; // starts at 0; the first frame is seq: 1
22
+
23
+ // Generation guard: bumped by __resetForTests so a stale retry chain (a retry
24
+ // setTimeout scheduled on a detached entry after a reset) dies at the reset
25
+ // boundary. Without it, the per-attempt ackTimer is never tracked in the entry
26
+ // and the socket's close → finish(false) fires AFTER the reset, scheduling a
27
+ // fresh retry timer on the now-detached entry — a timer nothing will ever
28
+ // clear (it would also cross-wire into a fresh same-event entry from the next
29
+ // test). A stale chain is a no-op when the timer fires (g !== generation).
30
+ let generation = 0;
31
+
32
+ // Coalescing guard: at most one in-flight connection per event type. While one
33
+ // is in flight, a newer sendEvent for the same event queues its payload
34
+ // (latest wins) and it is sent when the current one settles.
35
+ const inFlight = new Map<string, { queued: unknown; timer?: ReturnType<typeof setTimeout> }>();
36
+
37
+ // Track open sockets so __resetForTests can reap them (prevents test hangs).
38
+ const openSockets = new Set<net.Socket>();
39
+
40
+ export function configure(opts: { active: boolean; socketPath: string | undefined }): void {
41
+ active = opts.active;
42
+ socketPath = opts.socketPath;
43
+ }
44
+
45
+ export function isActive(): boolean {
46
+ return active;
47
+ }
48
+
49
+ function socketTarget(): string | undefined {
50
+ // env value is the bare pipe name on Windows (the Client contract — unlike
51
+ // PI_SUBAGENT_SOCKET, which is a full pipe path).
52
+ if (!socketPath) return undefined;
53
+ return process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
54
+ }
55
+
56
+ function trackSocket(socket: net.Socket): void {
57
+ openSockets.add(socket);
58
+ socket.on("close", () => openSockets.delete(socket));
59
+ }
60
+
61
+ /** Test seam: reset the module singletons and reap any open sockets. */
62
+ export function __resetForTests(): void {
63
+ active = false;
64
+ socketPath = undefined;
65
+ seq = 0;
66
+ generation++; // kill any stale retry chain scheduled on a detached entry
67
+ for (const entry of inFlight.values()) {
68
+ if (entry.timer) clearTimeout(entry.timer);
69
+ }
70
+ inFlight.clear();
71
+ for (const socket of openSockets) {
72
+ try { socket.destroy(); } catch { /* already closed */ }
73
+ }
74
+ openSockets.clear();
75
+ }
76
+
77
+ // ── Best-effort push ─────────────────────────────────────────────────────
78
+
79
+ export function sendEvent(event: string, payload: unknown): void {
80
+ const existing = inFlight.get(event);
81
+ if (existing) {
82
+ existing.queued = payload; // latest wins
83
+ return;
84
+ }
85
+ inFlight.set(event, { queued: undefined });
86
+ runAttempt(event, 0, payload);
87
+ }
88
+
89
+ function runAttempt(event: string, attempt: number, payload: unknown): void {
90
+ const entry = inFlight.get(event);
91
+ if (!entry) return;
92
+ // Consume the latest queued payload (latest wins).
93
+ const framePayload = entry.queued !== undefined ? entry.queued : payload;
94
+ entry.queued = undefined;
95
+
96
+ const target = socketTarget();
97
+ if (!target) {
98
+ settle(event); // unreachable channel → no-op
99
+ return;
100
+ }
101
+
102
+ const frame = { v: 1, type: "push", seq: ++seq, event, payload: framePayload };
103
+ const socket = net.createConnection(target);
104
+ trackSocket(socket);
105
+
106
+ let done = false;
107
+ const finish = (ok: boolean) => {
108
+ if (done) return;
109
+ done = true;
110
+ clearTimeout(ackTimer);
111
+ try { socket.destroy(); } catch { /* already closed */ }
112
+ if (ok) {
113
+ settle(event);
114
+ } else if (attempt < 2) {
115
+ // Retry: initial → 500 ms → 1500 ms → drop.
116
+ const delay = attempt === 0 ? 500 : 1500;
117
+ const g = generation; // generation guard: a stale chain (a reset happened
118
+ // between scheduling and firing) is a no-op when it fires — the timer
119
+ // dies at the reset boundary instead of leaking a retry on a detached
120
+ // entry (the close → finish(false) that schedules it fires AFTER reset).
121
+ const timer = setTimeout(() => {
122
+ if (g !== generation) return;
123
+ runAttempt(event, attempt + 1, framePayload);
124
+ }, delay);
125
+ timer.unref?.();
126
+ entry.timer = timer;
127
+ } else {
128
+ settle(event);
129
+ }
130
+ };
131
+
132
+ // No-ack window: if the connection is alive but never acks, treat it as a
133
+ // failure and retry. (The Client acks promptly after receiving a push.)
134
+ const ackTimer = setTimeout(() => finish(false), 1000);
135
+ ackTimer.unref?.();
136
+
137
+ socket.on("connect", () => {
138
+ try { socket.write(JSON.stringify(frame) + "\n"); } catch { finish(false); }
139
+ });
140
+ socket.on("data", () => finish(true)); // first data = the ack line
141
+ socket.on("error", () => finish(false));
142
+ socket.on("close", () => finish(false));
143
+ }
144
+
145
+ function settle(event: string): void {
146
+ const entry = inFlight.get(event);
147
+ if (!entry) return;
148
+ if (entry.timer) { clearTimeout(entry.timer); delete entry.timer; }
149
+ if (entry.queued !== undefined) {
150
+ // A newer payload accumulated while in flight — send it (latest wins).
151
+ const queued = entry.queued;
152
+ entry.queued = undefined;
153
+ runAttempt(event, 0, queued);
154
+ } else {
155
+ inFlight.delete(event);
156
+ }
157
+ }
158
+
159
+ // ── Interactive request ───────────────────────────────────────────────────
160
+
161
+ export function request<T>(
162
+ method: string,
163
+ params: unknown,
164
+ opts?: { toolCallId?: string; source?: string },
165
+ ): { promise: Promise<T>; cancel: () => void } {
166
+ const id = randomUUID();
167
+ const frame = {
168
+ v: 1,
169
+ type: "request",
170
+ id,
171
+ method,
172
+ source: opts?.source ?? "main",
173
+ toolCallId: opts?.toolCallId,
174
+ params,
175
+ };
176
+
177
+ let settled = false;
178
+ let socket: net.Socket | undefined;
179
+ let timer: ReturnType<typeof setTimeout> | undefined;
180
+
181
+ const promise = new Promise<T>((resolve, reject) => {
182
+ const target = socketTarget();
183
+ if (!target) {
184
+ // Unreachable channel → fail fast.
185
+ settled = true;
186
+ reject(new Error("bridge channel unreachable"));
187
+ return;
188
+ }
189
+
190
+ const finish = (err?: Error, value?: T) => {
191
+ if (settled) return;
192
+ settled = true;
193
+ if (timer) clearTimeout(timer);
194
+ try { socket?.destroy(); } catch { /* already closed */ }
195
+ if (err) reject(err);
196
+ else resolve(value as T);
197
+ };
198
+
199
+ // 5-minute timeout (unref'd) → immediate reject + socket destroy.
200
+ timer = setTimeout(() => {
201
+ finish(new Error("bridge request timed out"));
202
+ }, 5 * 60 * 1000);
203
+ timer.unref?.();
204
+
205
+ socket = net.createConnection(target);
206
+ const sock = socket; // capture non-undefined for the handlers below
207
+ trackSocket(sock);
208
+ let buffer = "";
209
+
210
+ sock.on("connect", () => {
211
+ try { sock.write(JSON.stringify(frame) + "\n"); } catch { finish(new Error("failed to write request")); }
212
+ });
213
+ sock.on("data", (chunk: Buffer) => {
214
+ buffer += chunk.toString("utf-8");
215
+ const lines = buffer.split("\n");
216
+ buffer = lines.pop() ?? "";
217
+ for (const line of lines) {
218
+ const trimmed = line.trim();
219
+ if (!trimmed) continue;
220
+ try {
221
+ const msg = JSON.parse(trimmed) as { type?: string; id?: string; result?: T; error?: string };
222
+ if (msg.type === "response" && msg.id === id) {
223
+ if (msg.error !== undefined) finish(new Error(msg.error));
224
+ else finish(undefined, msg.result);
225
+ }
226
+ } catch { /* malformed line — ignore */ }
227
+ }
228
+ });
229
+ // error / close before a response → cancel.
230
+ sock.on("error", () => finish(new Error("bridge channel error")));
231
+ sock.on("close", () => finish(new Error("bridge channel closed before response")));
232
+ });
233
+
234
+ // cancel() closes the socket (→ the promise rejects with a cancel error) and
235
+ // is idempotent. A bare Promise cannot be cancelled, so the ASK_CANCEL path
236
+ // needs this surface.
237
+ const cancel = () => {
238
+ if (settled) return;
239
+ try { socket?.destroy(); } catch { /* already closed */ }
240
+ };
241
+
242
+ return { promise, cancel };
243
+ }
@@ -0,0 +1,197 @@
1
+ // ── Bridge events (bus subscriptions — the state machine + subagent
2
+ // forwarding + push events) ─────────────────────────────────────────────
3
+ //
4
+ // Subscribes the SIX real bus events (nothing emits agent_start/agent_settled/
5
+ // session_start on the bus — those are pi extension events dispatched via
6
+ // pi.on(...) and handled in registerBridge). Bus event names map to wire names
7
+ // as explicit literals at each call site (no lookup table, no string surgery):
8
+ // COST_UPDATE → cost_update, TODOS_UPDATE → todos_update, TODOS_CLEAR →
9
+ // todos_clear. The ASK_* trio is not forwarded as pushes of their own —
10
+ // ASK_REQUEST drives the refcount + subagent forwarding, ASK_CANCEL cancels
11
+ // the pending Client request, and ASK_RESPONSE additionally triggers a
12
+ // `state` push (refcount-- + pushState).
13
+ //
14
+ // The bridge is the SOLE emitter of child-path ASK_RESPONSE (spawn.ts only
15
+ // signals via ASK_CANCEL). Every forwarded ASK_REQUEST is eventually paired
16
+ // with an ASK_RESPONSE (success, error, timeout, or ASK_CANCEL) so the
17
+ // refcount never leaks.
18
+ //
19
+ // start() is one-way (no teardown counterpart to channel.configure({active:
20
+ // false})). That's safe today because bridge activation is a process-constant
21
+ // (isBridgeMode is evaluated once at session_start and never changes within a
22
+ // process) — a future mixed-mode change (flipping active per session) would
23
+ // need a stop() counterpart or it would leak the bus subscriptions.
24
+
25
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
26
+ import { getBus, Events } from "../bus.js";
27
+ import { sendEvent, request } from "./channel.js";
28
+
29
+ let refcount = 0;
30
+ let settled = false;
31
+ let started = false; // the start() idempotency guard (sole writer is start())
32
+ let sessionCtx: ExtensionContext | undefined;
33
+
34
+ const pending = new Map<string, { source: string; toolCallId?: string; cancel: () => void }>();
35
+ const unsubscribers: Array<() => void> = [];
36
+
37
+ export function state(): "working" | "idle" | "blocked" {
38
+ return refcount > 0 ? "blocked" : (settled ? "idle" : "working");
39
+ }
40
+
41
+ function pushState(): void {
42
+ sendEvent("state", { state: state() });
43
+ }
44
+
45
+ // ── Pi extension events (NOT bus events — nothing emits them on the bus;
46
+ // they're dispatched by the extension runner via pi.on(...) and DO fire in
47
+ // RPC mode, since the RPC driver runs the same session machinery). ──────
48
+
49
+ export function onAgentStart(): void {
50
+ settled = false;
51
+ pushState();
52
+ }
53
+
54
+ export function onAgentSettled(): void {
55
+ settled = true;
56
+ pushState();
57
+ }
58
+
59
+ export function onSessionStart(ctx?: ExtensionContext): void {
60
+ if (ctx) sessionCtx = ctx;
61
+ // Re-emitted on every session_start so a lost frame is recoverable; the
62
+ // payload echoes PI_ARCHIMEDES_BRIDGE_SESSION so the Client can assert the
63
+ // connection↔session mapping rather than infer it from the socket path.
64
+ // (Does NOT set `started` — start() is the sole writer of that flag, so a
65
+ // call-order flip can't turn start() into a permanent no-op; the session
66
+ // push is unconditional by design.)
67
+ sendEvent("session", { ...sessionRefs(), bridgeSession: process.env.PI_ARCHIMEDES_BRIDGE_SESSION });
68
+ }
69
+
70
+ function sessionRefs(): Record<string, unknown> {
71
+ const sm = sessionCtx?.sessionManager;
72
+ if (!sm) return {};
73
+ const refs: Record<string, unknown> = {};
74
+ try { refs.sessionId = sm.getSessionId?.(); } catch { /* */ }
75
+ try { refs.sessionFile = sm.getSessionFile?.(); } catch { /* */ }
76
+ try { refs.cwd = sm.getCwd?.(); } catch { /* */ }
77
+ try { refs.sessionName = sm.getSessionName?.(); } catch { /* */ }
78
+ return refs;
79
+ }
80
+
81
+ /**
82
+ * Subscribe the six real bus events. Idempotent (a re-start on /reload does
83
+ * not double-subscribe — guarded by the `started` flag; the unsubscribers are
84
+ * stored so a reset can tear them down).
85
+ */
86
+ export function start(): void {
87
+ if (started) return;
88
+ started = true;
89
+
90
+ unsubscribers.push(getBus().on(Events.ASK_REQUEST, (payload: unknown) => {
91
+ const p = payload as {
92
+ source: string;
93
+ requestId: string;
94
+ toolCallId?: string;
95
+ questions: Array<{ id: string }>;
96
+ };
97
+ refcount++;
98
+ // A subagent ask is forwarded to the Client. A source === "main" ask is
99
+ // NOT forwarded here — the root's Client request comes from bridge.ask()
100
+ // directly; the bus event is for the refcount only.
101
+ if (p.source !== "main") {
102
+ const { cancel } = requestAskToClient(p);
103
+ const entry: { source: string; toolCallId?: string; cancel: () => void } = { source: p.source, cancel };
104
+ if (p.toolCallId !== undefined) entry.toolCallId = p.toolCallId;
105
+ pending.set(p.requestId, entry);
106
+ }
107
+ }));
108
+
109
+ unsubscribers.push(getBus().on(Events.ASK_RESPONSE, () => {
110
+ // Defensive floor: a spurious/duplicated ASK_RESPONSE must not drive the
111
+ // refcount negative — a negative count would make every later ask
112
+ // off-by-one (state reports working/idle while a prompt is pending, never
113
+ // blocked again). The floor keeps the machine honest. A decrement at
114
+ // refcount === 0 is a bug signal (by the module's own invariant, every
115
+ // ASK_REQUEST is paired) — log it so the protocol violation doesn't
116
+ // disappear without a trace.
117
+ if (refcount === 0) console.warn("[archimedes:bridge] unpaired ASK_RESPONSE received (refcount already 0)");
118
+ refcount = Math.max(0, refcount - 1);
119
+ pushState();
120
+ }));
121
+
122
+ unsubscribers.push(getBus().on(Events.ASK_CANCEL, (payload: unknown) => {
123
+ const p = payload as { requestId: string };
124
+ const entry = pending.get(p.requestId);
125
+ // cancel() closes the socket → the request rejects → the .catch below
126
+ // emits the cancelled ASK_RESPONSE → refcount-- + pending.delete. The
127
+ // ASK_CANCEL itself does not touch the refcount (no double-decrement).
128
+ if (entry) entry.cancel();
129
+ }));
130
+
131
+ // Bus payload types ARE the wire types — forward verbatim.
132
+ unsubscribers.push(getBus().on(Events.TODOS_UPDATE, (payload: unknown) => {
133
+ sendEvent("todos_update", payload);
134
+ }));
135
+
136
+ unsubscribers.push(getBus().on(Events.TODOS_CLEAR, (payload: unknown) => {
137
+ sendEvent("todos_clear", payload);
138
+ }));
139
+
140
+ unsubscribers.push(getBus().on(Events.COST_UPDATE, (payload: unknown) => {
141
+ sendEvent("cost_update", payload);
142
+ }));
143
+ }
144
+
145
+ /**
146
+ * Send a forwarded (subagent) ask to the Client and wire the response. The
147
+ * bridge is the sole emitter of child-path ASK_RESPONSE: on a settled ask it
148
+ * emits the Client's response verbatim; on a failure (error / timeout /
149
+ * cancel / child exit) it emits a cancelled ASK_RESPONSE so the refcount
150
+ * always pairs.
151
+ */
152
+ function requestAskToClient(
153
+ payload: {
154
+ requestId: string;
155
+ source: string;
156
+ toolCallId?: string;
157
+ questions: Array<{ id: string }>;
158
+ },
159
+ ): { cancel: () => void } {
160
+ const opts: { toolCallId?: string; source?: string } = { source: payload.source };
161
+ if (payload.toolCallId !== undefined) opts.toolCallId = payload.toolCallId;
162
+ const handle = request<{
163
+ cancelled: boolean;
164
+ results: Array<{ id: string; selectedOptions: string[]; customInput?: string }>;
165
+ }>("ask", payload.questions, opts);
166
+
167
+ handle.promise
168
+ .then((resp) => {
169
+ getBus().emit(Events.ASK_RESPONSE, {
170
+ requestId: payload.requestId,
171
+ cancelled: resp.cancelled,
172
+ results: resp.results,
173
+ });
174
+ pending.delete(payload.requestId);
175
+ })
176
+ .catch(() => {
177
+ getBus().emit(Events.ASK_RESPONSE, {
178
+ requestId: payload.requestId,
179
+ cancelled: true,
180
+ results: payload.questions.map((q) => ({ id: q.id, selectedOptions: [] })),
181
+ });
182
+ pending.delete(payload.requestId);
183
+ });
184
+
185
+ return { cancel: handle.cancel };
186
+ }
187
+
188
+ /** Test seam: reset the module singletons and tear down the bus subscriptions. */
189
+ export function __resetForTests(): void {
190
+ refcount = 0;
191
+ settled = false;
192
+ started = false;
193
+ sessionCtx = undefined;
194
+ pending.clear();
195
+ for (const u of unsubscribers) u();
196
+ unsubscribers.length = 0;
197
+ }
@@ -0,0 +1,576 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import * as net from "node:net";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import * as fs from "node:fs";
7
+ import { randomUUID } from "node:crypto";
8
+ import { getBus, Events } from "../bus.js";
9
+ import {
10
+ getBridge,
11
+ ask,
12
+ confirm,
13
+ password,
14
+ registerBridge,
15
+ BridgeInactiveError,
16
+ } from "./index.js";
17
+ import { configure, sendEvent, request, __resetForTests as resetChannel } from "./channel.js";
18
+ import {
19
+ state,
20
+ start,
21
+ onAgentStart,
22
+ onAgentSettled,
23
+ onSessionStart,
24
+ __resetForTests as resetEvents,
25
+ } from "./events.js";
26
+
27
+ // ── env helpers ─────────────────────────────────────────────────────────
28
+
29
+ const ENV_KEYS = [
30
+ "PI_ARCHIMEDES_BRIDGE",
31
+ "PI_ARCHIMEDES_BRIDGE_SOCKET",
32
+ "PI_ARCHIMEDES_BRIDGE_SESSION",
33
+ "PI_ARCHIMEDES_BRIDGE_SERVER_PID",
34
+ "PI_SUBAGENT_SOCKET",
35
+ ] as const;
36
+
37
+ const savedEnv: Record<string, string | undefined> = {};
38
+
39
+ function clearBridgeEnv(): void {
40
+ for (const k of ENV_KEYS) delete process.env[k];
41
+ }
42
+
43
+ function setBridgeEnv(overrides: Record<string, string> = {}): void {
44
+ clearBridgeEnv();
45
+ process.env.PI_ARCHIMEDES_BRIDGE = "1";
46
+ process.env.PI_ARCHIMEDES_BRIDGE_SOCKET = "/tmp/nonexistent-bridge.sock";
47
+ process.env.PI_ARCHIMEDES_BRIDGE_SESSION = "desktop-session-1";
48
+ process.env.PI_ARCHIMEDES_BRIDGE_SERVER_PID = "12345";
49
+ for (const [k, v] of Object.entries(overrides)) process.env[k] = v;
50
+ }
51
+
52
+ function makeFakeCtx(mode: "tui" | "rpc" | "json" | "print"): { mode: string } {
53
+ return { mode };
54
+ }
55
+
56
+ /** Register the bridge and fire its session_start handler with the given ctx. */
57
+ function registerAndFireSessionStart(ctx: { mode: string }): void {
58
+ const onSpy = vi.fn();
59
+ registerBridge({ on: onSpy } as unknown as ExtensionAPI);
60
+ const calls = onSpy.mock.calls as Array<[string, (e: unknown, ctx: unknown) => void]>;
61
+ const startCall = calls.find((c) => c[0] === "session_start");
62
+ expect(startCall).toBeTruthy();
63
+ startCall![1](null, ctx);
64
+ }
65
+
66
+ function tempSocketPath(): string {
67
+ return path.join(os.tmpdir(), `bridge-test-${randomUUID()}.sock`);
68
+ }
69
+
70
+ /** A minimal valid AskQuestion (matches AskRequestPayload["questions"][n]). */
71
+ function makeQuestion(overrides: Partial<{ id: string; question: string }> = {}): {
72
+ id: string;
73
+ question: string;
74
+ options: Array<{ label: string }>;
75
+ } {
76
+ return {
77
+ id: overrides.id ?? "q1",
78
+ question: overrides.question ?? "Which option?",
79
+ options: [{ label: "A" }, { label: "B" }],
80
+ };
81
+ }
82
+
83
+ function startServer(
84
+ sockPath: string,
85
+ onFrame: (frame: Record<string, unknown>) => void,
86
+ ): Promise<net.Server> {
87
+ const server = net.createServer((socket) => {
88
+ let buffer = "";
89
+ socket.on("data", (chunk: Buffer) => {
90
+ buffer += chunk.toString("utf-8");
91
+ const lines = buffer.split("\n");
92
+ buffer = lines.pop() ?? "";
93
+ for (const line of lines) {
94
+ const trimmed = line.trim();
95
+ if (!trimmed) continue;
96
+ try {
97
+ onFrame(JSON.parse(trimmed) as Record<string, unknown>);
98
+ } catch {
99
+ /* malformed — ignore */
100
+ }
101
+ }
102
+ });
103
+ socket.on("error", () => { /* connection dropped */ });
104
+ });
105
+ return new Promise((resolve) => server.listen(sockPath, () => resolve(server)));
106
+ }
107
+
108
+ beforeEach(() => {
109
+ for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
110
+ clearBridgeEnv();
111
+ resetChannel();
112
+ resetEvents();
113
+ });
114
+
115
+ afterEach(() => {
116
+ for (const k of ENV_KEYS) {
117
+ if (savedEnv[k] === undefined) delete process.env[k];
118
+ else process.env[k] = savedEnv[k];
119
+ }
120
+ clearBridgeEnv();
121
+ resetChannel();
122
+ resetEvents();
123
+ });
124
+
125
+ // ── 1. Env matrix (getBridge().active at session_start) ─────────────────
126
+
127
+ describe("env matrix (getBridge().active at session_start)", () => {
128
+ it("(a) no env → active === false", () => {
129
+ clearBridgeEnv();
130
+ registerAndFireSessionStart(makeFakeCtx("rpc"));
131
+ expect(getBridge().active).toBe(false);
132
+ });
133
+
134
+ it("(b) all env + tui mode → active === false (TUI wins)", () => {
135
+ setBridgeEnv();
136
+ registerAndFireSessionStart(makeFakeCtx("tui"));
137
+ expect(getBridge().active).toBe(false);
138
+ });
139
+
140
+ it("(c) all env + rpc mode + no PI_SUBAGENT_SOCKET → active === true", () => {
141
+ setBridgeEnv();
142
+ registerAndFireSessionStart(makeFakeCtx("rpc"));
143
+ expect(getBridge().active).toBe(true);
144
+ });
145
+
146
+ it("(d) all env + rpc mode + PI_SUBAGENT_SOCKET set → active === false (root-only)", () => {
147
+ setBridgeEnv({ PI_SUBAGENT_SOCKET: "/tmp/subagent.sock" });
148
+ registerAndFireSessionStart(makeFakeCtx("rpc"));
149
+ expect(getBridge().active).toBe(false);
150
+ });
151
+
152
+ it("missing one env var (SERVER_PID) → active === false", () => {
153
+ setBridgeEnv();
154
+ delete process.env.PI_ARCHIMEDES_BRIDGE_SERVER_PID;
155
+ registerAndFireSessionStart(makeFakeCtx("rpc"));
156
+ expect(getBridge().active).toBe(false);
157
+ });
158
+ });
159
+
160
+ // ── 2. Inactive API ──────────────────────────────────────────────────────
161
+
162
+ describe("inactive API", () => {
163
+ it("ask throws BridgeInactiveError", () => {
164
+ clearBridgeEnv();
165
+ expect(() => ask({ questions: [makeQuestion()] }, "tool-1")).toThrow(BridgeInactiveError);
166
+ expect(() => ask({ questions: [makeQuestion()] }, "tool-1")).toThrow("bridge is not active");
167
+ });
168
+
169
+ it("confirm resolves false", async () => {
170
+ clearBridgeEnv();
171
+ await expect(confirm({ command: "rm -rf /", reason: "test" })).resolves.toBe(false);
172
+ });
173
+
174
+ it("password resolves empty string", async () => {
175
+ clearBridgeEnv();
176
+ await expect(password({ command: "sudo apt install", reason: "test" })).resolves.toBe("");
177
+ });
178
+ });
179
+
180
+ // ── 3. State machine ─────────────────────────────────────────────────────
181
+
182
+ describe("state machine", () => {
183
+ it("onAgentSettled → idle; onAgentStart → working", () => {
184
+ start();
185
+ onAgentSettled();
186
+ expect(state()).toBe("idle");
187
+ onAgentStart();
188
+ expect(state()).toBe("working");
189
+ });
190
+
191
+ it("ASK_REQUEST → blocked; stays blocked through onAgentSettled; ASK_RESPONSE → idle (settled)", () => {
192
+ start();
193
+ onAgentSettled(); // settled = true
194
+ getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: "r1", questions: [makeQuestion()] });
195
+ expect(state()).toBe("blocked");
196
+ onAgentSettled(); // still blocked (refcount > 0)
197
+ expect(state()).toBe("blocked");
198
+ getBus().emit(Events.ASK_RESPONSE, { requestId: "r1", cancelled: false, results: [] });
199
+ expect(state()).toBe("idle"); // settled
200
+ });
201
+
202
+ it("ASK_RESPONSE → working (not settled)", () => {
203
+ start();
204
+ onAgentStart(); // settled = false
205
+ getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: "r2", questions: [makeQuestion()] });
206
+ expect(state()).toBe("blocked");
207
+ getBus().emit(Events.ASK_RESPONSE, { requestId: "r2", cancelled: false, results: [] });
208
+ expect(state()).toBe("working"); // not settled
209
+ });
210
+
211
+ it("an unpaired ASK_RESPONSE does not drive the refcount negative (floor at 0)", () => {
212
+ start();
213
+ onAgentSettled(); // settled = true
214
+ expect(state()).toBe("idle");
215
+
216
+ // A spurious/duplicated ASK_RESPONSE with no matching ASK_REQUEST must not
217
+ // push the refcount below zero (the floor keeps the machine honest).
218
+ getBus().emit(Events.ASK_RESPONSE, { requestId: "orphan", cancelled: false, results: [] });
219
+ expect(state()).toBe("idle"); // floor at 0 (not -1)
220
+
221
+ // A legitimate ask is still blocked (the floor prevented the negative count).
222
+ getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: "r3", questions: [makeQuestion()] });
223
+ expect(state()).toBe("blocked");
224
+
225
+ // ...and it still unblocks cleanly.
226
+ getBus().emit(Events.ASK_RESPONSE, { requestId: "r3", cancelled: false, results: [] });
227
+ expect(state()).toBe("idle"); // settled
228
+ });
229
+ });
230
+
231
+ // ── 4. ASK_CANCEL flow ───────────────────────────────────────────────────
232
+
233
+ describe("ASK_CANCEL flow", () => {
234
+ it("a cancelled subagent ask emits a cancelled ASK_RESPONSE and refcount returns to 0", async () => {
235
+ const sockPath = tempSocketPath();
236
+ const server = await startServer(sockPath, () => {
237
+ /* accept but never respond */
238
+ });
239
+
240
+ start();
241
+ configure({ active: true, socketPath: sockPath });
242
+
243
+ const responses: unknown[] = [];
244
+ const unsub = getBus().on(Events.ASK_RESPONSE, (p) => responses.push(p));
245
+
246
+ getBus().emit(Events.ASK_REQUEST, {
247
+ source: "subagent:x",
248
+ requestId: "sub-req-1",
249
+ questions: [makeQuestion()],
250
+ });
251
+ expect(state()).toBe("blocked");
252
+
253
+ // Give the forwarded request a moment to connect to the (silent) server.
254
+ await new Promise((r) => setTimeout(r, 50));
255
+
256
+ getBus().emit(Events.ASK_CANCEL, { requestId: "sub-req-1", source: "subagent:x" });
257
+
258
+ // The cancel closes the socket → the request rejects → a cancelled ASK_RESPONSE is emitted.
259
+ await vi.waitFor(() => {
260
+ expect(responses.length).toBeGreaterThanOrEqual(1);
261
+ }, 3000);
262
+
263
+ const resp = responses[0] as { requestId: string; cancelled: boolean; results: unknown[] };
264
+ expect(resp.requestId).toBe("sub-req-1");
265
+ expect(resp.cancelled).toBe(true);
266
+ expect(resp.results).toEqual([{ id: "q1", selectedOptions: [] }]);
267
+ expect(state()).toBe("working"); // refcount back to 0, not settled
268
+
269
+ unsub();
270
+ server.close();
271
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
272
+ });
273
+
274
+ it("ASK_CANCEL with no pending entry is a no-op (no crash, refcount untouched)", () => {
275
+ start();
276
+ getBus().emit(Events.ASK_CANCEL, { requestId: "does-not-exist", source: "subagent:x" });
277
+ expect(state()).toBe("working"); // refcount still 0
278
+ });
279
+ });
280
+
281
+ // ── 5. request round-trip + cancel ───────────────────────────────────────
282
+
283
+ describe("request round-trip + cancel", () => {
284
+ it("resolves with the response result", async () => {
285
+ const sockPath = tempSocketPath();
286
+ const server = await startServer(sockPath, (frame) => {
287
+ /* the server's onFrame is used below via a dedicated handler */
288
+ });
289
+ // Replace with a server that echoes a response for a matching id.
290
+ server.close();
291
+ const echoServer = net.createServer((socket) => {
292
+ let buffer = "";
293
+ socket.on("data", (chunk: Buffer) => {
294
+ buffer += chunk.toString("utf-8");
295
+ const lines = buffer.split("\n");
296
+ buffer = lines.pop() ?? "";
297
+ for (const line of lines) {
298
+ const trimmed = line.trim();
299
+ if (!trimmed) continue;
300
+ try {
301
+ const msg = JSON.parse(trimmed) as { type?: string; id?: string };
302
+ if (msg.type === "request" && msg.id) {
303
+ socket.write(JSON.stringify({ v: 1, type: "response", id: msg.id, result: "roundtrip-ok" }) + "\n");
304
+ }
305
+ } catch { /* malformed — ignore */
306
+ }
307
+ }
308
+ });
309
+ socket.on("error", () => { /* connection dropped */ });
310
+ });
311
+ await new Promise<void>((resolve) => echoServer.listen(sockPath, resolve));
312
+
313
+ configure({ active: true, socketPath: sockPath });
314
+ const { promise } = request("ask", { questions: [makeQuestion()] });
315
+ await expect(promise).resolves.toBe("roundtrip-ok");
316
+
317
+ echoServer.close();
318
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
319
+ });
320
+
321
+ it("cancel() before the response rejects with a cancel error", async () => {
322
+ const sockPath = tempSocketPath();
323
+ const server = await startServer(sockPath, () => {
324
+ /* accept but never respond */
325
+ });
326
+
327
+ configure({ active: true, socketPath: sockPath });
328
+ const { promise, cancel } = request("ask", { questions: [makeQuestion()] });
329
+ await new Promise((r) => setTimeout(r, 50)); // let the socket connect
330
+ cancel();
331
+ await expect(promise).rejects.toThrow();
332
+ // Idempotent: a second cancel is a no-op.
333
+ cancel();
334
+
335
+ server.close();
336
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
337
+ });
338
+
339
+ it("an unreachable channel fails fast (rejects on connect error)", async () => {
340
+ configure({ active: true, socketPath: "/tmp/definitely-does-not-exist-bridge.sock" });
341
+ const { promise } = request("ask", { questions: [makeQuestion()] });
342
+ await expect(promise).rejects.toThrow();
343
+ });
344
+
345
+ it("ask() with an AbortSignal cancels the request on abort", async () => {
346
+ const sockPath = tempSocketPath();
347
+ const server = await startServer(sockPath, () => {
348
+ /* accept but never respond */
349
+ });
350
+
351
+ configure({ active: true, socketPath: sockPath });
352
+ const controller = new AbortController();
353
+ const p = ask({ questions: [makeQuestion()] }, "tool-1", controller.signal);
354
+ await new Promise((r) => setTimeout(r, 50)); // let the socket connect
355
+ controller.abort();
356
+ await expect(p).rejects.toThrow();
357
+
358
+ server.close();
359
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
360
+ });
361
+
362
+ it("ask() with a PRE-ABORTED signal cancels the request immediately (no lingering)", async () => {
363
+ const sockPath = tempSocketPath();
364
+ const server = await startServer(sockPath, () => {
365
+ /* accept but never respond */
366
+ });
367
+
368
+ configure({ active: true, socketPath: sockPath });
369
+ const controller = new AbortController();
370
+ controller.abort(); // pre-aborted BEFORE ask() is invoked
371
+ const p = ask({ questions: [makeQuestion()] }, "tool-1", controller.signal);
372
+
373
+ // A pre-aborted signal never dispatches its "abort" event (it fires exactly
374
+ // once, at abort time), so addEventListener alone would leave the request
375
+ // lingering until the Client responds or the 5-minute timeout. The fix
376
+ // calls cancel() directly when the signal is already aborted → the promise
377
+ // rejects quickly (the socket-close handler settles it).
378
+ await expect(p).rejects.toThrow();
379
+
380
+ server.close();
381
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
382
+ });
383
+ });
384
+
385
+ // ── 6. seq counter ───────────────────────────────────────────────────────
386
+
387
+ describe("seq counter", () => {
388
+ it("two sendEvent calls produce seq 1 then seq 2 (coalesced, latest wins)", async () => {
389
+ const sockPath = tempSocketPath();
390
+ const frames: Array<{ seq: number; event: string; payload: unknown }> = [];
391
+ const server = net.createServer((socket) => {
392
+ let buffer = "";
393
+ socket.on("data", (chunk: Buffer) => {
394
+ buffer += chunk.toString("utf-8");
395
+ const lines = buffer.split("\n");
396
+ buffer = lines.pop() ?? "";
397
+ for (const line of lines) {
398
+ const trimmed = line.trim();
399
+ if (!trimmed) continue;
400
+ try {
401
+ const msg = JSON.parse(trimmed) as { type?: string; seq?: number; event?: string; payload?: unknown };
402
+ if (msg.type === "push") frames.push({ seq: msg.seq!, event: msg.event!, payload: msg.payload });
403
+ } catch { /* malformed — ignore */
404
+ }
405
+ }
406
+ socket.write("ack\n"); // ack so the sender destroys
407
+ });
408
+ socket.on("error", () => { /* connection dropped */ });
409
+ });
410
+ await new Promise<void>((resolve) => server.listen(sockPath, resolve));
411
+
412
+ configure({ active: true, socketPath: sockPath });
413
+ sendEvent("state", { state: "working" });
414
+ sendEvent("state", { state: "idle" }); // coalesced (latest wins)
415
+
416
+ await vi.waitFor(() => {
417
+ expect(frames.length).toBeGreaterThanOrEqual(2);
418
+ }, 3000);
419
+
420
+ expect(frames[0]!.seq).toBe(1);
421
+ expect(frames[1]!.seq).toBe(2);
422
+
423
+ server.close();
424
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
425
+ });
426
+
427
+ it("the session push is re-emitted on session_start and echoes PI_ARCHIMEDES_BRIDGE_SESSION", async () => {
428
+ const sockPath = tempSocketPath();
429
+ const frames: Array<{ event: string; payload: unknown }> = [];
430
+ const server = net.createServer((socket) => {
431
+ let buffer = "";
432
+ socket.on("data", (chunk: Buffer) => {
433
+ buffer += chunk.toString("utf-8");
434
+ const lines = buffer.split("\n");
435
+ buffer = lines.pop() ?? "";
436
+ for (const line of lines) {
437
+ const trimmed = line.trim();
438
+ if (!trimmed) continue;
439
+ try {
440
+ const msg = JSON.parse(trimmed) as { type?: string; event?: string; payload?: unknown };
441
+ if (msg.type === "push") frames.push({ event: msg.event!, payload: msg.payload! });
442
+ } catch { /* malformed — ignore */
443
+ }
444
+ }
445
+ socket.write("ack\n");
446
+ });
447
+ socket.on("error", () => { /* connection dropped */ });
448
+ });
449
+ await new Promise<void>((resolve) => server.listen(sockPath, resolve));
450
+
451
+ configure({ active: true, socketPath: sockPath });
452
+ process.env.PI_ARCHIMEDES_BRIDGE_SESSION = "my-desktop-session";
453
+ onSessionStart();
454
+
455
+ await vi.waitFor(() => {
456
+ expect(frames.some((f) => f.event === "session")).toBe(true);
457
+ }, 3000);
458
+
459
+ const sessionFrame = frames.find((f) => f.event === "session")!;
460
+ expect((sessionFrame.payload as { bridgeSession?: string }).bridgeSession).toBe("my-desktop-session");
461
+
462
+ server.close();
463
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
464
+ });
465
+ });
466
+
467
+ // ── 7. sendEvent retry/drop (unreachable channel) ────────────────────────
468
+
469
+ describe("sendEvent retry/drop", () => {
470
+ // The legitimate timeline is ~4.5 s (initial + 2 retries at 500/1500 ms +
471
+ // the 1 s no-ack window each), so give the test an explicit timeout with
472
+ // real headroom against vitest's 5 s default. A unique event name keeps
473
+ // this test hermetic: inFlight is keyed by event type, so a foreign "state"
474
+ // retry chain from an earlier test can't cross-wire into "retry-probe".
475
+ it("retries twice then drops on a channel that never acks (no hang, no leaked timer)", { timeout: 10_000 }, async () => {
476
+ const sockPath = tempSocketPath();
477
+ let connections = 0;
478
+ const server = net.createServer((socket) => {
479
+ connections++;
480
+ // Accept but never ack and never close → the sender's no-ack window
481
+ // (1 s) treats it as a failure and retries (same finish(false) path as
482
+ // an unreachable socket; the retry/backoff/drop machinery is identical).
483
+ socket.on("error", () => { /* connection dropped */ });
484
+ });
485
+ await new Promise<void>((resolve) => server.listen(sockPath, resolve));
486
+
487
+ configure({ active: true, socketPath: sockPath });
488
+
489
+ // A unique event name (not "state") so a foreign "state" retry chain from
490
+ // an earlier test can't cross-wire into this one (inFlight is keyed by
491
+ // event type).
492
+ sendEvent("retry-probe", { state: "working" });
493
+
494
+ // initial + 2 retries (500 ms + 1500 ms) → exactly 3 attempts, then drop.
495
+ await vi.waitFor(() => {
496
+ expect(connections).toBe(3);
497
+ }, 6000);
498
+
499
+ // Dropped (not looping): a short wait later it is still exactly 3 (no 4th attempt).
500
+ await new Promise((r) => setTimeout(r, 500));
501
+ expect(connections).toBe(3);
502
+
503
+ server.close();
504
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
505
+ });
506
+ });
507
+
508
+ // ── 8. subagent ask success path (end-to-end through the bridge) ─────────
509
+
510
+ describe("subagent ask success path", () => {
511
+ it("forwards to the Client and settles with the response verbatim (state unblocked)", async () => {
512
+ const sockPath = tempSocketPath();
513
+ const frames: Array<Record<string, unknown>> = [];
514
+ const server = net.createServer((socket) => {
515
+ let buffer = "";
516
+ socket.on("data", (chunk: Buffer) => {
517
+ buffer += chunk.toString("utf-8");
518
+ const lines = buffer.split("\n");
519
+ buffer = lines.pop() ?? "";
520
+ for (const line of lines) {
521
+ const trimmed = line.trim();
522
+ if (!trimmed) continue;
523
+ try {
524
+ const msg = JSON.parse(trimmed) as Record<string, unknown>;
525
+ frames.push(msg);
526
+ if (msg.type === "request") {
527
+ socket.write(JSON.stringify({
528
+ v: 1,
529
+ type: "response",
530
+ id: msg.id,
531
+ result: { cancelled: false, results: [{ id: "q1", selectedOptions: ["B"], customInput: "extra" }] },
532
+ }) + "\n");
533
+ }
534
+ } catch { /* malformed — ignore */ }
535
+ }
536
+ });
537
+ socket.on("error", () => { /* connection dropped */ });
538
+ });
539
+ await new Promise<void>((resolve) => server.listen(sockPath, resolve));
540
+
541
+ start();
542
+ configure({ active: true, socketPath: sockPath });
543
+
544
+ const responses: unknown[] = [];
545
+ const unsub = getBus().on(Events.ASK_RESPONSE, (p) => responses.push(p));
546
+
547
+ getBus().emit(Events.ASK_REQUEST, {
548
+ source: "subagent:x",
549
+ requestId: "sub-req-2",
550
+ toolCallId: "child-tool-1",
551
+ questions: [makeQuestion()],
552
+ });
553
+ expect(state()).toBe("blocked"); // refcount 1
554
+
555
+ // The Client answers → the bridge emits the paired ASK_RESPONSE verbatim
556
+ // (the same .then also drains `pending`, so the emission implies it).
557
+ await vi.waitFor(() => {
558
+ expect(responses.length).toBeGreaterThanOrEqual(1);
559
+ }, 3000);
560
+
561
+ const resp = responses[0] as { requestId: string; cancelled: boolean; results: unknown[] };
562
+ expect(resp.requestId).toBe("sub-req-2");
563
+ expect(resp.cancelled).toBe(false);
564
+ expect(resp.results).toEqual([{ id: "q1", selectedOptions: ["B"], customInput: "extra" }]);
565
+ expect(state()).toBe("working"); // refcount back to 0, not settled
566
+
567
+ // The forwarded request frame carried the source + toolCallId.
568
+ const reqFrame = frames.find((f) => f.type === "request")!;
569
+ expect(reqFrame.source).toBe("subagent:x");
570
+ expect(reqFrame.toolCallId).toBe("child-tool-1");
571
+
572
+ unsub();
573
+ server.close();
574
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
575
+ });
576
+ });
@@ -0,0 +1,116 @@
1
+ // ── Bridge public API ────────────────────────────────────────────────────
2
+ //
3
+ // getBridge() is the singleton Tasks 2-3 import. The bridge is env-gated and
4
+ // root-only: active iff (all 4 env vars present ∧ ctx.mode !== "tui" ∧
5
+ // PI_SUBAGENT_SOCKET absent), evaluated at session_start. TUI always wins; a
6
+ // subagent child (env present) is inactive. The `active` flag does not depend
7
+ // on a successful connection (lazy, per-message connect).
8
+ //
9
+ // Inactive API: ask → throws BridgeInactiveError (loud); confirm → false;
10
+ // password → "" (cancel).
11
+
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import type { AskResponsePayload, AskRequestPayload } from "../bus.js";
14
+ import * as channel from "./channel.js";
15
+ import * as events from "./events.js";
16
+
17
+ export class BridgeInactiveError extends Error {
18
+ constructor() {
19
+ super("bridge is not active");
20
+ this.name = "BridgeInactiveError";
21
+ }
22
+ }
23
+
24
+ function channelActive(): boolean {
25
+ return channel.isActive();
26
+ }
27
+
28
+ function eventsState(): "working" | "idle" | "blocked" {
29
+ return events.state();
30
+ }
31
+
32
+ export function getBridge() {
33
+ return { active: channelActive(), ask, confirm, password, state: eventsState };
34
+ }
35
+
36
+ /**
37
+ * Ask the Client (the root's direct Client request — NOT the relay/bridge bus
38
+ * subscription). The param is typed structurally from the bus payload —
39
+ * AskQuestion lives in packages/ask, which core cannot import.
40
+ */
41
+ export function ask(params: { questions: AskRequestPayload["questions"] }, toolCallId: string, signal?: AbortSignal): Promise<AskResponsePayload> {
42
+ if (!channelActive()) throw new BridgeInactiveError();
43
+ const handle = channel.request<AskResponsePayload>("ask", params, { toolCallId, source: "main" });
44
+ if (signal) {
45
+ // Abort the turn → cancel the Client request (the tool's catch converts the
46
+ // cancel-rejection into the paired cancelled ASK_RESPONSE, so the refcount
47
+ // stays balanced). A signal that is ALREADY aborted never dispatches its
48
+ // "abort" event (it fires exactly once, at abort time), so an addEventListener
49
+ // alone would leave the request lingering until the Client responds or the
50
+ // 5-minute timeout — call cancel() directly in that case. cancel() is
51
+ // idempotent and settles the promise via the socket-close handler, so both
52
+ // branches coexist safely. The listener is removed on settle; the
53
+ // .then(onFulfilled, onRejected) form (not .finally) avoids an unhandled
54
+ // rejection from the derived promise when the request rejects.
55
+ if (signal.aborted) handle.cancel();
56
+ else signal.addEventListener("abort", handle.cancel, { once: true });
57
+ handle.promise.then(
58
+ () => { signal?.removeEventListener("abort", handle.cancel); },
59
+ () => { signal?.removeEventListener("abort", handle.cancel); },
60
+ );
61
+ }
62
+ return handle.promise;
63
+ }
64
+
65
+ export function confirm(params: { command: string; reason: string }): Promise<boolean> {
66
+ if (!channelActive()) return Promise.resolve(false);
67
+ return channel.request<{ confirmed: boolean }>("confirm", params, { source: "main" }).promise
68
+ .then((r) => r.confirmed)
69
+ .catch(() => false);
70
+ }
71
+
72
+ export function password(params: { command: string; reason: string }): Promise<string> {
73
+ if (!channelActive()) return Promise.resolve("");
74
+ return channel.request<{ password?: string }>("password", params, { source: "main" }).promise
75
+ .then((r) => r.password ?? "")
76
+ .catch(() => "");
77
+ }
78
+
79
+ /**
80
+ * Bridge-mode gate. PI_ARCHIMEDES_BRIDGE_SERVER_PID is presence-checked only
81
+ * in v1: agent-side server-pid verification is unimplemented on both
82
+ * platforms (Node's `net` exposes neither `GetNamedPipeServerProcessId` nor
83
+ * `SO_PEERCRED`); the desktop-side descendant check is the real defense.
84
+ */
85
+ function isBridgeMode(ctx: { mode: string }): boolean {
86
+ return (
87
+ process.env.PI_ARCHIMEDES_BRIDGE === "1" &&
88
+ !!process.env.PI_ARCHIMEDES_BRIDGE_SOCKET &&
89
+ !!process.env.PI_ARCHIMEDES_BRIDGE_SESSION &&
90
+ !!process.env.PI_ARCHIMEDES_BRIDGE_SERVER_PID &&
91
+ ctx.mode !== "tui" &&
92
+ !process.env.PI_SUBAGENT_SOCKET
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Subscribe the pi EXTENSION events (NOT the bus — nothing emits
98
+ * agent_start/agent_settled/session_start on the bus). The extension events DO
99
+ * fire in RPC mode (the RPC driver runs the same session machinery).
100
+ */
101
+ export function registerBridge(pi: ExtensionAPI): void {
102
+ pi.on("session_start", (_e, ctx) => {
103
+ const active = isBridgeMode(ctx);
104
+ channel.configure({ active, socketPath: process.env.PI_ARCHIMEDES_BRIDGE_SOCKET });
105
+ if (active) {
106
+ events.start();
107
+ events.onSessionStart(ctx);
108
+ }
109
+ });
110
+ pi.on("agent_start", () => {
111
+ if (channelActive()) events.onAgentStart();
112
+ });
113
+ pi.on("agent_settled", () => {
114
+ if (channelActive()) events.onAgentSettled();
115
+ });
116
+ }
package/src/bus.ts CHANGED
@@ -104,6 +104,7 @@ export const Events = {
104
104
  TODOS_CLEAR: "archimedes:todos_clear",
105
105
  ASK_REQUEST: "archimedes:ask_request",
106
106
  ASK_RESPONSE: "archimedes:ask_response",
107
+ ASK_CANCEL: "archimedes:ask_cancel",
107
108
  } as const;
108
109
 
109
110
  interface TodoUpdatePayload {
@@ -118,6 +119,7 @@ interface TodoClearPayload {
118
119
  interface AskRequestPayload {
119
120
  source: string; // "subagent:<agent-name>"
120
121
  requestId: string; // unique id to match request → response
122
+ toolCallId?: string; // the tool-call id of the process that invoked the tool (carried for Client correlation)
121
123
  questions: Array<{ id: string; question: string; description?: string; options: Array<{ label: string }>; multi?: boolean; recommended?: number }>;
122
124
  }
123
125
 
@@ -127,4 +129,9 @@ interface AskResponsePayload {
127
129
  results: Array<{ id: string; selectedOptions: string[]; customInput?: string }>;
128
130
  }
129
131
 
130
- export type { CostUpdatePayload, TodoUpdatePayload, TodoClearPayload, AskRequestPayload, AskResponsePayload };
132
+ interface AskCancelPayload {
133
+ requestId: string;
134
+ source: string;
135
+ }
136
+
137
+ export type { CostUpdatePayload, TodoUpdatePayload, TodoClearPayload, AskRequestPayload, AskResponsePayload, AskCancelPayload };
package/src/index.test.ts CHANGED
@@ -92,12 +92,16 @@ function registerAndCapture(): {
92
92
  } {
93
93
  const onSpy = vi.fn();
94
94
  registerCore({ on: onSpy } as unknown as ExtensionAPI);
95
- // Top-level registrations are exactly: session_shutdown + session_start
96
- // (message_end is registered inside the session_start handler).
95
+ // Top-level registrations: registerBridge (added right after patchConsoleLog)
96
+ // registers session_start + agent_start + agent_settled FIRST, then
97
+ // registerCore registers session_shutdown + its own session_start. The core's
98
+ // session_start handler (the one that drives the editor/spin logic) is the
99
+ // LAST session_start registered — find it by position, not .find (which would
100
+ // return the bridge's no-op handler).
97
101
  const calls = onSpy.mock.calls as Array<
98
102
  [string, (event: unknown, ctx: ExtensionContext) => void]
99
103
  >;
100
- const startCall = calls.find((c) => c[0] === "session_start");
104
+ const startCall = calls.filter((c) => c[0] === "session_start").at(-1);
101
105
  const shutdownCall = calls.find((c) => c[0] === "session_shutdown");
102
106
  expect(startCall).toBeTruthy();
103
107
  expect(shutdownCall).toBeTruthy();
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import { patchThinkingRenderer } from "./thinking/patch.js";
10
10
  import { transformThinkingContent } from "./thinking/transform.js";
11
11
  import { loadCoreConfig, saveCoreConfig, DEFAULT_CORE_CONFIG, ANIMATION_STYLES, type CoreConfig } from "./config.js";
12
12
  import { initBus } from "./bus.js";
13
+ import { registerBridge } from "./bridge/index.js";
13
14
 
14
15
  // Re-export for session lifecycle management
15
16
  export { unpatchConsoleLog } from "./startup/capture.js";
@@ -132,6 +133,11 @@ export function registerCore(pi: ExtensionAPI): void {
132
133
  // Patch console.log for model scope capture
133
134
  patchConsoleLog();
134
135
 
136
+ // Bridge (env-gated local channel to the Client) — top-level, like
137
+ // patchConsoleLog (never inside a session handler, so it doesn't accumulate
138
+ // on /reload).
139
+ registerBridge(pi);
140
+
135
141
  // session_shutdown handler (top-level to prevent accumulation on /reload)
136
142
  pi.on("session_shutdown", (_event, _ctx) => {
137
143
  // Restore the Working line if we hid it, and reap the editor's spinner