@pi-archimedes/core 2.8.0 → 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 +6 -27
- package/package.json +7 -6
- package/src/bridge/channel.test.ts +52 -0
- package/src/bridge/channel.ts +97 -18
- package/src/bridge/index.test.ts +147 -1
- package/src/bridge/index.ts +52 -2
- package/src/bridge/self-usage.test.ts +146 -0
- package/src/bridge/self-usage.ts +37 -0
- package/src/config.test.ts +8 -103
- package/src/config.ts +4 -66
- package/src/index.test.ts +19 -354
- package/src/index.ts +9 -272
- package/src/tool-render.ts +1 -1
- package/src/editor/index.test.ts +0 -820
- package/src/editor/index.ts +0 -379
- package/src/editor/spin-quips.test.ts +0 -106
- package/src/editor/spin-quips.ts +0 -44
- package/src/editor/spin.test.ts +0 -702
- package/src/editor/spin.ts +0 -471
- package/src/startup/capture.ts +0 -47
- package/src/startup/index.ts +0 -330
- package/src/startup/logo.test.ts +0 -260
- package/src/startup/logo.ts +0 -85
- package/src/startup/sections.test.ts +0 -314
- package/src/startup/sections.ts +0 -284
- package/src/startup/version.test.ts +0 -98
- package/src/startup/version.ts +0 -26
- package/src/thinking/patch.test.ts +0 -389
- package/src/thinking/patch.ts +0 -288
- package/src/thinking/theme.test.ts +0 -216
- package/src/thinking/theme.ts +0 -156
- package/src/thinking/transform.test.ts +0 -89
- package/src/thinking/transform.ts +0 -21
- package/src/thinking/unindent.test.ts +0 -74
- package/src/thinking/unindent.ts +0 -74
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @pi-archimedes/core
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Foundational non-UI runtime for the Pi Archimedes suite.**
|
|
4
4
|
|
|
5
|
-
Core is the
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/danielcherubini/pi-archimedes.git"
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"keywords": [
|
|
10
10
|
"pi-package"
|
|
11
11
|
],
|
|
12
|
-
"description": "Core
|
|
12
|
+
"description": "Core primitives for pi-archimedes",
|
|
13
13
|
"files": [
|
|
14
14
|
"src"
|
|
15
15
|
],
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"exports": {
|
|
18
18
|
".": "./src/index.ts",
|
|
19
19
|
"./bridge": "./src/bridge/index.ts",
|
|
20
|
+
"./bridge/channel": "./src/bridge/channel.ts",
|
|
20
21
|
"./bus": "./src/bus.ts",
|
|
21
22
|
"./chrome": "./src/chrome.ts",
|
|
22
23
|
"./text": "./src/text.ts",
|
|
@@ -28,12 +29,12 @@
|
|
|
28
29
|
"./tool-render": "./src/tool-render.ts"
|
|
29
30
|
},
|
|
30
31
|
"peerDependencies": {
|
|
31
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
32
|
-
"@earendil-works/pi-tui": ">=0.
|
|
32
|
+
"@earendil-works/pi-coding-agent": ">=0.85.0",
|
|
33
|
+
"@earendil-works/pi-tui": ">=0.85.0"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
36
|
-
"@earendil-works/pi-tui": "^0.
|
|
36
|
+
"@earendil-works/pi-coding-agent": "^0.87.0",
|
|
37
|
+
"@earendil-works/pi-tui": "^0.87.0",
|
|
37
38
|
"typescript": "^6.0.0"
|
|
38
39
|
},
|
|
39
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
|
+
});
|
package/src/bridge/channel.ts
CHANGED
|
@@ -6,16 +6,63 @@
|
|
|
6
6
|
// - sendEvent: best-effort push (initial + 2 retries at 500/1500 ms, then
|
|
7
7
|
// drop; coalesced to at most one in-flight connection per event type).
|
|
8
8
|
// - request: interactive (a single request/response over one connection;
|
|
9
|
-
// 5-minute timeout (unref'd
|
|
9
|
+
// 5-minute timeout by default (unref'd, overridable per-request —
|
|
10
|
+
// `timeoutMs: null` = no timeout) → immediate reject + socket destroy;
|
|
10
11
|
// connection close = immediate cancel; unreachable channel → fail fast).
|
|
11
12
|
// Returns a cancellable handle ({ promise, cancel }).
|
|
12
13
|
//
|
|
13
14
|
// The `active` flag does NOT depend on a successful connection (lazy,
|
|
14
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.
|
|
15
26
|
|
|
16
27
|
import * as net from "node:net";
|
|
17
28
|
import { randomUUID } from "node:crypto";
|
|
18
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
|
+
|
|
19
66
|
let active = false;
|
|
20
67
|
let socketPath: string | undefined;
|
|
21
68
|
let seq = 0; // starts at 0; the first frame is seq: 1
|
|
@@ -161,7 +208,7 @@ function settle(event: string): void {
|
|
|
161
208
|
export function request<T>(
|
|
162
209
|
method: string,
|
|
163
210
|
params: unknown,
|
|
164
|
-
opts?: { toolCallId?: string; source?: string },
|
|
211
|
+
opts?: { toolCallId?: string | undefined; source?: string; timeoutMs?: number | null | undefined },
|
|
165
212
|
): { promise: Promise<T>; cancel: () => void } {
|
|
166
213
|
const id = randomUUID();
|
|
167
214
|
const frame = {
|
|
@@ -177,17 +224,24 @@ export function request<T>(
|
|
|
177
224
|
let settled = false;
|
|
178
225
|
let socket: net.Socket | undefined;
|
|
179
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 = () => {};
|
|
180
230
|
|
|
181
231
|
const promise = new Promise<T>((resolve, reject) => {
|
|
182
232
|
const target = socketTarget();
|
|
183
233
|
if (!target) {
|
|
184
|
-
// Unreachable channel → fail fast
|
|
234
|
+
// Unreachable channel → fail fast (a transport-level failure — the
|
|
235
|
+
// desktop is effectively gone, so the subagent's fallback trigger).
|
|
185
236
|
settled = true;
|
|
186
|
-
reject(new
|
|
237
|
+
reject(new BridgeTransportError("bridge channel unreachable"));
|
|
187
238
|
return;
|
|
188
239
|
}
|
|
189
240
|
|
|
190
|
-
|
|
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.
|
|
191
245
|
if (settled) return;
|
|
192
246
|
settled = true;
|
|
193
247
|
if (timer) clearTimeout(timer);
|
|
@@ -196,11 +250,28 @@ export function request<T>(
|
|
|
196
250
|
else resolve(value as T);
|
|
197
251
|
};
|
|
198
252
|
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
+
}
|
|
204
275
|
|
|
205
276
|
socket = net.createConnection(target);
|
|
206
277
|
const sock = socket; // capture non-undefined for the handlers below
|
|
@@ -226,17 +297,25 @@ export function request<T>(
|
|
|
226
297
|
} catch { /* malformed line — ignore */ }
|
|
227
298
|
}
|
|
228
299
|
});
|
|
229
|
-
// error / close before a response →
|
|
230
|
-
|
|
231
|
-
|
|
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")));
|
|
232
307
|
});
|
|
233
308
|
|
|
234
|
-
// cancel()
|
|
235
|
-
//
|
|
236
|
-
//
|
|
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.
|
|
237
317
|
const cancel = () => {
|
|
238
|
-
|
|
239
|
-
try { socket?.destroy(); } catch { /* already closed */ }
|
|
318
|
+
finish(new BridgeCancelledError());
|
|
240
319
|
};
|
|
241
320
|
|
|
242
321
|
return { promise, cancel };
|
package/src/bridge/index.test.ts
CHANGED
|
@@ -12,9 +12,10 @@ import {
|
|
|
12
12
|
confirm,
|
|
13
13
|
password,
|
|
14
14
|
registerBridge,
|
|
15
|
+
dispatch,
|
|
15
16
|
BridgeInactiveError,
|
|
16
17
|
} from "./index.js";
|
|
17
|
-
import { configure, sendEvent, request, __resetForTests as resetChannel } from "./channel.js";
|
|
18
|
+
import { configure, sendEvent, request, BridgeTransportError, __resetForTests as resetChannel } from "./channel.js";
|
|
18
19
|
import {
|
|
19
20
|
state,
|
|
20
21
|
start,
|
|
@@ -175,6 +176,11 @@ describe("inactive API", () => {
|
|
|
175
176
|
clearBridgeEnv();
|
|
176
177
|
await expect(password({ command: "sudo apt install", reason: "test" })).resolves.toBe("");
|
|
177
178
|
});
|
|
179
|
+
|
|
180
|
+
it("dispatch throws BridgeInactiveError", () => {
|
|
181
|
+
clearBridgeEnv();
|
|
182
|
+
expect(() => dispatch({ agentName: "a", task: "t", systemPrompt: null, model: null, thinking: null, tools: null }, "tool-1")).toThrow(BridgeInactiveError);
|
|
183
|
+
});
|
|
178
184
|
});
|
|
179
185
|
|
|
180
186
|
// ── 3. State machine ─────────────────────────────────────────────────────
|
|
@@ -382,6 +388,146 @@ describe("request round-trip + cancel", () => {
|
|
|
382
388
|
});
|
|
383
389
|
});
|
|
384
390
|
|
|
391
|
+
// ── 8b. request timeout override + BridgeTransportError ─────────────────
|
|
392
|
+
|
|
393
|
+
describe("request timeout override + BridgeTransportError", () => {
|
|
394
|
+
it("timeoutMs: null does NOT reject at 5 minutes (no timer)", async () => {
|
|
395
|
+
vi.useFakeTimers();
|
|
396
|
+
try {
|
|
397
|
+
const sockPath = tempSocketPath();
|
|
398
|
+
const server = await startServer(sockPath, () => {
|
|
399
|
+
/* accept but never respond */
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
configure({ active: true, socketPath: sockPath });
|
|
403
|
+
const { promise } = request("dispatch_subagent", { task: "x" }, { toolCallId: "t1", source: "main", timeoutMs: null });
|
|
404
|
+
let settled = false;
|
|
405
|
+
promise.then(() => { settled = true; }, () => { settled = true; });
|
|
406
|
+
|
|
407
|
+
// Advance 6 minutes of fake time — the 5-minute timer (if any) would
|
|
408
|
+
// have fired. The promise must still be pending.
|
|
409
|
+
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
|
|
410
|
+
expect(settled).toBe(false);
|
|
411
|
+
|
|
412
|
+
server.close();
|
|
413
|
+
try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
|
|
414
|
+
} finally {
|
|
415
|
+
vi.useRealTimers();
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("the default (timeoutMs omitted) still rejects after 5 minutes", async () => {
|
|
420
|
+
vi.useFakeTimers();
|
|
421
|
+
try {
|
|
422
|
+
const sockPath = tempSocketPath();
|
|
423
|
+
const server = await startServer(sockPath, () => {
|
|
424
|
+
/* accept but never respond */
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
configure({ active: true, socketPath: sockPath });
|
|
428
|
+
const { promise } = request("ask", { questions: [makeQuestion()] });
|
|
429
|
+
// Attach the handler BEFORE advancing (a rejection during the advance
|
|
430
|
+
// with no handler attached would be an unhandled rejection).
|
|
431
|
+
const outcome = promise.then(() => null, (e: unknown) => e);
|
|
432
|
+
|
|
433
|
+
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
|
|
434
|
+
const err = await outcome;
|
|
435
|
+
expect(err).toBeInstanceOf(Error);
|
|
436
|
+
expect((err as Error).message).toBe("bridge request timed out");
|
|
437
|
+
|
|
438
|
+
server.close();
|
|
439
|
+
try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
|
|
440
|
+
} finally {
|
|
441
|
+
vi.useRealTimers();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("a negative timeoutMs is clamped to the 1 ms minimum (the timer still fires — NOT 'no timer')", async () => {
|
|
446
|
+
vi.useFakeTimers();
|
|
447
|
+
try {
|
|
448
|
+
const sockPath = tempSocketPath();
|
|
449
|
+
const server = await startServer(sockPath, () => {
|
|
450
|
+
/* accept but never respond */
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
configure({ active: true, socketPath: sockPath });
|
|
454
|
+
const { promise } = request("ask", { questions: [makeQuestion()] }, { timeoutMs: -5000 });
|
|
455
|
+
// `null` is the "never" case; a negative value is clamped to the 1 ms
|
|
456
|
+
// minimum, so a short advance must fire the timeout (not skip it).
|
|
457
|
+
const outcome = promise.then(() => null, (e: unknown) => e);
|
|
458
|
+
|
|
459
|
+
await vi.advanceTimersByTimeAsync(2);
|
|
460
|
+
const err = await outcome;
|
|
461
|
+
expect(err).toBeInstanceOf(Error);
|
|
462
|
+
expect((err as Error).message).toBe("bridge request timed out");
|
|
463
|
+
|
|
464
|
+
server.close();
|
|
465
|
+
try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
|
|
466
|
+
} finally {
|
|
467
|
+
vi.useRealTimers();
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("a mid-connection close (no response frame) rejects with a BridgeTransportError", async () => {
|
|
472
|
+
const sockPath = tempSocketPath();
|
|
473
|
+
const server = net.createServer((socket) => {
|
|
474
|
+
// Accept then destroy — the desktop is effectively gone.
|
|
475
|
+
socket.destroy();
|
|
476
|
+
});
|
|
477
|
+
await new Promise<void>((resolve) => server.listen(sockPath, resolve));
|
|
478
|
+
|
|
479
|
+
configure({ active: true, socketPath: sockPath });
|
|
480
|
+
const { promise } = request("ask", { questions: [makeQuestion()] });
|
|
481
|
+
const err = await promise.catch((e: unknown) => e);
|
|
482
|
+
expect(err).toBeInstanceOf(BridgeTransportError);
|
|
483
|
+
|
|
484
|
+
server.close();
|
|
485
|
+
try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("a response-carrying error frame rejects with a plain Error (NOT BridgeTransportError)", async () => {
|
|
489
|
+
const sockPath = tempSocketPath();
|
|
490
|
+
const server = net.createServer((socket) => {
|
|
491
|
+
let buffer = "";
|
|
492
|
+
socket.on("data", (chunk: Buffer) => {
|
|
493
|
+
buffer += chunk.toString("utf-8");
|
|
494
|
+
const lines = buffer.split("\n");
|
|
495
|
+
buffer = lines.pop() ?? "";
|
|
496
|
+
for (const line of lines) {
|
|
497
|
+
const trimmed = line.trim();
|
|
498
|
+
if (!trimmed) continue;
|
|
499
|
+
try {
|
|
500
|
+
const msg = JSON.parse(trimmed) as { type?: string; id?: string };
|
|
501
|
+
if (msg.type === "request" && msg.id) {
|
|
502
|
+
socket.write(JSON.stringify({ v: 1, type: "response", id: msg.id, error: "cancelled" }) + "\n");
|
|
503
|
+
}
|
|
504
|
+
} catch { /* malformed — ignore */ }
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
socket.on("error", () => { /* connection dropped */ });
|
|
508
|
+
});
|
|
509
|
+
await new Promise<void>((resolve) => server.listen(sockPath, resolve));
|
|
510
|
+
|
|
511
|
+
configure({ active: true, socketPath: sockPath });
|
|
512
|
+
const { promise } = request("dispatch_subagent", { task: "x" }, { toolCallId: "t1", source: "main", timeoutMs: null });
|
|
513
|
+
const err = await promise.catch((e: unknown) => e);
|
|
514
|
+
// The desktop answered — it is alive and the outcome is authoritative.
|
|
515
|
+
expect(err).toBeInstanceOf(Error);
|
|
516
|
+
expect(err).not.toBeInstanceOf(BridgeTransportError);
|
|
517
|
+
expect((err as Error).message).toBe("cancelled");
|
|
518
|
+
|
|
519
|
+
server.close();
|
|
520
|
+
try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("an unreachable channel (no socket target) rejects with a BridgeTransportError", async () => {
|
|
524
|
+
configure({ active: true, socketPath: "/tmp/definitely-does-not-exist-bridge2.sock" });
|
|
525
|
+
const { promise } = request("ask", { questions: [makeQuestion()] });
|
|
526
|
+
const err = await promise.catch((e: unknown) => e);
|
|
527
|
+
expect(err).toBeInstanceOf(BridgeTransportError);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
385
531
|
// ── 6. seq counter ───────────────────────────────────────────────────────
|
|
386
532
|
|
|
387
533
|
describe("seq counter", () => {
|
package/src/bridge/index.ts
CHANGED
|
@@ -14,6 +14,8 @@ import type { AskResponsePayload, AskRequestPayload } from "../bus.js";
|
|
|
14
14
|
import * as channel from "./channel.js";
|
|
15
15
|
import * as events from "./events.js";
|
|
16
16
|
|
|
17
|
+
export { BridgeTransportError, BridgeCancelledError } from "./channel.js";
|
|
18
|
+
|
|
17
19
|
export class BridgeInactiveError extends Error {
|
|
18
20
|
constructor() {
|
|
19
21
|
super("bridge is not active");
|
|
@@ -30,7 +32,54 @@ function eventsState(): "working" | "idle" | "blocked" {
|
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export function getBridge() {
|
|
33
|
-
return { active: channelActive(), ask, confirm, password, state: eventsState };
|
|
35
|
+
return { active: channelActive(), ask, confirm, password, dispatch, state: eventsState };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Delegate a subagent task to the Client (desktop) instead of forking a
|
|
40
|
+
* child pi process. The desktop runs the subagent session (a full ACP
|
|
41
|
+
* session on its worker runtime) and answers with the final output +
|
|
42
|
+
* metrics.
|
|
43
|
+
*
|
|
44
|
+
* The request opts OUT of the 5-minute timeout (`timeoutMs: null`): the
|
|
45
|
+
* desktop's lifecycle (parent close / EOF / app exit) is the cancel path —
|
|
46
|
+
* the tool's AbortSignal cancels the request (the promise settles
|
|
47
|
+
* deterministically with a BridgeCancelledError — NOT a
|
|
48
|
+
* BridgeTransportError, so it can never trigger a fork fallback) and the
|
|
49
|
+
* socket close delivers the desktop's EOF → the subagent session cancels.
|
|
50
|
+
* An unreachable bridge rejects with BridgeTransportError (the subagent's
|
|
51
|
+
* fork-fallback trigger); a response-carrying `error` (incl. the terminal
|
|
52
|
+
* `"cancelled"` frame) is authoritative and never a fallback.
|
|
53
|
+
*/
|
|
54
|
+
export interface DispatchParams {
|
|
55
|
+
agentName: string;
|
|
56
|
+
task: string;
|
|
57
|
+
systemPrompt: string | null;
|
|
58
|
+
model: string | null;
|
|
59
|
+
thinking: string | null;
|
|
60
|
+
tools: string[] | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface DispatchResult {
|
|
64
|
+
output: string;
|
|
65
|
+
metrics: { inputTokens: number; outputTokens: number; cost: number; durationMs: number };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function dispatch(params: DispatchParams, toolCallId: string | undefined, signal?: AbortSignal): Promise<DispatchResult> {
|
|
69
|
+
if (!channelActive()) throw new BridgeInactiveError();
|
|
70
|
+
const handle = channel.request<DispatchResult>("dispatch_subagent", params, { toolCallId, source: "main", timeoutMs: null });
|
|
71
|
+
if (signal) {
|
|
72
|
+
// Same abort wiring as ask (see there for the full rationale): a
|
|
73
|
+
// pre-aborted signal cancels immediately, the listener is removed on
|
|
74
|
+
// settle via the .then(onFulfilled, onRejected) form.
|
|
75
|
+
if (signal.aborted) handle.cancel();
|
|
76
|
+
else signal.addEventListener("abort", handle.cancel, { once: true });
|
|
77
|
+
handle.promise.then(
|
|
78
|
+
() => { signal?.removeEventListener("abort", handle.cancel); },
|
|
79
|
+
() => { signal?.removeEventListener("abort", handle.cancel); },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return handle.promise;
|
|
34
83
|
}
|
|
35
84
|
|
|
36
85
|
/**
|
|
@@ -48,7 +97,8 @@ export function ask(params: { questions: AskRequestPayload["questions"] }, toolC
|
|
|
48
97
|
// "abort" event (it fires exactly once, at abort time), so an addEventListener
|
|
49
98
|
// alone would leave the request lingering until the Client responds or the
|
|
50
99
|
// 5-minute timeout — call cancel() directly in that case. cancel() is
|
|
51
|
-
// idempotent and settles the promise
|
|
100
|
+
// idempotent and settles the promise deterministically (the
|
|
101
|
+
// socket-close handler it triggers is a no-op once settled), so both
|
|
52
102
|
// branches coexist safely. The listener is removed on settle; the
|
|
53
103
|
// .then(onFulfilled, onRejected) form (not .finally) avoids an unhandled
|
|
54
104
|
// rejection from the derived promise when the request rejects.
|