@pi-archimedes/core 2.10.4 → 2.11.1

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/src/chrome.ts CHANGED
@@ -1,4 +1,10 @@
1
- import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
1
+ import type { Theme, ThemeAppearance, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ // A namespace import, deliberately: `foregroundAnsi` is pi-tui 1.x, and a named
3
+ // import of a name an older host never exported fails when the module *links* —
4
+ // before any of this package runs, taking the palette and everyone who imports it
5
+ // down. ADR 0026's rule is to warn about an old host, never break it.
6
+ import * as piTui from "@earendil-works/pi-tui";
7
+ import type { Color, TerminalColorMode } from "@earendil-works/pi-tui";
2
8
 
3
9
  // ── ANSI constants ───────────────────────────────────────────────────────────
4
10
 
@@ -14,10 +20,17 @@ export const AUTOCOMPLETE_CURSOR = "›";
14
20
  export const HINT_MARGIN_RIGHT = 3;
15
21
  export const PAD_X = 1;
16
22
 
17
- // ── Fallback ANSI codes (neutral near-black) ─────────────────────────────────
23
+ // ── Fallback ANSI codes (no-theme-object path only) ─────────────────────────
24
+
25
+ // A palette is only ever built without a theme in tests and headless runs, so
26
+ // these constants stay hardcoded — but they still respect a light appearance
27
+ // when one can be read, because a near-black slab on a light terminal is the
28
+ // bug this file used to ship.
29
+ const PANEL_FALLBACK: Readonly<Record<ThemeAppearance, PanelColor>> = {
30
+ dark: { panelBg: "\x1b[48;2;16;16;16m", panelEdge: "\x1b[38;2;16;16;16m" },
31
+ light: { panelBg: "\x1b[48;2;238;238;238m", panelEdge: "\x1b[38;2;238;238;238m" },
32
+ };
18
33
 
19
- const FALLBACK_PANEL_BG = "\x1b[48;2;16;16;16m";
20
- const FALLBACK_PANEL_EDGE = "\x1b[38;2;16;16;16m";
21
34
  const FALLBACK_FG = "\x1b[38;2;74;74;74m"; // #4a4a4a structural gray
22
35
 
23
36
  // ── Palette ──────────────────────────────────────────────────────────────────
@@ -36,12 +49,18 @@ export interface PanePalette {
36
49
 
37
50
  function tryGetBgAnsi(theme: Theme, key: ThemeBg): string | undefined {
38
51
  try {
39
- return (theme as any).getBgAnsi(key);
52
+ return theme.getBgAnsi(key);
40
53
  } catch {
54
+ // Unknown token (`tokenAnsi` throws in 1.x), or no theme object at all.
41
55
  return undefined;
42
56
  }
43
57
  }
44
58
 
59
+ /** `Theme.appearance` exists from pi 0.99.0; older hosts report nothing. */
60
+ function themeAppearance(theme: Theme | undefined): ThemeAppearance | undefined {
61
+ return (theme as { appearance?: ThemeAppearance } | undefined)?.appearance;
62
+ }
63
+
45
64
  function tryFg(theme: Theme, key: ThemeColor, text: string): string | undefined {
46
65
  try {
47
66
  return theme.fg(key, text);
@@ -58,12 +77,119 @@ function safeThemeColor(theme: Theme, keys: ThemeColor[], text: string): string
58
77
  return fgWrap(FALLBACK_FG, text);
59
78
  }
60
79
 
61
- /** Convert a 48;2 or 48;5 bg ANSI code to its fg equivalent (38;…). */
62
- function bgToFgAnsi(bg: string): string {
63
- if (bg.startsWith("\x1b[48;2;") || bg.startsWith("\x1b[48;5;")) {
64
- return bg.replace("\x1b[48;", "\x1b[38;");
80
+ // ── Terminal-default tokens: "no color", not "some color" ──────────────────
81
+
82
+ /**
83
+ * The escape sequences that mean *paint nothing, use the terminal's own
84
+ * color*. Since pi 0.99.0 a theme token is allowed to be `""` (terminal
85
+ * default), and `Theme.getBgAnsi()` / `getFgAnsi()` render that as `\x1b[49m` /
86
+ * `\x1b[39m` — **truthy** strings, so a `getBgAnsi(…) ?? FALLBACK` chain never
87
+ * reaches the fallback and a `… || HARD_COLOR` chain never fires. Measured on
88
+ * pi 1.2.0: a theme with `userMessageBg: ""` answers `"\x1b[49m"`, and one with
89
+ * `borderMuted: ""` answers `"\x1b[39m"`.
90
+ *
91
+ * Membership here is a *comparison*, never a decode: nothing in this file reads
92
+ * the color an escape happens to encode. Suite rule — decide color from the
93
+ * theme, never by parsing an escape sequence.
94
+ */
95
+ const TERMINAL_DEFAULT_SEQUENCES: ReadonlySet<string> = new Set([
96
+ "", // the answer itself, pre-0.99.0
97
+ RESET, // "\x1b[0m": a full reset leaves no colour set
98
+ "\x1b[m", // …and its parameter-less form
99
+ "\x1b[39m", // a foreground token set to ""
100
+ "\x1b[49m", // a background token set to ""
101
+ ]);
102
+
103
+ /** Does this theme answer mean "the terminal's own color", i.e. no color? */
104
+ export function isTerminalDefault(ansi: string | undefined): boolean {
105
+ return ansi === undefined || TERMINAL_DEFAULT_SEQUENCES.has(ansi);
106
+ }
107
+
108
+ // ── Panel tint and edge ─────────────────────────────────────────────────────
109
+
110
+ interface PanelColor {
111
+ /** Opening background escape for the panel body, or "" for no tint. */
112
+ panelBg: string;
113
+ /** Opening foreground escape for the edge slabs, or "" for no edge. */
114
+ panelEdge: string;
115
+ }
116
+
117
+ /** No color at all: panel and edges render in the terminal's own. */
118
+ const NO_PANEL_COLOR: PanelColor = { panelBg: "", panelEdge: "" };
119
+
120
+ /** Queried in order; the first answer wins — a terminal default included. */
121
+ const PANEL_BG_TOKENS: readonly ThemeBg[] = ["userMessageBg", "customMessageBg"];
122
+
123
+ /** The token the panel is tinted with, and the escape it answers with. */
124
+ interface PanelTint {
125
+ token: ThemeBg;
126
+ ansi: string;
127
+ }
128
+
129
+ function panelTint(theme: Theme): PanelTint | undefined {
130
+ for (const token of PANEL_BG_TOKENS) {
131
+ const ansi = tryGetBgAnsi(theme, token);
132
+ if (ansi !== undefined) return { token, ansi };
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ /** pi-tui's own "colour → foreground escape" emitter, if this host has one. */
138
+ type ForegroundAnsi = (color: Color, mode: TerminalColorMode) => string;
139
+ const emitForeground = (
140
+ piTui as Partial<Record<string, unknown>>
141
+ ).foregroundAnsi as ForegroundAnsi | undefined;
142
+
143
+ /**
144
+ * The edge slabs are the **panel's own colour, asked for as a foreground**.
145
+ *
146
+ * A slab sits hard against the panel body, so it is a cushion rather than a
147
+ * border: in the panel's own tint it is invisible by construction, and in any
148
+ * other token — a border, for instance — it is a visible rule hugging the text
149
+ * area. pi accepts a token only in its own slot (asking the foreground for
150
+ * `userMessageBg` throws; measured on 1.2.0) and documents `theme.colors[token]`
151
+ * as the way to use a token's color in the other slot, so that is what we ask:
152
+ * the theme hands back its own `Color`, and the host's own emitter renders it as
153
+ * a foreground escape. The value is still *asked for*, never read out of a
154
+ * string, so the suite's colour rule stands — and reaching for a different
155
+ * token because it looks similar is the same mistake in a different costume.
156
+ *
157
+ * A theme that cannot answer — no `colors`, nothing for the winning token, an
158
+ * old host without the emitter — means **no colour**, and the editor blanks the
159
+ * rows. Never a substitute token.
160
+ */
161
+ function panelEdgeAnsi(theme: Theme, token: ThemeBg): string {
162
+ try {
163
+ const colors = (theme as { colors?: Partial<Record<ThemeBg, Color | undefined>> })
164
+ .colors;
165
+ const color = colors?.[token];
166
+ if (!color || !emitForeground) return "";
167
+ return emitForeground(color, theme.getColorMode());
168
+ } catch {
169
+ return "";
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Panel tint and edge, decided by theme tokens alone: the panel is painted with
175
+ * whatever the theme's own panel background token says (passed through, not
176
+ * re-emitted), and the edge slabs with that *same* token asked for as a
177
+ * foreground, so the cushion matches the surface it extends.
178
+ *
179
+ * Nothing is derived *from* an escape sequence. The old
180
+ * background-to-foreground helper matched `48;2;`/`48;5;` inside the background
181
+ * string and answered everything else — notably the `\x1b[49m` a
182
+ * terminal-default token produces — with near-black `#101010`, which is how an
183
+ * untinted panel ended up with black borders on a light terminal.
184
+ */
185
+ function resolvePanelColor(theme: Theme): PanelColor {
186
+ const tint = panelTint(theme);
187
+ if (tint === undefined) {
188
+ // Nothing to ask: no theme object, or one with no panel token at all.
189
+ return PANEL_FALLBACK[themeAppearance(theme) ?? "dark"];
65
190
  }
66
- return FALLBACK_PANEL_EDGE;
191
+ if (isTerminalDefault(tint.ansi)) return NO_PANEL_COLOR;
192
+ return { panelBg: tint.ansi, panelEdge: panelEdgeAnsi(theme, tint.token) };
67
193
  }
68
194
 
69
195
  function fgWrap(ansi: string, text: string): string {
@@ -71,14 +197,11 @@ function fgWrap(ansi: string, text: string): string {
71
197
  }
72
198
 
73
199
  export function resolvePalette(theme: Theme): PanePalette {
74
- const panelBg =
75
- tryGetBgAnsi(theme, "userMessageBg") ??
76
- tryGetBgAnsi(theme, "customMessageBg") ??
77
- FALLBACK_PANEL_BG;
200
+ const { panelBg, panelEdge } = resolvePanelColor(theme);
78
201
 
79
202
  return {
80
203
  panelBg,
81
- panelEdge: bgToFgAnsi(panelBg),
204
+ panelEdge,
82
205
  frame: (t) => safeThemeColor(theme, ["borderMuted", "border"], t),
83
206
  prefix: (t) => safeThemeColor(theme, ["borderMuted", "border"], t),
84
207
  spin: (t) => safeThemeColor(theme, ["accent", "borderMuted"], t),
@@ -86,8 +209,3 @@ export function resolvePalette(theme: Theme): PanePalette {
86
209
  hint: (t) => safeThemeColor(theme, ["dim", "muted"], t),
87
210
  };
88
211
  }
89
-
90
- /** Set a bg color on the theme's internal bgColors map. */
91
- export function setThemeBg(theme: Theme, key: ThemeBg, ansi: string): void {
92
- (theme as any)?.bgColors?.set(key, ansi);
93
- }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The emit helper against pi's REAL event bus (`createEventBus()`), not the
3
+ * suite's synchronous stand-in.
4
+ *
5
+ * Every other test in this migration drives a hand-written bus whose `emit`
6
+ * calls listeners inline and lets their throws escape. pi's bus is not built
7
+ * that way: `createEventBus()` (`dist/core/event-bus.js`) wraps each handler in
8
+ * an `async` function with its own `try/catch`, so a subscriber's throw is
9
+ * contained by the host and printed with `console.error`. The two transports
10
+ * are NOT interchangeable on that point, and the difference has two
11
+ * consequences worth having in writing:
12
+ *
13
+ * - Nothing in the suite can rely on a subscriber's throw reaching the emitter.
14
+ * The retired bus caught and logged it too, so no caller is broken, but a
15
+ * future "let the emitter see it" plan would be silently dead on arrival.
16
+ * - Where a listener failure is *reported* changed: the old bus wrote to
17
+ * `diag()` (our log file); the host writes to `console.error`, which is the
18
+ * one surface `log-discipline.test.ts` forbids precisely because it smears a
19
+ * fullscreen frame. Our listeners therefore have to report their own failures.
20
+ */
21
+ import { describe, it, expect, vi, afterEach } from "vitest";
22
+ import { createEventBus } from "@earendil-works/pi-coding-agent";
23
+ import { Events, emitOn } from "./events.js";
24
+
25
+ afterEach(() => {
26
+ vi.restoreAllMocks();
27
+ });
28
+
29
+ describe("pi's real event bus, as our emitters and listeners meet it", () => {
30
+ it("delivers a payload to a subscriber on the same handle", () => {
31
+ const bus = createEventBus();
32
+ const seen: unknown[] = [];
33
+ const unsub = bus.on(Events.TODOS_UPDATE, (p) => seen.push(p));
34
+ emitOn(bus, Events.TODOS_UPDATE, { source: "main", todos: [] });
35
+ expect(seen).toEqual([{ source: "main", todos: [] }]);
36
+ unsub();
37
+ bus.clear();
38
+ });
39
+
40
+ /**
41
+ * RED-FIRST EVIDENCE, kept as a pin. The migration note in
42
+ * `docs/decisions/0029` claimed a throwing subscriber is "still rethrown into
43
+ * the emitter, which is what the old bus did". It is not: written as
44
+ * `expect(() => emitOn(bus, …)).toThrow()` this test failed ("expected
45
+ * [Function] to throw an error") against pi 1.2.0, and the same is true of a
46
+ * bare `bus.emit`. The host swallows it.
47
+ */
48
+ it("does NOT rethrow a subscriber's throw into the emitter", async () => {
49
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
50
+ const bus = createEventBus();
51
+ const unsub = bus.on(Events.COST_UPDATE, () => {
52
+ throw new Error("listener exploded");
53
+ });
54
+
55
+ expect(() => bus.emit(Events.COST_UPDATE, { source: "main" })).not.toThrow();
56
+ expect(() => emitOn(bus, Events.COST_UPDATE, { source: "main" })).not.toThrow();
57
+ await new Promise((resolve) => setImmediate(resolve));
58
+ // The host does report it — on the console, which is not our log file.
59
+ expect(errSpy.mock.calls.some((call) => String(call[0]).includes("Event handler error"))).toBe(true);
60
+ unsub();
61
+ bus.clear();
62
+ });
63
+
64
+ it("a throwing subscriber does not cost the other subscribers the payload", async () => {
65
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
66
+ const bus = createEventBus();
67
+ const seen: unknown[] = [];
68
+ const a = bus.on(Events.TODOS_UPDATE, () => {
69
+ throw new Error("first listener exploded");
70
+ });
71
+ const b = bus.on(Events.TODOS_UPDATE, (p) => seen.push(p));
72
+
73
+ emitOn(bus, Events.TODOS_UPDATE, { source: "subagent:x", todos: [] });
74
+ expect(seen).toEqual([{ source: "subagent:x", todos: [] }]);
75
+ a();
76
+ b();
77
+ bus.clear();
78
+ await new Promise((resolve) => setImmediate(resolve));
79
+ expect(errSpy).toHaveBeenCalled();
80
+ });
81
+
82
+ it("still swallows handle-death, which is what emitOn exists for", () => {
83
+ const stale = {
84
+ emit: () => {
85
+ throw new Error("This extension ctx is stale after session replacement or reload.");
86
+ },
87
+ on: () => () => {},
88
+ };
89
+ // Unlike a subscriber throw, a dead handle DOES throw at the caller — and
90
+ // that is the only throw `emitOn` is written to absorb.
91
+ expect(() => emitOn(stale, Events.COST_UPDATE, { source: "main" })).not.toThrow();
92
+ });
93
+
94
+ it("an unsubscribe returned by the host really removes the subscriber", () => {
95
+ const bus = createEventBus();
96
+ const seen: unknown[] = [];
97
+ const unsub = bus.on(Events.TODOS_CLEAR, (p) => seen.push(p));
98
+ unsub();
99
+ emitOn(bus, Events.TODOS_CLEAR, { source: "subagent:x" });
100
+ expect(seen).toEqual([]);
101
+ bus.clear();
102
+ });
103
+
104
+ it("an emit with no subscriber is dropped, not queued for the next one", () => {
105
+ const bus = createEventBus();
106
+ emitOn(bus, Events.TODOS_UPDATE, { source: "subagent:x", todos: [] });
107
+ const seen: unknown[] = [];
108
+ const unsub = bus.on(Events.TODOS_UPDATE, (p) => seen.push(p));
109
+ expect(seen).toEqual([]);
110
+ unsub();
111
+ bus.clear();
112
+ });
113
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The channel vocabulary (`Events`) + payload types, and the emit helper.
3
+ *
4
+ * This module has NO transport of its own — it holds no listeners and touches
5
+ * no `globalThis` (that was the retired `core/bus` singleton, ADR 0004, now
6
+ * replaced by the host's `pi.events`; see ADR 0029). The tests here are the
7
+ * regression pins for that: the six channel strings must stay byte-identical
8
+ * (they are the wire names the Archimedes Client sees, and the contract
9
+ * between packages that no longer share a bus object), and importing this file
10
+ * must not install a process-wide singleton.
11
+ */
12
+ import { describe, it, expect } from "vitest";
13
+ import { Events, emitOn } from "./events.js";
14
+
15
+ describe("Events — channel names", () => {
16
+ it("are byte-identical to the retired bus constants (wire contract with the Client)", () => {
17
+ // Byte-identical on purpose: `core/bridge/events.ts` maps several of these
18
+ // verbatim onto push frames (`cost_update`, `todos_update`, `todos_clear`).
19
+ expect({ ...Events }).toEqual({
20
+ COST_UPDATE: "archimedes:cost_update",
21
+ TODOS_UPDATE: "archimedes:todos_update",
22
+ TODOS_CLEAR: "archimedes:todos_clear",
23
+ ASK_REQUEST: "archimedes:ask_request",
24
+ ASK_RESPONSE: "archimedes:ask_response",
25
+ ASK_CANCEL: "archimedes:ask_cancel",
26
+ });
27
+ });
28
+ });
29
+
30
+ describe("emitOn — the host handle is not ours to trust", () => {
31
+ it("delivers to a listener on the same handle", () => {
32
+ const seen: unknown[] = [];
33
+ const bus = fakeBus();
34
+ bus.on(Events.TODOS_UPDATE, (p) => seen.push(p));
35
+ emitOn(bus, Events.TODOS_UPDATE, { source: "main", todos: [] });
36
+ expect(seen).toEqual([{ source: "main", todos: [] }]);
37
+ });
38
+
39
+ /**
40
+ * A real one: `pi.events.emit` THROWS once the extension runtime has been
41
+ * invalidated — verified against a live pi 1.2.0 session, where a deferred
42
+ * `setTimeout` emit after the run ended crashed the process with "This
43
+ * extension ctx is stale after session replacement or reload." Our emitters
44
+ * hang off sockets, child streams and timers, so they CAN outlive the session
45
+ * that handed them the handle. The old `globalThis` bus never threw; this
46
+ * helper keeps that contract.
47
+ */
48
+ it("does not rethrow a handle that throws (stale runtime after session replacement)", () => {
49
+ const stale = {
50
+ emit: () => {
51
+ throw new Error("This extension ctx is stale after session replacement or reload.");
52
+ },
53
+ on: () => () => {},
54
+ };
55
+ expect(() => emitOn(stale, Events.COST_UPDATE, { source: "main" })).not.toThrow();
56
+ });
57
+
58
+ it("is a no-op without a handle (a call site constructed before registration)", () => {
59
+ expect(() => emitOn(undefined, Events.ASK_REQUEST, {})).not.toThrow();
60
+ });
61
+ });
62
+
63
+ /** A minimal stand-in for pi's `createEventBus()` (same emit/on contract). */
64
+ export function fakeBus() {
65
+ const listeners = new Map<string, Array<(data: unknown) => void>>();
66
+ return {
67
+ emit(channel: string, data: unknown): void {
68
+ for (const handler of [...(listeners.get(channel) ?? [])]) handler(data);
69
+ },
70
+ on(channel: string, handler: (data: unknown) => void): () => void {
71
+ const subs = listeners.get(channel) ?? [];
72
+ subs.push(handler);
73
+ listeners.set(channel, subs);
74
+ return () => {
75
+ const current = listeners.get(channel) ?? [];
76
+ const idx = current.indexOf(handler);
77
+ if (idx !== -1) current.splice(idx, 1);
78
+ };
79
+ },
80
+ /** Test-only: how many handlers are live on a channel. */
81
+ listenerCount(channel: string): number {
82
+ return (listeners.get(channel) ?? []).length;
83
+ },
84
+ };
85
+ }
package/src/events.ts ADDED
@@ -0,0 +1,107 @@
1
+ // ── Channel vocabulary ──────────────────────────────────────────────────────
2
+ //
3
+ // The names packages use to talk to each other, and the payload shapes that
4
+ // ride on them. **This module carries no transport.** It keeps no listeners,
5
+ // opens no socket, and — unlike the `Bus` singleton it replaces (ADR 0004,
6
+ // retired in favour of the host's `pi.events`, ADR 0029) — touches no
7
+ // `globalThis`. Call sites share the constants; the host owns the bus.
8
+ //
9
+ // The six channel strings are byte-identical to the retired `Events`
10
+ // constants. They are not free to change: `bridge/events.ts` maps several of
11
+ // them straight onto Archimedes Client push frames (`cost_update`,
12
+ // `todos_update`, `todos_clear`), so a rename is a wire-protocol change.
13
+ //
14
+ // Delivery is the host's promise, and it has two edges worth knowing before
15
+ // adding a channel:
16
+ //
17
+ // 1. **No queue.** An emit with no subscriber is dropped. Every emitter here
18
+ // fires from a tool call, a child socket, or a turn — all of which happen
19
+ // after `session_start`, so every subscription (made at registration or at
20
+ // `session_start`) is already live. If you add an emitter that fires during
21
+ // extension load, it needs its own ordering guard at the emitter; do not
22
+ // reintroduce a general-purpose queue.
23
+ // 2. **A handle dies with its session.** `pi.events.emit` throws once the
24
+ // extension runtime has been invalidated ("This extension ctx is stale
25
+ // after session replacement or reload."), which a deferred emit — a socket
26
+ // callback, a child stream, a timer — can easily outlive. `emitOn` below is
27
+ // the contract the old bus gave for free: emitting never throws at you.
28
+ // 3. **A subscriber's throw is the host's problem, not the emitter's.**
29
+ // `createEventBus()` wraps every handler in its own `try/catch` and prints
30
+ // `Event handler error (<channel>)` with `console.error`, so a listener
31
+ // failure never propagates back to whoever emitted — and never reaches our
32
+ // log file either. A listener with something worth keeping must report it
33
+ // through `diag()` itself. Pinned in `events-host-bus.test.ts`.
34
+
35
+ import type { EventBus } from "@earendil-works/pi-coding-agent";
36
+ import { diag, describeError } from "./log.js";
37
+
38
+ export const Events = {
39
+ COST_UPDATE: "archimedes:cost_update",
40
+ TODOS_UPDATE: "archimedes:todos_update",
41
+ TODOS_CLEAR: "archimedes:todos_clear",
42
+ ASK_REQUEST: "archimedes:ask_request",
43
+ ASK_RESPONSE: "archimedes:ask_response",
44
+ ASK_CANCEL: "archimedes:ask_cancel",
45
+ } as const;
46
+
47
+ interface CostUpdatePayload {
48
+ source: string;
49
+ inputTokens?: number;
50
+ outputTokens?: number;
51
+ cacheReadTokens?: number;
52
+ cacheWriteTokens?: number;
53
+ cost?: number;
54
+ }
55
+
56
+ interface TodoUpdatePayload {
57
+ source: string; // "main" or "subagent:<agent-name>"
58
+ todos: Array<{ content: string; description?: string; status: "pending" | "in_progress" | "completed" }>;
59
+ }
60
+
61
+ interface TodoClearPayload {
62
+ source: string;
63
+ }
64
+
65
+ interface AskRequestPayload {
66
+ source: string; // "subagent:<agent-name>"
67
+ requestId: string; // unique id to match request → response
68
+ toolCallId?: string; // the tool-call id of the process that invoked the tool (carried for Client correlation)
69
+ questions: Array<{ id: string; question: string; description?: string; options: Array<{ label: string }>; multi?: boolean; recommended?: number }>;
70
+ }
71
+
72
+ interface AskResponsePayload {
73
+ requestId: string;
74
+ cancelled: boolean;
75
+ results: Array<{ id: string; selectedOptions: string[]; customInput?: string }>;
76
+ }
77
+
78
+ interface AskCancelPayload {
79
+ requestId: string;
80
+ source: string;
81
+ }
82
+
83
+ export type { CostUpdatePayload, TodoUpdatePayload, TodoClearPayload, AskRequestPayload, AskResponsePayload, AskCancelPayload };
84
+
85
+ /**
86
+ * Emit on a host handle without letting the handle's failure become ours.
87
+ *
88
+ * `pi.events.emit` throws when the runtime behind the handle has been
89
+ * invalidated — verified against a live pi 1.2.0 session, where an emit
90
+ * deferred past the end of the run crashed the process. Every emitter in this
91
+ * suite hangs off something asynchronous (a child's socket, a child's stdout,
92
+ * a tool call that outlived its session), so the throw is reachable, and a
93
+ * dropped notification is a better outcome than a dead pi. The retired bus
94
+ * swallowed the same class of failure; `diag` keeps it visible when someone is
95
+ * looking.
96
+ *
97
+ * @param events the host handle, or `undefined` for a call site that was
98
+ * constructed before there was one to hold
99
+ */
100
+ export function emitOn(events: EventBus | undefined, channel: string, payload: unknown): void {
101
+ if (!events) return;
102
+ try {
103
+ events.emit(channel, payload);
104
+ } catch (err) {
105
+ diag("core", `[archimedes:events] emit "${channel}" failed: ${describeError(err)}`);
106
+ }
107
+ }
package/src/index.test.ts CHANGED
@@ -1,13 +1,9 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { registerCore } from "./index.js";
4
- import { initBus } from "./bus.js";
5
4
  import { registerBridge } from "./bridge/index.js";
6
5
  import { registerSelfUsage } from "./bridge/self-usage.js";
7
6
 
8
- vi.mock("./bus.js", () => ({
9
- initBus: vi.fn(),
10
- }));
11
7
  vi.mock("./bridge/index.js", () => ({
12
8
  registerBridge: vi.fn(),
13
9
  }));
@@ -20,12 +16,10 @@ describe("registerCore", () => {
20
16
  vi.clearAllMocks();
21
17
  });
22
18
 
23
- it("registers bus, bridge, and self-usage", () => {
24
- const pi = {} as ExtensionAPI;
19
+ it("registers bridge and self-usage", () => {
20
+ const pi = { events: { emit: vi.fn(), on: vi.fn() } } as unknown as ExtensionAPI;
25
21
  registerCore(pi);
26
- expect(initBus).toHaveBeenCalled();
27
22
  expect(registerBridge).toHaveBeenCalledWith(pi);
28
23
  expect(registerSelfUsage).toHaveBeenCalledWith(pi);
29
24
  });
30
25
  });
31
-
package/src/index.ts CHANGED
@@ -1,19 +1,19 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { initBus } from "./bus.js";
3
2
  import { registerBridge } from "./bridge/index.js";
4
3
  import { registerSelfUsage } from "./bridge/self-usage.js";
5
4
 
6
5
  // ── Core registration ─────────────────────────────────────────────────────
7
6
 
8
7
  export function registerCore(pi: ExtensionAPI): void {
9
- initBus();
10
8
  registerBridge(pi);
11
9
 
12
10
  // Self-usage (per-turn COST_UPDATE, source "main") — top-level like the
13
11
  // bridge (never inside a session handler, so it doesn't accumulate on
14
- // /reload). Unconditional: NOT gated on bridge mode (design decision 3 —
15
- // the TUI footer's CostAccumulator sums it; in bridge mode the existing
16
- // events.ts forwarder carries it to the desktop).
12
+ // /reload). Unconditional: NOT gated on bridge mode (design decision 3).
13
+ // Its live consumer is the events.ts forwarder, which carries it to the
14
+ // desktop as `cost_update`; the footer stopped subscribing in Task 9 (it
15
+ // reads usage from the session entries, which already include the main
16
+ // agent's — see bridge/self-usage.ts).
17
17
  registerSelfUsage(pi);
18
18
  }
19
19