@yaag/extension 0.1.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.
@@ -0,0 +1,183 @@
1
+ /**
2
+ * A fully typed `ExtensionUIContext` for the tui-mode test context.
3
+ *
4
+ * `custom`, `notify`, `editor`, `setWidget`, and a scripted `select` work;
5
+ * every other capability throws, so a dependency a view is not supposed to
6
+ * have fails loudly. The
7
+ * class implements the interface, so pi's UI surface is checked at compile
8
+ * time and no `as` cast is needed at the seam.
9
+ */
10
+ import type {
11
+ ExtensionUIContext,
12
+ KeybindingsManager,
13
+ Theme,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import type { Component, TUI } from "@earendil-works/pi-tui";
16
+ import { fakeTheme } from "./fake-theme.ts";
17
+ import { FakeTerminal, FakeTui } from "./fake-tui.ts";
18
+ import { loadPiKeybindings } from "./pi-keybindings.ts";
19
+
20
+ /** The live component `custom` opened, as a test drives it. */
21
+ export interface OpenedComponent {
22
+ render(width: number): string[];
23
+ input(data: string): void;
24
+ /** How many redraws the component requested so far. */
25
+ readonly renders: number;
26
+ }
27
+
28
+ /** A notification the view sent to the Host Session. */
29
+ export interface Notice {
30
+ readonly message: string;
31
+ readonly level: "info" | "warning" | "error";
32
+ }
33
+
34
+ /** An editor the view opened, with the body it pre-filled. */
35
+ export interface OpenedEditor {
36
+ readonly title: string;
37
+ readonly body: string;
38
+ }
39
+
40
+ /**
41
+ * The fake terminal size a test gives the view. Both fields are optional and
42
+ * default to a 20x100 terminal; no value here can make construction fail.
43
+ */
44
+ export interface FakeExtensionUiOptions {
45
+ /** Terminal rows the fake TUI reports; the view sizes overlays from it. */
46
+ readonly rows?: number;
47
+ /** Terminal columns the fake TUI reports. */
48
+ readonly columns?: number;
49
+ }
50
+
51
+ /** Runs the factory `ctx.ui.custom()` receives against fake pi services. */
52
+ export class FakeExtensionUi implements ExtensionUIContext {
53
+ readonly notices: Notice[] = [];
54
+ readonly edits: OpenedEditor[] = [];
55
+ /** The latest frame per widget key; `undefined` means the key was cleared. */
56
+ readonly widgets = new Map<string, string[] | undefined>();
57
+ /** Answers `select` in order; an empty queue answers `undefined`, i.e. esc. */
58
+ readonly selections: string[] = [];
59
+ /** The component `custom` opened, once a Run opened one. */
60
+ opened: OpenedComponent | undefined;
61
+ readonly theme: Theme = fakeTheme();
62
+
63
+ readonly #tui: FakeTui;
64
+
65
+ constructor(options: FakeExtensionUiOptions = {}) {
66
+ this.#tui = new FakeTui(new FakeTerminal(options.rows ?? 20, options.columns ?? 100));
67
+ }
68
+
69
+ custom<T>(
70
+ factory: (
71
+ tui: TUI,
72
+ theme: Theme,
73
+ keybindings: KeybindingsManager,
74
+ done: (result: T) => void,
75
+ ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
76
+ ): Promise<T> {
77
+ let resolve: (value: T) => void = () => {};
78
+ const promise = new Promise<T>((settle) => {
79
+ resolve = settle;
80
+ });
81
+ // Synchronous on purpose: a test drives the component right after the call,
82
+ // with no scheduling of its own.
83
+ const component = factory(this.#tui, this.theme, loadPiKeybindings(), resolve);
84
+ if (component instanceof Promise) {
85
+ throw new TypeError("FakeExtensionUi.custom: the factory must return a component directly");
86
+ }
87
+ const tui = this.#tui;
88
+ this.opened = {
89
+ render: (width) => component.render(width),
90
+ input: (data) => component.handleInput?.(data),
91
+ get renders() {
92
+ return tui.renders;
93
+ },
94
+ };
95
+ return promise;
96
+ }
97
+
98
+ notify(message: string, type?: "info" | "warning" | "error"): void {
99
+ this.notices.push({ message, level: type ?? "info" });
100
+ }
101
+
102
+ async editor(title: string, prefill?: string): Promise<string | undefined> {
103
+ this.edits.push({ title, body: prefill ?? "" });
104
+ return prefill;
105
+ }
106
+
107
+ async select(): Promise<string | undefined> {
108
+ return this.selections.shift();
109
+ }
110
+ async confirm(): Promise<boolean> {
111
+ return unavailable("ui.confirm");
112
+ }
113
+ async input(): Promise<string | undefined> {
114
+ return unavailable("ui.input");
115
+ }
116
+ onTerminalInput(): () => void {
117
+ return unavailable("ui.onTerminalInput");
118
+ }
119
+ setStatus(): void {
120
+ unavailable("ui.setStatus");
121
+ }
122
+ setWorkingMessage(): void {
123
+ unavailable("ui.setWorkingMessage");
124
+ }
125
+ setWorkingVisible(): void {
126
+ unavailable("ui.setWorkingVisible");
127
+ }
128
+ setWorkingIndicator(): void {
129
+ unavailable("ui.setWorkingIndicator");
130
+ }
131
+ setHiddenThinkingLabel(): void {
132
+ unavailable("ui.setHiddenThinkingLabel");
133
+ }
134
+ setWidget(key: string, content?: string[] | unknown): void {
135
+ this.widgets.set(key, Array.isArray(content) ? content.map(String) : undefined);
136
+ }
137
+ setFooter(): void {
138
+ unavailable("ui.setFooter");
139
+ }
140
+ setHeader(): void {
141
+ unavailable("ui.setHeader");
142
+ }
143
+ setTitle(): void {
144
+ unavailable("ui.setTitle");
145
+ }
146
+ pasteToEditor(): void {
147
+ unavailable("ui.pasteToEditor");
148
+ }
149
+ setEditorText(): void {
150
+ unavailable("ui.setEditorText");
151
+ }
152
+ getEditorText(): string {
153
+ return unavailable("ui.getEditorText");
154
+ }
155
+ addAutocompleteProvider(): void {
156
+ unavailable("ui.addAutocompleteProvider");
157
+ }
158
+ setEditorComponent(): void {
159
+ unavailable("ui.setEditorComponent");
160
+ }
161
+ getEditorComponent(): undefined {
162
+ return unavailable("ui.getEditorComponent");
163
+ }
164
+ getAllThemes(): { name: string; path: string | undefined }[] {
165
+ return unavailable("ui.getAllThemes");
166
+ }
167
+ getTheme(): Theme | undefined {
168
+ return unavailable("ui.getTheme");
169
+ }
170
+ setTheme(): { success: boolean; error?: string } {
171
+ return unavailable("ui.setTheme");
172
+ }
173
+ getToolsExpanded(): boolean {
174
+ return unavailable("ui.getToolsExpanded");
175
+ }
176
+ setToolsExpanded(): void {
177
+ unavailable("ui.setToolsExpanded");
178
+ }
179
+ }
180
+
181
+ function unavailable(capability: string): never {
182
+ throw new Error(`unexpected test context dependency: ${capability}`);
183
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * A `Theme` instance for tests, built from an explicit colour table.
3
+ *
4
+ * `ctx.ui.custom()` hands a real `Theme` to its factory, so the tui-mode test
5
+ * context needs one. The table is written out in full: TypeScript checks it
6
+ * against `ThemeColor`/`ThemeBg`, which keeps the double honest without a cast.
7
+ */
8
+ import { Theme } from "@earendil-works/pi-coding-agent";
9
+
10
+ /** Default 256-colour indexes; every colour renders as plain text. */
11
+ const FOREGROUND = {
12
+ accent: 7,
13
+ border: 7,
14
+ borderAccent: 7,
15
+ borderMuted: 7,
16
+ success: 7,
17
+ error: 7,
18
+ warning: 7,
19
+ muted: 7,
20
+ dim: 7,
21
+ text: 7,
22
+ thinkingText: 7,
23
+ userMessageText: 7,
24
+ customMessageText: 7,
25
+ customMessageLabel: 7,
26
+ toolTitle: 7,
27
+ toolOutput: 7,
28
+ mdHeading: 7,
29
+ mdLink: 7,
30
+ mdLinkUrl: 7,
31
+ mdCode: 7,
32
+ mdCodeBlock: 7,
33
+ mdCodeBlockBorder: 7,
34
+ mdQuote: 7,
35
+ mdQuoteBorder: 7,
36
+ mdHr: 7,
37
+ mdListBullet: 7,
38
+ toolDiffAdded: 7,
39
+ toolDiffRemoved: 7,
40
+ toolDiffContext: 7,
41
+ syntaxComment: 7,
42
+ syntaxKeyword: 7,
43
+ syntaxFunction: 7,
44
+ syntaxVariable: 7,
45
+ syntaxString: 7,
46
+ syntaxNumber: 7,
47
+ syntaxType: 7,
48
+ syntaxOperator: 7,
49
+ syntaxPunctuation: 7,
50
+ thinkingOff: 7,
51
+ thinkingMinimal: 7,
52
+ thinkingLow: 7,
53
+ thinkingMedium: 7,
54
+ thinkingHigh: 7,
55
+ thinkingXhigh: 7,
56
+ thinkingMax: 7,
57
+ bashMode: 7,
58
+ } as const satisfies Record<string, number>;
59
+
60
+ const BACKGROUND = {
61
+ selectedBg: 0,
62
+ userMessageBg: 0,
63
+ customMessageBg: 0,
64
+ toolPendingBg: 0,
65
+ toolSuccessBg: 0,
66
+ toolErrorBg: 0,
67
+ } as const satisfies Record<string, number>;
68
+
69
+ /** Builds the test theme. Colours are inert, so frames stay comparable. */
70
+ export function fakeTheme(): Theme {
71
+ return new Theme(FOREGROUND, BACKGROUND, "256color", { name: "test" });
72
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Fully typed `TUI` and `Terminal` doubles for the tui-mode test context.
3
+ *
4
+ * The foreground Run view uses three capabilities only: `terminal.rows`,
5
+ * `terminal.columns`, and `requestRender()`. Every other member of `Terminal`
6
+ * and `TUI` — fields and getters included — throws on access, so a component
7
+ * that starts to depend on the terminal fails loudly in the test instead of
8
+ * behaving differently in production. `renders` is the fake's own counter, not
9
+ * part of pi's surface.
10
+ */
11
+ import type {
12
+ Component,
13
+ OverlayHandle,
14
+ RgbColor,
15
+ Terminal,
16
+ TerminalColorScheme,
17
+ TUI,
18
+ TuiInputListener,
19
+ TuiMode,
20
+ } from "@earendil-works/pi-tui";
21
+
22
+ /** A terminal of a fixed size that refuses every write. */
23
+ export class FakeTerminal implements Terminal {
24
+ constructor(
25
+ readonly rows: number,
26
+ readonly columns: number,
27
+ ) {}
28
+
29
+ get kittyProtocolActive(): boolean {
30
+ return unavailable("terminal.kittyProtocolActive");
31
+ }
32
+
33
+ start(): void {
34
+ unavailable("terminal.start");
35
+ }
36
+ stop(): void {
37
+ unavailable("terminal.stop");
38
+ }
39
+ async drainInput(): Promise<void> {
40
+ unavailable("terminal.drainInput");
41
+ }
42
+ write(): void {
43
+ unavailable("terminal.write");
44
+ }
45
+ moveBy(): void {
46
+ unavailable("terminal.moveBy");
47
+ }
48
+ hideCursor(): void {
49
+ unavailable("terminal.hideCursor");
50
+ }
51
+ showCursor(): void {
52
+ unavailable("terminal.showCursor");
53
+ }
54
+ clearLine(): void {
55
+ unavailable("terminal.clearLine");
56
+ }
57
+ clearFromCursor(): void {
58
+ unavailable("terminal.clearFromCursor");
59
+ }
60
+ clearScreen(): void {
61
+ unavailable("terminal.clearScreen");
62
+ }
63
+ setTitle(): void {
64
+ unavailable("terminal.setTitle");
65
+ }
66
+ setProgress(): void {
67
+ unavailable("terminal.setProgress");
68
+ }
69
+ }
70
+
71
+ /** A TUI that counts redraw requests instead of drawing. */
72
+ export class FakeTui implements TUI {
73
+ /** How many times a component asked for a redraw. */
74
+ renders = 0;
75
+
76
+ constructor(readonly terminal: Terminal) {}
77
+
78
+ get mode(): TuiMode {
79
+ return unavailable("tui.mode");
80
+ }
81
+
82
+ get children(): Component[] {
83
+ return unavailable("tui.children");
84
+ }
85
+
86
+ get fullRedraws(): number {
87
+ return unavailable("tui.fullRedraws");
88
+ }
89
+
90
+ requestRender(): void {
91
+ this.renders += 1;
92
+ }
93
+
94
+ render(): string[] {
95
+ return unavailable("tui.render");
96
+ }
97
+ invalidate(): void {
98
+ unavailable("tui.invalidate");
99
+ }
100
+
101
+ addChild(): void {
102
+ unavailable("tui.addChild");
103
+ }
104
+ removeChild(): void {
105
+ unavailable("tui.removeChild");
106
+ }
107
+ clear(): void {
108
+ unavailable("tui.clear");
109
+ }
110
+ getShowHardwareCursor(): boolean {
111
+ return unavailable("tui.getShowHardwareCursor");
112
+ }
113
+ setShowHardwareCursor(): void {
114
+ unavailable("tui.setShowHardwareCursor");
115
+ }
116
+ getClearOnShrink(): boolean {
117
+ return unavailable("tui.getClearOnShrink");
118
+ }
119
+ setClearOnShrink(): void {
120
+ unavailable("tui.setClearOnShrink");
121
+ }
122
+ setFocus(): void {
123
+ unavailable("tui.setFocus");
124
+ }
125
+ showOverlay(): OverlayHandle {
126
+ return unavailable("tui.showOverlay");
127
+ }
128
+ hideOverlay(): void {
129
+ unavailable("tui.hideOverlay");
130
+ }
131
+ hasOverlay(): boolean {
132
+ return unavailable("tui.hasOverlay");
133
+ }
134
+ start(): void {
135
+ unavailable("tui.start");
136
+ }
137
+ stop(): void {
138
+ unavailable("tui.stop");
139
+ }
140
+ renderNow(): void {
141
+ unavailable("tui.renderNow");
142
+ }
143
+ addInputListener(_listener: TuiInputListener): () => void {
144
+ return unavailable("tui.addInputListener");
145
+ }
146
+ removeInputListener(): void {
147
+ unavailable("tui.removeInputListener");
148
+ }
149
+ onTerminalColorSchemeChange(): () => void {
150
+ return unavailable("tui.onTerminalColorSchemeChange");
151
+ }
152
+ setTerminalColorSchemeNotifications(): void {
153
+ unavailable("tui.setTerminalColorSchemeNotifications");
154
+ }
155
+ async queryTerminalBackgroundColor(): Promise<RgbColor | undefined> {
156
+ return unavailable("tui.queryTerminalBackgroundColor");
157
+ }
158
+ async queryTerminalColorScheme(): Promise<TerminalColorScheme | undefined> {
159
+ return unavailable("tui.queryTerminalColorScheme");
160
+ }
161
+ }
162
+
163
+ function unavailable(capability: string): never {
164
+ throw new Error(`unexpected test tui dependency: ${capability}`);
165
+ }
package/src/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { createBackgroundWidget } from "./background-widget.ts";
3
+ import { createDescribeTool } from "./describe-tool.ts";
4
+ import { findProgramDirectories } from "./program-directories.ts";
5
+ import { resolveBun } from "./resolve-bun.ts";
6
+ import { resolveCliEntry } from "./resolve-cli.ts";
7
+ import { createRunCompleteRenderer } from "./run-complete-renderer.ts";
8
+ import { RunRegistry } from "./run-registry.ts";
9
+ import { createRunTool } from "./run-tool.ts";
10
+ import { RunTreeStore } from "./run-trees.ts";
11
+ import { createSetupWorkspaceExecutor } from "./setup-workspace.ts";
12
+ import { createSetupWorkspaceCommand } from "./setup-workspace-command.ts";
13
+ import { createSetupWorkspaceTool } from "./setup-workspace-tool.ts";
14
+ import { statusReport } from "./status.ts";
15
+ import { createStatusTool } from "./status-tool.ts";
16
+ import { createStopTool } from "./stop-tool.ts";
17
+ import { createSystemPromptAppender } from "./system-prompt-append.ts";
18
+ import { createYaagCommand } from "./yaag-command.ts";
19
+
20
+ /**
21
+ * The yaag pi extension. Loaded by pi through jiti, in Node — nothing here may
22
+ * touch a Bun-only API (ADR-0005). It resolves the bridge to the Bun CLI once,
23
+ * at load, and reports what it found. Run execution, program discovery, and
24
+ * workspace setup bridge from pi's Node process to the Bun CLI.
25
+ *
26
+ * The run, describe, and workspace-setup tools are registered even when Bun is
27
+ * missing: a tool that explains how to install Bun is more use to a model than one absent.
28
+ */
29
+ export default async function (pi: ExtensionAPI): Promise<void> {
30
+ const bun = await resolveBun();
31
+ const cli = resolveCliEntry();
32
+ const report = statusReport(bun, cli);
33
+
34
+ // One registry per session retains every foreground and background Run.
35
+ const registry = new RunRegistry();
36
+ // The renderer projections and the one combined inline widget both live for
37
+ // the session, beside the registry, so a background Run keeps a tree.
38
+ const store = new RunTreeStore();
39
+ const widget = createBackgroundWidget({ registry, store });
40
+ pi.registerTool(
41
+ createRunTool({ bun, cli, registry, store, widget, sendMessage: pi.sendMessage.bind(pi) }),
42
+ );
43
+ pi.registerTool(createStatusTool(registry));
44
+ pi.registerMessageRenderer("yaag-run-complete", createRunCompleteRenderer());
45
+ pi.registerTool(createDescribeTool({ bun, cli }));
46
+ const workspaceSetup = createSetupWorkspaceExecutor({ bun, cli });
47
+ pi.registerTool(createSetupWorkspaceTool({ executor: workspaceSetup }));
48
+ pi.registerTool(createStopTool(registry));
49
+
50
+ // Host discovery: when this repo holds a Program Directory, tell the session
51
+ // where programs live and how to author one. The handler's result REPLACES
52
+ // the turn's system prompt, so it re-emits the incoming prompt first.
53
+ const appendYaagPrompt = createSystemPromptAppender(() => findProgramDirectories(process.cwd()));
54
+ pi.on("before_agent_start", (event) => appendYaagPrompt(event));
55
+
56
+ pi.on("session_start", (_event, ctx) => {
57
+ if (bun === null) ctx.ui.notify(report, "error");
58
+ if (ctx.hasUI) widget.bind(ctx.ui);
59
+ });
60
+
61
+ // Commands and tools have separate namespaces: this CLI-resolution command
62
+ // deliberately keeps its established hyphenated name beside `yaag_status`.
63
+ pi.registerCommand("yaag-status", {
64
+ description: "Show how yaag resolved Bun and the yaag CLI",
65
+ handler: async (_args, ctx) => {
66
+ ctx.ui.notify(report, bun === null ? "error" : "info");
67
+ },
68
+ });
69
+ pi.registerCommand("yaag-setup-workspace", createSetupWorkspaceCommand(workspaceSetup));
70
+
71
+ // Human-only, read-only interactive Run tree over any Run of this session.
72
+ pi.registerCommand("yaag", createYaagCommand({ registry, store }));
73
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Loads pi's own `KeybindingsManager` for tests.
3
+ *
4
+ * `ctx.ui.custom()` hands the factory a pi `KeybindingsManager`. Pi exports
5
+ * that class as a type only, and its private field makes it impossible to
6
+ * implement, so a fake UI must give the factory the real class. This module
7
+ * loads it from the installed package and narrows the import with a runtime
8
+ * check, so a pi release that moves or renames the class fails here with a
9
+ * clear message instead of at the first keystroke.
10
+ *
11
+ * The load is synchronous, so `ctx.ui.custom()` can stay synchronous in tests.
12
+ */
13
+ import { createRequire } from "node:module";
14
+ import { getPackageDir, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
15
+
16
+ const require = createRequire(import.meta.url);
17
+
18
+ /** Builds a manager over pi's default bindings. Throws when pi's shape changed. */
19
+ export function loadPiKeybindings(): KeybindingsManager {
20
+ const source = `${getPackageDir()}/dist/core/keybindings.js`;
21
+ const module: unknown = require(source);
22
+ if (typeof module !== "object" || module === null || !("KeybindingsManager" in module)) {
23
+ throw new Error(`pi keybindings: ${source} exports no KeybindingsManager`);
24
+ }
25
+ const exported = module.KeybindingsManager;
26
+ if (typeof exported !== "function") {
27
+ throw new Error(`pi keybindings: ${source} exports a non-constructor KeybindingsManager`);
28
+ }
29
+ const manager: unknown = Reflect.construct(exported, []);
30
+ if (!isKeybindingsManager(manager)) {
31
+ throw new Error("pi keybindings: the constructed manager has an unexpected shape");
32
+ }
33
+ return manager;
34
+ }
35
+
36
+ /** Every member the loaded object must have to be pi's manager. */
37
+ const MEMBERS = ["matches", "getKeys", "setUserBindings", "reload", "getEffectiveConfig"] as const;
38
+
39
+ function isKeybindingsManager(value: unknown): value is KeybindingsManager {
40
+ if (typeof value !== "object" || value === null) return false;
41
+ return MEMBERS.every((name) => {
42
+ const member: unknown = Reflect.get(value, name);
43
+ return typeof member === "function";
44
+ });
45
+ }
@@ -0,0 +1,29 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ const MAX_DEPTH = 4;
5
+ const SKIPPED = new Set(["node_modules"]);
6
+
7
+ /**
8
+ * Finds Program Directories: directories containing a `.yaag` subdirectory.
9
+ * Bounded breadth-first walk — depth <= 4, skips node_modules and dot-dirs —
10
+ * so the per-turn cost of host discovery stays negligible.
11
+ */
12
+ export async function findProgramDirectories(root: string): Promise<readonly string[]> {
13
+ const found: string[] = [];
14
+ let frontier: readonly string[] = [root];
15
+ for (let depth = 0; depth <= MAX_DEPTH && frontier.length > 0; depth += 1) {
16
+ const next: string[] = [];
17
+ for (const dir of frontier) {
18
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
19
+ for (const entry of entries) {
20
+ if (!entry.isDirectory()) continue;
21
+ if (entry.name === ".yaag") found.push(dir);
22
+ else if (!entry.name.startsWith(".") && !SKIPPED.has(entry.name))
23
+ next.push(join(dir, entry.name));
24
+ }
25
+ }
26
+ frontier = next;
27
+ }
28
+ return found.sort();
29
+ }
@@ -0,0 +1,35 @@
1
+ import { access, constants } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { delimiter, join } from "node:path";
4
+
5
+ /** Where to look for a `bun` executable. Injectable so the search is testable. */
6
+ export interface BunSearch {
7
+ /** A `PATH`-shaped list of directories. */
8
+ readonly path: string;
9
+ /** The user's home directory, holding the conventional `.bun/bin` install. */
10
+ readonly home: string;
11
+ }
12
+
13
+ /**
14
+ * Finds a `bun` executable: `PATH` first, then the conventional user install
15
+ * location (`~/.bun/bin`). Returns null when there is none — the caller decides
16
+ * how to surface that (ADR-0015).
17
+ */
18
+ export async function resolveBun(search?: BunSearch): Promise<string | null> {
19
+ const { path, home } = search ?? { path: process.env.PATH ?? "", home: homedir() };
20
+ const dirs = [...path.split(delimiter).filter(Boolean), join(home, ".bun", "bin")];
21
+ for (const dir of dirs) {
22
+ const candidate = join(dir, "bun");
23
+ if (await isExecutable(candidate)) return candidate;
24
+ }
25
+ return null;
26
+ }
27
+
28
+ async function isExecutable(path: string): Promise<boolean> {
29
+ try {
30
+ await access(path, constants.X_OK);
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
@@ -0,0 +1,9 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ /**
4
+ * Resolves the `yaag` CLI entry point through node resolution, so it works both
5
+ * in-monorepo (workspace symlink) and from a published tarball (ADR-0015).
6
+ */
7
+ export function resolveCliEntry(): string {
8
+ return createRequire(import.meta.url).resolve("yaag/src/cli.ts");
9
+ }
@@ -0,0 +1,20 @@
1
+ import type { MessageRenderer } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Text } from "@earendil-works/pi-tui";
3
+ import { parseRunDetails } from "./run-details.ts";
4
+ import { RunTreeComponent } from "./run-tree-component.ts";
5
+
6
+ /** Renders the completion Run tree when the durable message carries valid details. */
7
+ export function createRunCompleteRenderer(): MessageRenderer<unknown> {
8
+ return (message, { outputPad }, theme) => {
9
+ const details = parseRunDetails(message.details);
10
+ if (details === undefined)
11
+ return new Text(theme.fg("customMessageText", messageText(message.content)), outputPad, 0);
12
+ const box = new Box(outputPad, 0, (text) => theme.bg("customMessageBg", text));
13
+ box.addChild(new RunTreeComponent(details));
14
+ return box;
15
+ };
16
+ }
17
+
18
+ function messageText(value: string | readonly unknown[]): string {
19
+ return typeof value === "string" ? value : value.map((part) => JSON.stringify(part)).join("\n");
20
+ }