@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 +2 -1
- package/src/bridge/channel.ts +243 -0
- package/src/bridge/events.ts +197 -0
- package/src/bridge/index.test.ts +576 -0
- package/src/bridge/index.ts +116 -0
- package/src/bus.ts +8 -1
- package/src/index.test.ts +7 -3
- package/src/index.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/core",
|
|
3
|
-
"version": "2.
|
|
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;
|
|
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
|
-
|
|
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
|
|
96
|
-
//
|
|
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.
|
|
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
|