@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.
@@ -1,9 +1,9 @@
1
- // ── Bridge events (bus subscriptions — the state machine + subagent
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 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
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
- // 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";
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
- let started = false; // the start() idempotency guard (sole writer is start())
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 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).
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
- if (started) return;
88
- started = true;
100
+ export function start(events: EventBus): void {
101
+ stop();
102
+ subscribedOn = events;
89
103
 
90
- unsubscribers.push(getBus().on(Events.ASK_REQUEST, (payload: unknown) => {
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(getBus().on(Events.ASK_RESPONSE, () => {
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) console.warn("[archimedes:bridge] unpaired ASK_RESPONSE received (refcount already 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(getBus().on(Events.ASK_CANCEL, (payload: unknown) => {
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(getBus().on(Events.TODOS_UPDATE, (payload: unknown) => {
146
+ unsubscribers.push(events.on(Events.TODOS_UPDATE, (payload: unknown) => {
133
147
  sendEvent("todos_update", payload);
134
148
  }));
135
149
 
136
- unsubscribers.push(getBus().on(Events.TODOS_CLEAR, (payload: unknown) => {
150
+ unsubscribers.push(events.on(Events.TODOS_CLEAR, (payload: unknown) => {
137
151
  sendEvent("todos_clear", payload);
138
152
  }));
139
153
 
140
- unsubscribers.push(getBus().on(Events.COST_UPDATE, (payload: unknown) => {
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
- getBus().emit(Events.ASK_RESPONSE, {
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
- getBus().emit(Events.ASK_RESPONSE, {
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 bus subscriptions. */
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
  }
@@ -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 { getBus, Events } from "../bus.js";
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![1](null, ctx);
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
- getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: "r1", questions: [makeQuestion()] });
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
- getBus().emit(Events.ASK_RESPONSE, { requestId: "r1", cancelled: false, results: [] });
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
- getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: "r2", questions: [makeQuestion()] });
239
+ bus.emit(Events.ASK_REQUEST, { source: "main", requestId: "r2", questions: [makeQuestion()] });
212
240
  expect(state()).toBe("blocked");
213
- getBus().emit(Events.ASK_RESPONSE, { requestId: "r2", cancelled: false, results: [] });
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
- getBus().emit(Events.ASK_RESPONSE, { requestId: "orphan", cancelled: false, results: [] });
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
- getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: "r3", questions: [makeQuestion()] });
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
- getBus().emit(Events.ASK_RESPONSE, { requestId: "r3", cancelled: false, results: [] });
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 = getBus().on(Events.ASK_RESPONSE, (p) => responses.push(p));
278
+ const unsub = bus.on(Events.ASK_RESPONSE, (p) => responses.push(p));
251
279
 
252
- getBus().emit(Events.ASK_REQUEST, {
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
- getBus().emit(Events.ASK_CANCEL, { requestId: "sub-req-1", source: "subagent:x" });
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
- getBus().emit(Events.ASK_CANCEL, { requestId: "does-not-exist", source: "subagent:x" });
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 = getBus().on(Events.ASK_RESPONSE, (p) => responses.push(p));
719
+ const unsub = bus.on(Events.ASK_RESPONSE, (p) => responses.push(p));
692
720
 
693
- getBus().emit(Events.ASK_REQUEST, {
721
+ bus.emit(Events.ASK_REQUEST, {
694
722
  source: "subagent:x",
695
723
  requestId: "sub-req-2",
696
724
  toolCallId: "child-tool-1",
@@ -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 "../bus.js";
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 bus — nothing emits
148
- * agent_start/agent_settled/session_start on the bus). The extension events DO
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 { getBus, Events } from "../bus.js";
3
+ import { Events } from "../events.js";
4
4
  import { registerSelfUsage } from "./self-usage.js";
5
5
 
6
6
  /**
7
- * Bus discipline (load-bearing): the bus has NO reset seam, `emit` with zero
8
- * subscribers queues the payload globally, and `on()` replays queued events
9
- * WITHOUT removing them. So EVERY test subscribes a per-test listener FIRST,
10
- * then invokes the handler, asserts only on that test's captured array, and
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 events: unknown[] = [];
15
- const unsub = getBus().on(Events.COST_UPDATE, (p) => events.push(p));
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
+ });
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { getBus, Events } from "../bus.js";
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 (the TUI footer's
20
- * CostAccumulator sums it — the footer's cost line becomes the true session
21
- * cost; in bridge mode the existing events.ts forwarder carries it to the
22
- * desktop). `reasoning` is excluded (a subset of `output` — double-count).
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
- getBus().emit(Events.COST_UPDATE, {
41
+ emitOn(pi.events, Events.COST_UPDATE, {
29
42
  source: "main",
30
43
  inputTokens: usage.input ?? 0,
31
44
  outputTokens: usage.output ?? 0,