@pi-archimedes/core 2.10.3 → 2.11.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 +3 -3
- package/package.json +7 -4
- package/src/bridge/events.test.ts +438 -0
- package/src/bridge/events.ts +98 -39
- package/src/bridge/index.test.ts +50 -22
- package/src/bridge/index.ts +18 -5
- package/src/bridge/self-usage.test.ts +90 -12
- package/src/bridge/self-usage.ts +19 -6
- package/src/chrome-old-host.test.ts +84 -0
- package/src/chrome.test.ts +435 -2
- package/src/chrome.ts +138 -20
- package/src/events-host-bus.test.ts +113 -0
- package/src/events.test.ts +85 -0
- package/src/events.ts +107 -0
- package/src/index.test.ts +3 -9
- package/src/index.ts +5 -5
- package/src/log-discipline.test.ts +187 -0
- package/src/log.test.ts +172 -0
- package/src/log.ts +69 -0
- package/src/overlay.test.ts +178 -6
- package/src/overlay.ts +46 -62
- package/src/pi-support.test.ts +444 -0
- package/src/pi-support.ts +190 -0
- package/src/profiler.ts +14 -5
- package/src/screen-mode-discipline.test.ts +214 -0
- package/src/screen-mode.test.ts +48 -0
- package/src/screen-mode.ts +45 -0
- package/src/settings-io.test.ts +174 -1
- package/src/settings-io.ts +54 -0
- package/src/text.test.ts +58 -0
- package/src/text.ts +21 -20
- package/src/tool-render.test.ts +48 -0
- package/src/tool-render.ts +44 -0
- package/src/bus.test.ts +0 -154
- package/src/bus.ts +0 -137
package/src/bridge/events.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// ── Bridge events (bus subscriptions — the state machine +
|
|
2
|
-
// forwarding + push events)
|
|
1
|
+
// ── Bridge events (host-event-bus subscriptions — the state machine +
|
|
2
|
+
// subagent forwarding + push events) ────────────────────────────────────
|
|
3
3
|
//
|
|
4
|
-
// Subscribes the SIX
|
|
5
|
-
// session_start on the bus — those are pi extension events dispatched via
|
|
6
|
-
// pi.on(...) and handled in registerBridge).
|
|
4
|
+
// Subscribes the SIX channel-vocabulary events (nothing emits agent_start/
|
|
5
|
+
// agent_settled/session_start on the bus — those are pi extension events dispatched via
|
|
6
|
+
// pi.on(...) and handled in registerBridge). Channel names map to wire names
|
|
7
7
|
// as explicit literals at each call site (no lookup table, no string surgery):
|
|
8
8
|
// COST_UPDATE → cost_update, TODOS_UPDATE → todos_update, TODOS_CLEAR →
|
|
9
9
|
// todos_clear. The ASK_* trio is not forwarded as pushes of their own —
|
|
@@ -16,19 +16,25 @@
|
|
|
16
16
|
// with an ASK_RESPONSE (success, error, timeout, or ASK_CANCEL) so the
|
|
17
17
|
// refcount never leaks.
|
|
18
18
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
import
|
|
26
|
-
import { getBus, Events } from "../bus.js";
|
|
19
|
+
// The subscriptions live on the CALLER's host handle (`pi.events`), which this
|
|
20
|
+
// module does not own — see `start()` / `stop()` for why the pair is mandatory
|
|
21
|
+
// now that the transport is the host's.
|
|
22
|
+
|
|
23
|
+
import type { EventBus, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { Events, emitOn } from "../events.js";
|
|
25
|
+
import { diag } from "../log.js";
|
|
27
26
|
import { sendEvent, request } from "./channel.js";
|
|
28
27
|
|
|
29
28
|
let refcount = 0;
|
|
30
29
|
let settled = false;
|
|
31
|
-
|
|
30
|
+
/** The handle the current subscription set was made on, or undefined when there is no subscription set. */
|
|
31
|
+
let subscribedOn: EventBus | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Bumped by every teardown. A Client reply that settles after its own
|
|
34
|
+
* subscription set is gone carries the generation it was asked on, and drops
|
|
35
|
+
* itself when that number is no longer current — see `requestAskToClient`.
|
|
36
|
+
*/
|
|
37
|
+
let generation = 0;
|
|
32
38
|
let sessionCtx: ExtensionContext | undefined;
|
|
33
39
|
|
|
34
40
|
const pending = new Map<string, { source: string; toolCallId?: string; cancel: () => void }>();
|
|
@@ -79,15 +85,23 @@ function sessionRefs(): Record<string, unknown> {
|
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
/**
|
|
82
|
-
* Subscribe the six
|
|
83
|
-
*
|
|
84
|
-
*
|
|
88
|
+
* Subscribe the six channel-vocabulary events on a host handle.
|
|
89
|
+
*
|
|
90
|
+
* Idempotent by teardown, not by an early return: it stops any previous
|
|
91
|
+
* subscription set first, so it can be called once per `session_start` without
|
|
92
|
+
* double-delivering. A "we already started, bail out" flag would be wrong
|
|
93
|
+
* here — the host drops every subscription of a runtime when that runtime is
|
|
94
|
+
* invalidated (session replacement, `/reload`), so the second `session_start`
|
|
95
|
+
* of a process genuinely needs new subscriptions.
|
|
96
|
+
*
|
|
97
|
+
* @param events the caller's `pi.events` — the relay never reaches for a bus
|
|
98
|
+
* of its own, which is the whole point of the migration
|
|
85
99
|
*/
|
|
86
|
-
export function start(): void {
|
|
87
|
-
|
|
88
|
-
|
|
100
|
+
export function start(events: EventBus): void {
|
|
101
|
+
stop();
|
|
102
|
+
subscribedOn = events;
|
|
89
103
|
|
|
90
|
-
unsubscribers.push(
|
|
104
|
+
unsubscribers.push(events.on(Events.ASK_REQUEST, (payload: unknown) => {
|
|
91
105
|
const p = payload as {
|
|
92
106
|
source: string;
|
|
93
107
|
requestId: string;
|
|
@@ -106,7 +120,7 @@ export function start(): void {
|
|
|
106
120
|
}
|
|
107
121
|
}));
|
|
108
122
|
|
|
109
|
-
unsubscribers.push(
|
|
123
|
+
unsubscribers.push(events.on(Events.ASK_RESPONSE, () => {
|
|
110
124
|
// Defensive floor: a spurious/duplicated ASK_RESPONSE must not drive the
|
|
111
125
|
// refcount negative — a negative count would make every later ask
|
|
112
126
|
// off-by-one (state reports working/idle while a prompt is pending, never
|
|
@@ -114,12 +128,12 @@ export function start(): void {
|
|
|
114
128
|
// refcount === 0 is a bug signal (by the module's own invariant, every
|
|
115
129
|
// ASK_REQUEST is paired) — log it so the protocol violation doesn't
|
|
116
130
|
// disappear without a trace.
|
|
117
|
-
if (refcount === 0)
|
|
131
|
+
if (refcount === 0) diag("core", "[archimedes:bridge] unpaired ASK_RESPONSE received (refcount already 0)");
|
|
118
132
|
refcount = Math.max(0, refcount - 1);
|
|
119
133
|
pushState();
|
|
120
134
|
}));
|
|
121
135
|
|
|
122
|
-
unsubscribers.push(
|
|
136
|
+
unsubscribers.push(events.on(Events.ASK_CANCEL, (payload: unknown) => {
|
|
123
137
|
const p = payload as { requestId: string };
|
|
124
138
|
const entry = pending.get(p.requestId);
|
|
125
139
|
// cancel() closes the socket → the request rejects → the .catch below
|
|
@@ -129,25 +143,62 @@ export function start(): void {
|
|
|
129
143
|
}));
|
|
130
144
|
|
|
131
145
|
// Bus payload types ARE the wire types — forward verbatim.
|
|
132
|
-
unsubscribers.push(
|
|
146
|
+
unsubscribers.push(events.on(Events.TODOS_UPDATE, (payload: unknown) => {
|
|
133
147
|
sendEvent("todos_update", payload);
|
|
134
148
|
}));
|
|
135
149
|
|
|
136
|
-
unsubscribers.push(
|
|
150
|
+
unsubscribers.push(events.on(Events.TODOS_CLEAR, (payload: unknown) => {
|
|
137
151
|
sendEvent("todos_clear", payload);
|
|
138
152
|
}));
|
|
139
153
|
|
|
140
|
-
unsubscribers.push(
|
|
154
|
+
unsubscribers.push(events.on(Events.COST_UPDATE, (payload: unknown) => {
|
|
141
155
|
sendEvent("cost_update", payload);
|
|
142
156
|
}));
|
|
143
157
|
}
|
|
144
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Tear the subscription set down. The counterpart `start()` needs, and the
|
|
161
|
+
* reason the relay survives a replacement session: the host unsubscribes a
|
|
162
|
+
* runtime's handles when it invalidates it, so without an explicit teardown
|
|
163
|
+
* the module-level state (refcount, pending asks, "am I subscribed") would
|
|
164
|
+
* outlive the subscriptions it describes.
|
|
165
|
+
*
|
|
166
|
+
* Also releases the asks that died with the session — a Client request that
|
|
167
|
+
* can never be answered would otherwise pin the state machine at `blocked` for
|
|
168
|
+
* every session after this one.
|
|
169
|
+
*
|
|
170
|
+
* Safe to call when nothing is subscribed.
|
|
171
|
+
*/
|
|
172
|
+
export function stop(): void {
|
|
173
|
+
// Retire in-flight replies BEFORE anything else: their callbacks run on the
|
|
174
|
+
// next microtask, which is after a same-tick `start()` has re-subscribed, so
|
|
175
|
+
// they need to be told they are stale rather than left to find the new set.
|
|
176
|
+
// Nothing waits on the dropped reply: this teardown runs from
|
|
177
|
+
// `session_shutdown`, and the replacement that fired it already aborted the
|
|
178
|
+
// turn (`AgentSessionRuntime.teardownCurrent` → `session.abort()`), whose
|
|
179
|
+
// AbortSignal is what kills the child that asked (`subagent/spawn.ts`'s abort
|
|
180
|
+
// handler). The ask's requester is gone with the session.
|
|
181
|
+
generation++;
|
|
182
|
+
for (const unsubscribe of unsubscribers.splice(0)) unsubscribe();
|
|
183
|
+
subscribedOn = undefined;
|
|
184
|
+
// Cancel first, then drop: a cancelled Client request settles asynchronously
|
|
185
|
+
// and its paired ASK_RESPONSE finds no listener by then (the refcount is
|
|
186
|
+
// reset below, not decremented, so the pair cannot double-count).
|
|
187
|
+
for (const [, entry] of pending) entry.cancel();
|
|
188
|
+
pending.clear();
|
|
189
|
+
refcount = 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
145
192
|
/**
|
|
146
193
|
* Send a forwarded (subagent) ask to the Client and wire the response. The
|
|
147
194
|
* bridge is the sole emitter of child-path ASK_RESPONSE: on a settled ask it
|
|
148
195
|
* emits the Client's response verbatim; on a failure (error / timeout /
|
|
149
196
|
* cancel / child exit) it emits a cancelled ASK_RESPONSE so the refcount
|
|
150
197
|
* always pairs.
|
|
198
|
+
*
|
|
199
|
+
* The answer rides back out on the handle this subscription set was started
|
|
200
|
+
* on — the same handle `subagent/spawn.ts` is listening on to write it down the
|
|
201
|
+
* child's socket.
|
|
151
202
|
*/
|
|
152
203
|
function requestAskToClient(
|
|
153
204
|
payload: {
|
|
@@ -159,39 +210,47 @@ function requestAskToClient(
|
|
|
159
210
|
): { cancel: () => void } {
|
|
160
211
|
const opts: { toolCallId?: string; source?: string } = { source: payload.source };
|
|
161
212
|
if (payload.toolCallId !== undefined) opts.toolCallId = payload.toolCallId;
|
|
213
|
+
// The generation this ask belongs to. `stop()` cancels the request and bumps
|
|
214
|
+
// it, so the settle callback below (a microtask later) can tell whether it is
|
|
215
|
+
// still answering into the subscription set that asked — otherwise a reply to
|
|
216
|
+
// a dead session decrements the live session's refcount and reports a pending
|
|
217
|
+
// prompt as answered.
|
|
162
218
|
const handle = request<{
|
|
163
219
|
cancelled: boolean;
|
|
164
220
|
results: Array<{ id: string; selectedOptions: string[]; customInput?: string }>;
|
|
165
221
|
}>("ask", payload.questions, opts);
|
|
166
222
|
|
|
223
|
+
const askedAtGeneration = generation;
|
|
224
|
+
const answer = (response: { cancelled: boolean; results: unknown[] }): void => {
|
|
225
|
+
if (askedAtGeneration !== generation) return;
|
|
226
|
+
emitOn(subscribedOn, Events.ASK_RESPONSE, {
|
|
227
|
+
requestId: payload.requestId,
|
|
228
|
+
cancelled: response.cancelled,
|
|
229
|
+
results: response.results,
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
|
|
167
233
|
handle.promise
|
|
168
234
|
.then((resp) => {
|
|
169
|
-
|
|
170
|
-
requestId: payload.requestId,
|
|
171
|
-
cancelled: resp.cancelled,
|
|
172
|
-
results: resp.results,
|
|
173
|
-
});
|
|
235
|
+
answer({ cancelled: resp.cancelled, results: resp.results });
|
|
174
236
|
pending.delete(payload.requestId);
|
|
175
237
|
})
|
|
176
238
|
.catch(() => {
|
|
177
|
-
|
|
178
|
-
requestId: payload.requestId,
|
|
179
|
-
cancelled: true,
|
|
180
|
-
results: payload.questions.map((q) => ({ id: q.id, selectedOptions: [] })),
|
|
181
|
-
});
|
|
239
|
+
answer({ cancelled: true, results: payload.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
|
|
182
240
|
pending.delete(payload.requestId);
|
|
183
241
|
});
|
|
184
242
|
|
|
185
243
|
return { cancel: handle.cancel };
|
|
186
244
|
}
|
|
187
245
|
|
|
188
|
-
/** Test seam: reset the module singletons and tear down the
|
|
246
|
+
/** Test seam: reset the module singletons and tear down the subscriptions. */
|
|
189
247
|
export function __resetForTests(): void {
|
|
248
|
+
generation++;
|
|
190
249
|
refcount = 0;
|
|
191
250
|
settled = false;
|
|
192
|
-
started = false;
|
|
193
251
|
sessionCtx = undefined;
|
|
194
252
|
pending.clear();
|
|
195
253
|
for (const u of unsubscribers) u();
|
|
196
254
|
unsubscribers.length = 0;
|
|
255
|
+
subscribedOn = undefined;
|
|
197
256
|
}
|
package/src/bridge/index.test.ts
CHANGED
|
@@ -5,7 +5,7 @@ import * as os from "node:os";
|
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
|
-
import {
|
|
8
|
+
import { Events } from "../events.js";
|
|
9
9
|
import {
|
|
10
10
|
getBridge,
|
|
11
11
|
ask,
|
|
@@ -57,13 +57,40 @@ function makeFakeCtx(mode: "tui" | "rpc" | "json" | "print"): { mode: string } {
|
|
|
57
57
|
/** Register the bridge and fire its session_start handler with the given ctx. */
|
|
58
58
|
function registerAndFireSessionStart(ctx: { mode: string }): void {
|
|
59
59
|
const onSpy = vi.fn();
|
|
60
|
-
registerBridge({ on: onSpy } as unknown as ExtensionAPI);
|
|
60
|
+
registerBridge({ on: onSpy, events: bus } as unknown as ExtensionAPI);
|
|
61
61
|
const calls = onSpy.mock.calls as Array<[string, (e: unknown, ctx: unknown) => void]>;
|
|
62
62
|
const startCall = calls.find((c) => c[0] === "session_start");
|
|
63
63
|
expect(startCall).toBeTruthy();
|
|
64
64
|
startCall;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The host handle the relay subscribes on, stood in for by the same
|
|
69
|
+
* emit/on/unsubscribe contract pi's `createEventBus()` implements. Per test,
|
|
70
|
+
* because the relay's subscriptions now live on the handle rather than on a
|
|
71
|
+
* process-wide singleton.
|
|
72
|
+
*/
|
|
73
|
+
function fakeBus() {
|
|
74
|
+
const listeners = new Map<string, Array<(data: unknown) => void>>();
|
|
75
|
+
return {
|
|
76
|
+
emit(channel: string, data: unknown): void {
|
|
77
|
+
for (const handler of [...(listeners.get(channel) ?? [])]) handler(data);
|
|
78
|
+
},
|
|
79
|
+
on(channel: string, handler: (data: unknown) => void): () => void {
|
|
80
|
+
const subs = listeners.get(channel) ?? [];
|
|
81
|
+
subs.push(handler);
|
|
82
|
+
listeners.set(channel, subs);
|
|
83
|
+
return () => {
|
|
84
|
+
const current = listeners.get(channel) ?? [];
|
|
85
|
+
const idx = current.indexOf(handler);
|
|
86
|
+
if (idx !== -1) current.splice(idx, 1);
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let bus: ReturnType<typeof fakeBus>;
|
|
93
|
+
|
|
67
94
|
function tempSocketPath(): string {
|
|
68
95
|
return path.join(os.tmpdir(), `bridge-test-${randomUUID()}.sock`);
|
|
69
96
|
}
|
|
@@ -109,6 +136,7 @@ function startServer(
|
|
|
109
136
|
beforeEach(() => {
|
|
110
137
|
for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
|
|
111
138
|
clearBridgeEnv();
|
|
139
|
+
bus = fakeBus();
|
|
112
140
|
resetChannel();
|
|
113
141
|
resetEvents();
|
|
114
142
|
});
|
|
@@ -187,7 +215,7 @@ describe("inactive API", () => {
|
|
|
187
215
|
|
|
188
216
|
describe("state machine", () => {
|
|
189
217
|
it("onAgentSettled → idle; onAgentStart → working", () => {
|
|
190
|
-
start();
|
|
218
|
+
start(bus);
|
|
191
219
|
onAgentSettled();
|
|
192
220
|
expect(state()).toBe("idle");
|
|
193
221
|
onAgentStart();
|
|
@@ -195,41 +223,41 @@ describe("state machine", () => {
|
|
|
195
223
|
});
|
|
196
224
|
|
|
197
225
|
it("ASK_REQUEST → blocked; stays blocked through onAgentSettled; ASK_RESPONSE → idle (settled)", () => {
|
|
198
|
-
start();
|
|
226
|
+
start(bus);
|
|
199
227
|
onAgentSettled(); // settled = true
|
|
200
|
-
|
|
228
|
+
bus.emit(Events.ASK_REQUEST, { source: "main", requestId: "r1", questions: [makeQuestion()] });
|
|
201
229
|
expect(state()).toBe("blocked");
|
|
202
230
|
onAgentSettled(); // still blocked (refcount > 0)
|
|
203
231
|
expect(state()).toBe("blocked");
|
|
204
|
-
|
|
232
|
+
bus.emit(Events.ASK_RESPONSE, { requestId: "r1", cancelled: false, results: [] });
|
|
205
233
|
expect(state()).toBe("idle"); // settled
|
|
206
234
|
});
|
|
207
235
|
|
|
208
236
|
it("ASK_RESPONSE → working (not settled)", () => {
|
|
209
|
-
start();
|
|
237
|
+
start(bus);
|
|
210
238
|
onAgentStart(); // settled = false
|
|
211
|
-
|
|
239
|
+
bus.emit(Events.ASK_REQUEST, { source: "main", requestId: "r2", questions: [makeQuestion()] });
|
|
212
240
|
expect(state()).toBe("blocked");
|
|
213
|
-
|
|
241
|
+
bus.emit(Events.ASK_RESPONSE, { requestId: "r2", cancelled: false, results: [] });
|
|
214
242
|
expect(state()).toBe("working"); // not settled
|
|
215
243
|
});
|
|
216
244
|
|
|
217
245
|
it("an unpaired ASK_RESPONSE does not drive the refcount negative (floor at 0)", () => {
|
|
218
|
-
start();
|
|
246
|
+
start(bus);
|
|
219
247
|
onAgentSettled(); // settled = true
|
|
220
248
|
expect(state()).toBe("idle");
|
|
221
249
|
|
|
222
250
|
// A spurious/duplicated ASK_RESPONSE with no matching ASK_REQUEST must not
|
|
223
251
|
// push the refcount below zero (the floor keeps the machine honest).
|
|
224
|
-
|
|
252
|
+
bus.emit(Events.ASK_RESPONSE, { requestId: "orphan", cancelled: false, results: [] });
|
|
225
253
|
expect(state()).toBe("idle"); // floor at 0 (not -1)
|
|
226
254
|
|
|
227
255
|
// A legitimate ask is still blocked (the floor prevented the negative count).
|
|
228
|
-
|
|
256
|
+
bus.emit(Events.ASK_REQUEST, { source: "main", requestId: "r3", questions: [makeQuestion()] });
|
|
229
257
|
expect(state()).toBe("blocked");
|
|
230
258
|
|
|
231
259
|
// ...and it still unblocks cleanly.
|
|
232
|
-
|
|
260
|
+
bus.emit(Events.ASK_RESPONSE, { requestId: "r3", cancelled: false, results: [] });
|
|
233
261
|
expect(state()).toBe("idle"); // settled
|
|
234
262
|
});
|
|
235
263
|
});
|
|
@@ -243,13 +271,13 @@ describe("ASK_CANCEL flow", () => {
|
|
|
243
271
|
/* accept but never respond */
|
|
244
272
|
});
|
|
245
273
|
|
|
246
|
-
start();
|
|
274
|
+
start(bus);
|
|
247
275
|
configure({ active: true, socketPath: sockPath });
|
|
248
276
|
|
|
249
277
|
const responses: unknown[] = [];
|
|
250
|
-
const unsub =
|
|
278
|
+
const unsub = bus.on(Events.ASK_RESPONSE, (p) => responses.push(p));
|
|
251
279
|
|
|
252
|
-
|
|
280
|
+
bus.emit(Events.ASK_REQUEST, {
|
|
253
281
|
source: "subagent:x",
|
|
254
282
|
requestId: "sub-req-1",
|
|
255
283
|
questions: [makeQuestion()],
|
|
@@ -259,7 +287,7 @@ describe("ASK_CANCEL flow", () => {
|
|
|
259
287
|
// Give the forwarded request a moment to connect to the (silent) server.
|
|
260
288
|
await new Promise((r) => setTimeout(r, 50));
|
|
261
289
|
|
|
262
|
-
|
|
290
|
+
bus.emit(Events.ASK_CANCEL, { requestId: "sub-req-1", source: "subagent:x" });
|
|
263
291
|
|
|
264
292
|
// The cancel closes the socket → the request rejects → a cancelled ASK_RESPONSE is emitted.
|
|
265
293
|
await vi.waitFor(() => {
|
|
@@ -278,8 +306,8 @@ describe("ASK_CANCEL flow", () => {
|
|
|
278
306
|
});
|
|
279
307
|
|
|
280
308
|
it("ASK_CANCEL with no pending entry is a no-op (no crash, refcount untouched)", () => {
|
|
281
|
-
start();
|
|
282
|
-
|
|
309
|
+
start(bus);
|
|
310
|
+
bus.emit(Events.ASK_CANCEL, { requestId: "does-not-exist", source: "subagent:x" });
|
|
283
311
|
expect(state()).toBe("working"); // refcount still 0
|
|
284
312
|
});
|
|
285
313
|
});
|
|
@@ -684,13 +712,13 @@ describe("subagent ask success path", () => {
|
|
|
684
712
|
});
|
|
685
713
|
await new Promise<void>((resolve) => server.listen(sockPath, resolve));
|
|
686
714
|
|
|
687
|
-
start();
|
|
715
|
+
start(bus);
|
|
688
716
|
configure({ active: true, socketPath: sockPath });
|
|
689
717
|
|
|
690
718
|
const responses: unknown[] = [];
|
|
691
|
-
const unsub =
|
|
719
|
+
const unsub = bus.on(Events.ASK_RESPONSE, (p) => responses.push(p));
|
|
692
720
|
|
|
693
|
-
|
|
721
|
+
bus.emit(Events.ASK_REQUEST, {
|
|
694
722
|
source: "subagent:x",
|
|
695
723
|
requestId: "sub-req-2",
|
|
696
724
|
toolCallId: "child-tool-1",
|
package/src/bridge/index.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// password → "" (cancel).
|
|
11
11
|
|
|
12
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import type { AskResponsePayload, AskRequestPayload } from "../
|
|
13
|
+
import type { AskResponsePayload, AskRequestPayload } from "../events.js";
|
|
14
14
|
import * as channel from "./channel.js";
|
|
15
15
|
import * as events from "./events.js";
|
|
16
16
|
|
|
@@ -144,16 +144,24 @@ function isBridgeMode(ctx: { mode: string }): boolean {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
/**
|
|
147
|
-
* Subscribe the pi EXTENSION events (NOT the
|
|
148
|
-
* agent_start/agent_settled/session_start
|
|
149
|
-
* fire in RPC mode (the RPC driver runs the same session machinery).
|
|
147
|
+
* Subscribe the pi EXTENSION events (NOT the channel vocabulary — nothing emits
|
|
148
|
+
* agent_start/agent_settled/session_start onto the event bus). The extension
|
|
149
|
+
* events DO fire in RPC mode (the RPC driver runs the same session machinery).
|
|
150
|
+
*
|
|
151
|
+
* The relay's own subscriptions are opened at `session_start` (activation is
|
|
152
|
+
* decided there) and closed at `session_shutdown`, which is registered here at
|
|
153
|
+
* the top level — never inside the `session_start` handler (AGENTS.md: nested
|
|
154
|
+
* registration accumulates handlers across `/reload`). It is not just hygiene:
|
|
155
|
+
* the host drops a runtime's event-bus subscriptions when the runtime is
|
|
156
|
+
* invalidated, so a relay that never tears down ends up describing itself with
|
|
157
|
+
* state whose subscriptions no longer exist.
|
|
150
158
|
*/
|
|
151
159
|
export function registerBridge(pi: ExtensionAPI): void {
|
|
152
160
|
pi.on("session_start", (_e, ctx) => {
|
|
153
161
|
const active = isBridgeMode(ctx);
|
|
154
162
|
channel.configure({ active, socketPath: process.env.PI_ARCHIMEDES_BRIDGE_SOCKET });
|
|
155
163
|
if (active) {
|
|
156
|
-
events.start();
|
|
164
|
+
events.start(pi.events);
|
|
157
165
|
events.onSessionStart(ctx);
|
|
158
166
|
}
|
|
159
167
|
});
|
|
@@ -163,4 +171,9 @@ export function registerBridge(pi: ExtensionAPI): void {
|
|
|
163
171
|
pi.on("agent_settled", () => {
|
|
164
172
|
if (channelActive()) events.onAgentSettled();
|
|
165
173
|
});
|
|
174
|
+
// Unconditional: an inactive bridge never subscribed, and stop() on nothing
|
|
175
|
+
// subscribed is a no-op — the safe direction for a teardown.
|
|
176
|
+
pi.on("session_shutdown", () => {
|
|
177
|
+
events.stop();
|
|
178
|
+
});
|
|
166
179
|
}
|
|
@@ -1,30 +1,57 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import {
|
|
3
|
+
import { Events } from "../events.js";
|
|
4
4
|
import { registerSelfUsage } from "./self-usage.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* unsubscribes via the `on` return value.
|
|
7
|
+
* The emitter emits on the handle it was handed (`pi.events`), so every test
|
|
8
|
+
* builds its own handle and subscribes its own listener before invoking the
|
|
9
|
+
* handler. Nothing is process-wide any more, so there is no queue and no replay
|
|
10
|
+
* to design around — which is the point of the migration.
|
|
12
11
|
*/
|
|
12
|
+
function fakeBus() {
|
|
13
|
+
const listeners = new Map<string, Array<(data: unknown) => void>>();
|
|
14
|
+
return {
|
|
15
|
+
emit(channel: string, data: unknown): void {
|
|
16
|
+
for (const handler of [...(listeners.get(channel) ?? [])]) handler(data);
|
|
17
|
+
},
|
|
18
|
+
on(channel: string, handler: (data: unknown) => void): () => void {
|
|
19
|
+
const subs = listeners.get(channel) ?? [];
|
|
20
|
+
subs.push(handler);
|
|
21
|
+
listeners.set(channel, subs);
|
|
22
|
+
return () => {
|
|
23
|
+
const current = listeners.get(channel) ?? [];
|
|
24
|
+
const idx = current.indexOf(handler);
|
|
25
|
+
if (idx !== -1) current.splice(idx, 1);
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The handle this test's emitter was handed. Fresh per test. */
|
|
32
|
+
let bus: ReturnType<typeof fakeBus>;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
bus = fakeBus();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** Subscribe to COST_UPDATE on that handle. */
|
|
13
39
|
function captureCostUpdates(): { events: unknown[]; done: () => void } {
|
|
14
|
-
const
|
|
15
|
-
const unsub =
|
|
16
|
-
return { events, done: unsub };
|
|
40
|
+
const captured: unknown[] = [];
|
|
41
|
+
const unsub = bus.on(Events.COST_UPDATE, (p: unknown) => captured.push(p));
|
|
42
|
+
return { events: captured, done: unsub };
|
|
17
43
|
}
|
|
18
44
|
|
|
19
45
|
/**
|
|
20
46
|
* Register and capture the turn_end handler, using the repo's mock-ExtensionAPI
|
|
21
47
|
* idiom (verbatim from bridge/index.test.ts — a plain object fails `tsc` with
|
|
22
48
|
* TS2740; the `as unknown as ExtensionAPI` cast and the `mock.calls` re-typing
|
|
23
|
-
* are BOTH required).
|
|
49
|
+
* are BOTH required). The handle the emitter gets is this test's bus, so the
|
|
50
|
+
* test can subscribe to the same one.
|
|
24
51
|
*/
|
|
25
52
|
function registerAndGetTurnEndHandler(): (e: unknown, ctx: unknown) => void {
|
|
26
53
|
const onSpy = vi.fn();
|
|
27
|
-
registerSelfUsage({ on: onSpy } as unknown as ExtensionAPI);
|
|
54
|
+
registerSelfUsage({ on: onSpy, events: bus } as unknown as ExtensionAPI);
|
|
28
55
|
const calls = onSpy.mock.calls as Array<[string, (e: unknown, ctx: unknown) => void]>;
|
|
29
56
|
const handler = calls.find(([event]) => event === "turn_end")![1];
|
|
30
57
|
expect(handler).toBeTruthy();
|
|
@@ -144,3 +171,54 @@ describe("bridge independence", () => {
|
|
|
144
171
|
}
|
|
145
172
|
});
|
|
146
173
|
});
|
|
174
|
+
|
|
175
|
+
// ── 5. the queue is gone, and nothing needed it ───────────────────────
|
|
176
|
+
|
|
177
|
+
describe("no pre-subscriber queue", () => {
|
|
178
|
+
/**
|
|
179
|
+
* The retired `globalThis` bus queued an emit that had no listener and replayed
|
|
180
|
+
* it to whoever subscribed next. The host bus does not: an emit with no
|
|
181
|
+
* subscriber is dropped. That is the property the migration had to answer for,
|
|
182
|
+
* so it is pinned here rather than assumed.
|
|
183
|
+
*
|
|
184
|
+
* Dropping is safe for this emitter, and the reason is call order: it fires at
|
|
185
|
+
* `turn_end`, and its one consumer (`bridge/events.ts`) subscribes at
|
|
186
|
+
* `session_start`, which pi always fires first — a turn cannot end before its
|
|
187
|
+
* session started. Every other emitter in the suite is later still (a tool
|
|
188
|
+
* call, a child's socket, a child's stdout). So no ordering guard was kept, and
|
|
189
|
+
* this is precisely the case that would need one if that ever stopped holding.
|
|
190
|
+
*/
|
|
191
|
+
it("an emit with no subscriber is not replayed to the next subscriber", () => {
|
|
192
|
+
const handler = registerAndGetTurnEndHandler();
|
|
193
|
+
handler(
|
|
194
|
+
{
|
|
195
|
+
type: "turn_end",
|
|
196
|
+
turnIndex: 0,
|
|
197
|
+
message: assistantMessage({ usage: { input: 5, output: 1, cost: { total: 0.5 } } }),
|
|
198
|
+
},
|
|
199
|
+
{ mode: "tui" },
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const { events, done } = captureCostUpdates();
|
|
203
|
+
expect(events).toHaveLength(0);
|
|
204
|
+
done();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("the same emit DOES reach a subscriber that was already listening", () => {
|
|
208
|
+
const handler = registerAndGetTurnEndHandler();
|
|
209
|
+
const { events, done } = captureCostUpdates();
|
|
210
|
+
try {
|
|
211
|
+
handler(
|
|
212
|
+
{
|
|
213
|
+
type: "turn_end",
|
|
214
|
+
turnIndex: 0,
|
|
215
|
+
message: assistantMessage({ usage: { input: 5, output: 1, cost: { total: 0.5 } } }),
|
|
216
|
+
},
|
|
217
|
+
{ mode: "tui" },
|
|
218
|
+
);
|
|
219
|
+
expect(events).toHaveLength(1);
|
|
220
|
+
} finally {
|
|
221
|
+
done();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
package/src/bridge/self-usage.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { Events, emitOn } from "../events.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Minimal structural mirror of the pi-ai `Usage` type (core does not depend on
|
|
@@ -16,16 +16,29 @@ interface UsageLike {
|
|
|
16
16
|
/**
|
|
17
17
|
* Emit the session's OWN usage as a per-turn COST_UPDATE (source "main").
|
|
18
18
|
* Per design decision 1-3: per-turn (turn_end), delta (the turn's usage is
|
|
19
|
-
* not cumulative), NOT gated on bridge mode (
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* not cumulative), NOT gated on bridge mode. `reasoning` is excluded (a subset
|
|
20
|
+
* of `output` — double-count).
|
|
21
|
+
*
|
|
22
|
+
* Who listens now (Task 9 of the pi 1.x alignment changed this): the bridge
|
|
23
|
+
* forwarder in `./events.ts` relays it to the desktop as a `cost_update` frame.
|
|
24
|
+
* The TUI's consumer is **gone** — the footer's `CostAccumulator` was deleted
|
|
25
|
+
* with it, because the footer reads usage from the session entries and those
|
|
26
|
+
* already carry the main agent's assistant usage; keeping the accumulator would
|
|
27
|
+
* have counted the main session's money twice. So on a plain TUI this emit has
|
|
28
|
+
* no in-suite subscriber left, and the host bus drops it (unlike the old bus,
|
|
29
|
+
* which queued it for a subscriber that never came). Deleting the emitter is a
|
|
30
|
+
* bridge-contract question, not part of the transport migration: `cost_update`
|
|
31
|
+
* is a frame the Archimedes Client may render.
|
|
32
|
+
*
|
|
33
|
+
* Ordering is safe without a queue. The forwarder subscribes at `session_start`
|
|
34
|
+
* and this fires at `turn_end`, which pi always fires later — see
|
|
35
|
+
* `self-usage.test.ts` for the executable version of that claim.
|
|
23
36
|
*/
|
|
24
37
|
export function registerSelfUsage(pi: ExtensionAPI): void {
|
|
25
38
|
pi.on("turn_end", (event) => {
|
|
26
39
|
const usage = (event.message as { usage?: UsageLike } | undefined)?.usage;
|
|
27
40
|
if (!usage) return;
|
|
28
|
-
|
|
41
|
+
emitOn(pi.events, Events.COST_UPDATE, {
|
|
29
42
|
source: "main",
|
|
30
43
|
inputTokens: usage.input ?? 0,
|
|
31
44
|
outputTokens: usage.output ?? 0,
|