@pi-archimedes/core 2.7.3 → 2.9.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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @pi-archimedes/core
2
2
 
3
- **A terminal worth spending your day in — plus the plumbing that keeps the rest of the suite talking.**
3
+ **Foundational non-UI runtime for the Pi Archimedes suite.**
4
4
 
5
- Core is the face of every session: the animated splash screen on launch, the framed editor where you type, the border spinner that works while the agent works, and clean, labelled thinking blocks. Under the surface it runs the shared event bus through which the other components pass costs, todos, and questions — and the text, colour, and settings utilities they all build on.
5
+ Core is the foundational runtime that keeps the rest of the suite talking. It provides the shared event bus, message handling, settings-io, pure text/color/tool-render utilities, overlay chrome, and a startup profiler.
6
6
 
7
7
  ## Install
8
8
 
@@ -12,7 +12,7 @@ Standalone:
12
12
  pi install npm:@pi-archimedes/core
13
13
  ```
14
14
 
15
- Or the full suite instead (which includes core and the ten optional components):
15
+ Or the full suite instead:
16
16
 
17
17
  ```bash
18
18
  pi install npm:pi-archimedes
@@ -24,37 +24,16 @@ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0:
24
24
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent
25
25
  ```
26
26
 
27
- After installing Pi, choose one installation command above, then `cd` into your project and run `pi`. Inside the session, `/login` signs you into a supported provider and `/model` picks a model; the full walkthrough, including API-key setup, is in the repo's [setup section](https://github.com/danielcherubini/pi-archimedes#setup) and Pi's own [quickstart](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/quickstart.md). A session that's already running picks up the extension with `/reload`.
27
+ After installing Pi, choose one installation command above, then `cd` into your project and run `pi`. Inside the session, `/login` signs you into a supported provider and `/model` picks a model.
28
28
 
29
29
  ## What you get
30
30
 
31
- - **Splash screen** — an animated greeting when the session launches, in one of nine reveal styles (`diagonal`, `top-right`, `bottom-left`, `bottom-right`, `center-out`, `wave`, `horizontal`, `vertical`, `vertical-up`).
32
- - **Framed editor** — your input in a clean bordered frame, with a double-press guard on the quit key (`Ctrl+C` by default) so a stray keystroke doesn't end the session.
33
- - **Working spinner on the border** — one of ten animating styles (`pendulum`, `typing`, `pulse`, `marquee`, `wave-rows`, `columns`, `cascade`, `diagonal-swipe`, `rain`, `sparkle`) traces the editor frame while the agent works, replacing Pi's native "Working" line. When `editorSpinLabel` is at its default, the label switches to a random quip per busy episode — re-picked on a subtle random 15–45 s timer while long episodes continue; set a custom value to pin a label (an empty value still hides it).
34
- - **Thinking blocks** — chain-of-thought output gets a consistent label, colour, and layout; `codeUnindent` strips the common indentation so code in reasoning reads flush.
35
31
  - **The bus** (`@pi-archimedes/core/bus`) — a global pub/sub event system: `COST_UPDATE`, `ASK_REQUEST`, `TODOS_UPDATE`, `TODOS_CLEAR`… subagent costs flow to the footer through it, subagent todos to the task board, subagent questions to the ask UI.
36
32
  - **Shared utilities** — text truncation and width measurement, colour formatting, settings I/O, and startup profiling.
37
-
38
- ## Settings
39
-
40
- Settings live in `~/.pi/agent/settings.json` under `archimedes.core`. The file is **strict JSON — no comments or trailing commas** (unlike the MCP server config files, which accept both).
41
-
42
- | Setting | Type | Default | Description |
43
- |---------|------|---------|-------------|
44
- | `editorSpinBorder` | bool | `true` | Show the animated border spinner while the agent works |
45
- | `editorSpinStyle` | string | `pendulum` | One of the ten spinner styles |
46
- | `editorSpinSpeed` | string | `normal` | `slow`, `normal`, or `fast` |
47
- | `editorSpinLabel` | string | `Working` | Label shown alongside the border spinner; the default is replaced by a random quip per busy episode (re-picked on a subtle random 15–45 s timer while long episodes continue) — set a custom value to pin it (empty still hides it) |
48
- | `animationStyle` | string | `vertical-up` | Splash-screen reveal style (the nine styles above) |
49
- | `labelText` | string | `Thinking...` | Prefix before thinking blocks |
50
- | `labelColor` | string | `255,215,0` | RGB string for the thinking label |
51
- | `codeUnindent` | bool | `true` | Strip common indentation from code blocks in thinking sections |
52
- | `mutedTheme` | bool | `false` | Stored, but **not yet effective** — the current thinking renderer doesn't consult it, so treat it as a pending toggle |
53
-
54
- In the suite, `/archimedes` offers panel controls for the settings that have them; `/reload` applies any that are read at startup.
33
+ - **Overlay Chrome** — shared base for UI components.
55
34
 
56
35
  ## Part of the suite
57
36
 
58
- In [pi-archimedes](https://github.com/danielcherubini/pi-archimedes), core is always registered — it isn't one of the `/plugins` toggles — and it underpins what the other components share: the bus that feeds subagent costs to the footer, subagent todos to the task board, and subagent questions to the ask UI, plus the chrome and colour utilities the TUIs use. The diff renderer is standalone and does not depend on core's chrome — it only shares the suite when it loads.
37
+ In [pi-archimedes](https://github.com/danielcherubini/pi-archimedes), core is always registered — it isn't one of the `/plugins` toggles — and it underpins what the other components share: the bus that feeds subagent costs to the footer, subagent todos to the task board, and subagent questions to the ask UI. The diff renderer is standalone and does not depend on core's chrome — it only shares the suite when it loads.
59
38
 
60
39
  ← [Back to pi-archimedes](https://github.com/danielcherubini/pi-archimedes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/core",
3
- "version": "2.7.3",
3
+ "version": "2.9.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -9,13 +9,15 @@
9
9
  "keywords": [
10
10
  "pi-package"
11
11
  ],
12
- "description": "Core UI modules for pi-archimedes: editor, message, startup, thinking",
12
+ "description": "Core primitives for pi-archimedes",
13
13
  "files": [
14
14
  "src"
15
15
  ],
16
16
  "main": "./src/index.ts",
17
17
  "exports": {
18
18
  ".": "./src/index.ts",
19
+ "./bridge": "./src/bridge/index.ts",
20
+ "./bridge/channel": "./src/bridge/channel.ts",
19
21
  "./bus": "./src/bus.ts",
20
22
  "./chrome": "./src/chrome.ts",
21
23
  "./text": "./src/text.ts",
@@ -27,12 +29,12 @@
27
29
  "./tool-render": "./src/tool-render.ts"
28
30
  },
29
31
  "peerDependencies": {
30
- "@earendil-works/pi-coding-agent": ">=0.1.0",
31
- "@earendil-works/pi-tui": ">=0.1.0"
32
+ "@earendil-works/pi-coding-agent": ">=0.85.0",
33
+ "@earendil-works/pi-tui": ">=0.85.0"
32
34
  },
33
35
  "devDependencies": {
34
- "@earendil-works/pi-coding-agent": "^0.85.1",
35
- "@earendil-works/pi-tui": "^0.85.1",
36
+ "@earendil-works/pi-coding-agent": "^0.87.0",
37
+ "@earendil-works/pi-tui": "^0.87.0",
36
38
  "typescript": "^6.0.0"
37
39
  },
38
40
  "pi": {
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import {
3
+ request,
4
+ configure,
5
+ __resetForTests,
6
+ BridgeCancelledError,
7
+ BridgeTransportError,
8
+ } from "./channel.js";
9
+
10
+ // The cancel contract between core and the subagent package is TYPED:
11
+ // dispatchViaBridge matches `instanceof BridgeCancelledError` (not the
12
+ // message string). These tests pin that contract at the source.
13
+
14
+ afterEach(() => {
15
+ __resetForTests();
16
+ });
17
+
18
+ describe("request() cancel contract", () => {
19
+ it("cancel() settles the promise deterministically with a BridgeCancelledError", async () => {
20
+ // Unreachable socket path: the connect fails asynchronously; the
21
+ // synchronous cancel() wins the race and settles first.
22
+ configure({ active: true, socketPath: "/tmp/bridge-does-not-exist-xyz" });
23
+ const handle = request("test_method", { a: 1 });
24
+ handle.cancel();
25
+ await expect(handle.promise).rejects.toBeInstanceOf(BridgeCancelledError);
26
+ await expect(handle.promise).rejects.toHaveProperty("name", "BridgeCancelledError");
27
+ });
28
+
29
+ it("a BridgeCancelledError is NOT a BridgeTransportError (never a fork-fallback trigger)", async () => {
30
+ configure({ active: true, socketPath: "/tmp/bridge-does-not-exist-xyz" });
31
+ const handle = request("test_method", { a: 1 });
32
+ handle.cancel();
33
+ const err = await handle.promise.catch((e: unknown) => e);
34
+ expect(err).toBeInstanceOf(BridgeCancelledError);
35
+ expect(err).not.toBeInstanceOf(BridgeTransportError);
36
+ });
37
+
38
+ it("cancel() is idempotent (a second settle is a no-op, same error)", async () => {
39
+ configure({ active: true, socketPath: "/tmp/bridge-does-not-exist-xyz" });
40
+ const handle = request("test_method", { a: 1 });
41
+ handle.cancel();
42
+ handle.cancel();
43
+ const err = await handle.promise.catch((e: unknown) => e);
44
+ expect(err).toBeInstanceOf(BridgeCancelledError);
45
+ });
46
+
47
+ it("an unreachable channel (no cancel) still rejects with a BridgeTransportError", async () => {
48
+ configure({ active: true, socketPath: undefined });
49
+ const handle = request("test_method", { a: 1 });
50
+ await expect(handle.promise).rejects.toBeInstanceOf(BridgeTransportError);
51
+ });
52
+ });
@@ -0,0 +1,322 @@
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 by default (unref'd, overridable per-request —
10
+ // `timeoutMs: null` = no timeout) → immediate reject + socket destroy;
11
+ // connection close = immediate cancel; unreachable channel → fail fast).
12
+ // Returns a cancellable handle ({ promise, cancel }).
13
+ //
14
+ // The `active` flag does NOT depend on a successful connection (lazy,
15
+ // per-message connect). A failed connect is a no-op.
16
+ //
17
+ // Failure taxonomy: a response frame carrying `error` rejects with a plain
18
+ // Error (the desktop answered — it is alive and the outcome is authoritative);
19
+ // a transport-level failure (unreachable socket / mid-connection error / a
20
+ // clean close before any response) rejects with BridgeTransportError — the
21
+ // subagent's fallback trigger. A timeout is ALSO "no response frame" but is
22
+ // deliberately a plain Error (ambiguous liveness — the desktop may still be
23
+ // working on a long request) so it can never trigger a fork fallback (double
24
+ // execution); a deliberate cancel() is a plain Error for the same reason — a
25
+ // cancel must never fork either.
26
+
27
+ import * as net from "node:net";
28
+ import { randomUUID } from "node:crypto";
29
+
30
+ /**
31
+ * A transport-level failure: the connection never delivered a response
32
+ * frame (unreachable socket / ECONNREFUSED, a mid-connection error, or a
33
+ * clean close before any response — the desktop is effectively gone).
34
+ * Distinct from a response-carrying error (the desktop answered with an
35
+ * `error` — it is alive and the outcome is authoritative).
36
+ *
37
+ * A timeout is also "no response frame" but is DELIBERATELY NOT a
38
+ * BridgeTransportError: it is an ambiguous-liveness outcome (the desktop may
39
+ * still be working on a long request) and must never trigger a fork
40
+ * fallback (double execution) — it rejects with a plain Error. Callers that
41
+ * cannot tolerate a 5-minute silence should pass a smaller `timeoutMs`.
42
+ */
43
+ export class BridgeTransportError extends Error {
44
+ constructor(message: string) {
45
+ super(message);
46
+ this.name = "BridgeTransportError";
47
+ }
48
+ }
49
+
50
+ /**
51
+ * A deliberate cancel: the caller (the tool's AbortSignal, or the desktop's
52
+ * lifecycle via the abort wiring) aborted the request. Distinct from
53
+ * BridgeTransportError (the desktop is gone — the fork-fallback trigger) and
54
+ * from a timeout (ambiguous liveness): the request was intentionally
55
+ * aborted, so callers map it to a FAILED outcome — never a fork fallback.
56
+ * This class is the cancel contract between core and the subagent package
57
+ * (matched with `instanceof`, NOT the message string).
58
+ */
59
+ export class BridgeCancelledError extends Error {
60
+ constructor() {
61
+ super("bridge request cancelled");
62
+ this.name = "BridgeCancelledError";
63
+ }
64
+ }
65
+
66
+ let active = false;
67
+ let socketPath: string | undefined;
68
+ let seq = 0; // starts at 0; the first frame is seq: 1
69
+
70
+ // Generation guard: bumped by __resetForTests so a stale retry chain (a retry
71
+ // setTimeout scheduled on a detached entry after a reset) dies at the reset
72
+ // boundary. Without it, the per-attempt ackTimer is never tracked in the entry
73
+ // and the socket's close → finish(false) fires AFTER the reset, scheduling a
74
+ // fresh retry timer on the now-detached entry — a timer nothing will ever
75
+ // clear (it would also cross-wire into a fresh same-event entry from the next
76
+ // test). A stale chain is a no-op when the timer fires (g !== generation).
77
+ let generation = 0;
78
+
79
+ // Coalescing guard: at most one in-flight connection per event type. While one
80
+ // is in flight, a newer sendEvent for the same event queues its payload
81
+ // (latest wins) and it is sent when the current one settles.
82
+ const inFlight = new Map<string, { queued: unknown; timer?: ReturnType<typeof setTimeout> }>();
83
+
84
+ // Track open sockets so __resetForTests can reap them (prevents test hangs).
85
+ const openSockets = new Set<net.Socket>();
86
+
87
+ export function configure(opts: { active: boolean; socketPath: string | undefined }): void {
88
+ active = opts.active;
89
+ socketPath = opts.socketPath;
90
+ }
91
+
92
+ export function isActive(): boolean {
93
+ return active;
94
+ }
95
+
96
+ function socketTarget(): string | undefined {
97
+ // env value is the bare pipe name on Windows (the Client contract — unlike
98
+ // PI_SUBAGENT_SOCKET, which is a full pipe path).
99
+ if (!socketPath) return undefined;
100
+ return process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
101
+ }
102
+
103
+ function trackSocket(socket: net.Socket): void {
104
+ openSockets.add(socket);
105
+ socket.on("close", () => openSockets.delete(socket));
106
+ }
107
+
108
+ /** Test seam: reset the module singletons and reap any open sockets. */
109
+ export function __resetForTests(): void {
110
+ active = false;
111
+ socketPath = undefined;
112
+ seq = 0;
113
+ generation++; // kill any stale retry chain scheduled on a detached entry
114
+ for (const entry of inFlight.values()) {
115
+ if (entry.timer) clearTimeout(entry.timer);
116
+ }
117
+ inFlight.clear();
118
+ for (const socket of openSockets) {
119
+ try { socket.destroy(); } catch { /* already closed */ }
120
+ }
121
+ openSockets.clear();
122
+ }
123
+
124
+ // ── Best-effort push ─────────────────────────────────────────────────────
125
+
126
+ export function sendEvent(event: string, payload: unknown): void {
127
+ const existing = inFlight.get(event);
128
+ if (existing) {
129
+ existing.queued = payload; // latest wins
130
+ return;
131
+ }
132
+ inFlight.set(event, { queued: undefined });
133
+ runAttempt(event, 0, payload);
134
+ }
135
+
136
+ function runAttempt(event: string, attempt: number, payload: unknown): void {
137
+ const entry = inFlight.get(event);
138
+ if (!entry) return;
139
+ // Consume the latest queued payload (latest wins).
140
+ const framePayload = entry.queued !== undefined ? entry.queued : payload;
141
+ entry.queued = undefined;
142
+
143
+ const target = socketTarget();
144
+ if (!target) {
145
+ settle(event); // unreachable channel → no-op
146
+ return;
147
+ }
148
+
149
+ const frame = { v: 1, type: "push", seq: ++seq, event, payload: framePayload };
150
+ const socket = net.createConnection(target);
151
+ trackSocket(socket);
152
+
153
+ let done = false;
154
+ const finish = (ok: boolean) => {
155
+ if (done) return;
156
+ done = true;
157
+ clearTimeout(ackTimer);
158
+ try { socket.destroy(); } catch { /* already closed */ }
159
+ if (ok) {
160
+ settle(event);
161
+ } else if (attempt < 2) {
162
+ // Retry: initial → 500 ms → 1500 ms → drop.
163
+ const delay = attempt === 0 ? 500 : 1500;
164
+ const g = generation; // generation guard: a stale chain (a reset happened
165
+ // between scheduling and firing) is a no-op when it fires — the timer
166
+ // dies at the reset boundary instead of leaking a retry on a detached
167
+ // entry (the close → finish(false) that schedules it fires AFTER reset).
168
+ const timer = setTimeout(() => {
169
+ if (g !== generation) return;
170
+ runAttempt(event, attempt + 1, framePayload);
171
+ }, delay);
172
+ timer.unref?.();
173
+ entry.timer = timer;
174
+ } else {
175
+ settle(event);
176
+ }
177
+ };
178
+
179
+ // No-ack window: if the connection is alive but never acks, treat it as a
180
+ // failure and retry. (The Client acks promptly after receiving a push.)
181
+ const ackTimer = setTimeout(() => finish(false), 1000);
182
+ ackTimer.unref?.();
183
+
184
+ socket.on("connect", () => {
185
+ try { socket.write(JSON.stringify(frame) + "\n"); } catch { finish(false); }
186
+ });
187
+ socket.on("data", () => finish(true)); // first data = the ack line
188
+ socket.on("error", () => finish(false));
189
+ socket.on("close", () => finish(false));
190
+ }
191
+
192
+ function settle(event: string): void {
193
+ const entry = inFlight.get(event);
194
+ if (!entry) return;
195
+ if (entry.timer) { clearTimeout(entry.timer); delete entry.timer; }
196
+ if (entry.queued !== undefined) {
197
+ // A newer payload accumulated while in flight — send it (latest wins).
198
+ const queued = entry.queued;
199
+ entry.queued = undefined;
200
+ runAttempt(event, 0, queued);
201
+ } else {
202
+ inFlight.delete(event);
203
+ }
204
+ }
205
+
206
+ // ── Interactive request ───────────────────────────────────────────────────
207
+
208
+ export function request<T>(
209
+ method: string,
210
+ params: unknown,
211
+ opts?: { toolCallId?: string | undefined; source?: string; timeoutMs?: number | null | undefined },
212
+ ): { promise: Promise<T>; cancel: () => void } {
213
+ const id = randomUUID();
214
+ const frame = {
215
+ v: 1,
216
+ type: "request",
217
+ id,
218
+ method,
219
+ source: opts?.source ?? "main",
220
+ toolCallId: opts?.toolCallId,
221
+ params,
222
+ };
223
+
224
+ let settled = false;
225
+ let socket: net.Socket | undefined;
226
+ let timer: ReturnType<typeof setTimeout> | undefined;
227
+ // The settle helper (assigned in the executor below — the executor runs
228
+ // synchronously, so it is assigned before any caller can invoke cancel()).
229
+ let finish: (err?: Error, value?: T) => void = () => {};
230
+
231
+ const promise = new Promise<T>((resolve, reject) => {
232
+ const target = socketTarget();
233
+ if (!target) {
234
+ // Unreachable channel → fail fast (a transport-level failure — the
235
+ // desktop is effectively gone, so the subagent's fallback trigger).
236
+ settled = true;
237
+ reject(new BridgeTransportError("bridge channel unreachable"));
238
+ return;
239
+ }
240
+
241
+ finish = (err?: Error, value?: T) => {
242
+ // Idempotent: the socket's close handler (fired by the destroy below)
243
+ // must NOT double-settle a request already settled by a timeout or a
244
+ // deterministic cancel.
245
+ if (settled) return;
246
+ settled = true;
247
+ if (timer) clearTimeout(timer);
248
+ try { socket?.destroy(); } catch { /* already closed */ }
249
+ if (err) reject(err);
250
+ else resolve(value as T);
251
+ };
252
+
253
+ // Timeout: `timeoutMs: null` = NO timeout (skip the timer entirely —
254
+ // dispatch_subagent, whose cancel path is the desktop's lifecycle, not a
255
+ // timer); `timeoutMs: undefined` = the 5-minute default. Note: `??`
256
+ // cannot express this (it falls back for null AND undefined) — hence the
257
+ // explicit undefined check. A NEGATIVE value is clamped to 1 ms (the
258
+ // minimum setTimeout delay — `null`, not a negative, is the "never"
259
+ // case). The timer is unref'd so a pending request never keeps the
260
+ // process alive.
261
+ //
262
+ // A timeout is an AMBIGUOUS-LIVENESS outcome (the desktop may still be
263
+ // working on a long request) — it is deliberately NOT a
264
+ // BridgeTransportError, so it can never trigger a fork fallback (double
265
+ // execution); callers that cannot tolerate a 5-minute silence should pass
266
+ // a smaller `timeoutMs` or `null`.
267
+ const rawTimeoutMs = opts?.timeoutMs === undefined ? 5 * 60 * 1000 : opts!.timeoutMs;
268
+ if (rawTimeoutMs !== null) {
269
+ const delay = rawTimeoutMs < 0 ? 1 : rawTimeoutMs; // clamp negatives to the 1 ms minimum
270
+ timer = setTimeout(() => {
271
+ finish(new Error("bridge request timed out"));
272
+ }, delay);
273
+ timer.unref?.();
274
+ }
275
+
276
+ socket = net.createConnection(target);
277
+ const sock = socket; // capture non-undefined for the handlers below
278
+ trackSocket(sock);
279
+ let buffer = "";
280
+
281
+ sock.on("connect", () => {
282
+ try { sock.write(JSON.stringify(frame) + "\n"); } catch { finish(new Error("failed to write request")); }
283
+ });
284
+ sock.on("data", (chunk: Buffer) => {
285
+ buffer += chunk.toString("utf-8");
286
+ const lines = buffer.split("\n");
287
+ buffer = lines.pop() ?? "";
288
+ for (const line of lines) {
289
+ const trimmed = line.trim();
290
+ if (!trimmed) continue;
291
+ try {
292
+ const msg = JSON.parse(trimmed) as { type?: string; id?: string; result?: T; error?: string };
293
+ if (msg.type === "response" && msg.id === id) {
294
+ if (msg.error !== undefined) finish(new Error(msg.error));
295
+ else finish(undefined, msg.result);
296
+ }
297
+ } catch { /* malformed line — ignore */ }
298
+ }
299
+ });
300
+ // error / close before a response → a transport-level failure (the
301
+ // desktop is effectively gone). A response frame carrying `error` still
302
+ // rejects with a plain Error — the desktop answered, so its outcome is
303
+ // authoritative (NOT a transport failure). (A deterministic cancel()
304
+ // settles first — the close it triggers is a no-op here.)
305
+ sock.on("error", () => finish(new BridgeTransportError("bridge channel error")));
306
+ sock.on("close", () => finish(new BridgeTransportError("bridge channel closed before response")));
307
+ });
308
+
309
+ // cancel() settles the promise DETERMINISTICALLY with a BridgeCancelledError
310
+ // (NOT a BridgeTransportError): a deliberate cancel is NOT "the desktop is
311
+ // gone", so a dispatch-like caller must map it to a FAILED outcome, never a
312
+ // fork fallback (a deliberate cancel must never fork — forking would spawn
313
+ // a fresh child pi for a task the user just cancelled). The socket destroy
314
+ // (inside finish) still delivers the desktop's EOF → the subagent session
315
+ // cancels. Idempotent: finish() no-ops a second settle, and the close
316
+ // handler (fired by the destroy) is a no-op once settled.
317
+ const cancel = () => {
318
+ finish(new BridgeCancelledError());
319
+ };
320
+
321
+ return { promise, cancel };
322
+ }
@@ -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
+ }