@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.
- package/README.md +218 -0
- package/package.json +40 -0
- package/src/background-widget.ts +107 -0
- package/src/cli-child.ts +97 -0
- package/src/describe-tool.ts +69 -0
- package/src/event-reader.ts +47 -0
- package/src/fake-extension-ui.ts +183 -0
- package/src/fake-theme.ts +72 -0
- package/src/fake-tui.ts +165 -0
- package/src/index.ts +73 -0
- package/src/pi-keybindings.ts +45 -0
- package/src/program-directories.ts +29 -0
- package/src/resolve-bun.ts +35 -0
- package/src/resolve-cli.ts +9 -0
- package/src/run-complete-renderer.ts +20 -0
- package/src/run-details.ts +294 -0
- package/src/run-foreground-view.ts +95 -0
- package/src/run-foreground.ts +154 -0
- package/src/run-picker.ts +40 -0
- package/src/run-registry.ts +107 -0
- package/src/run-settlement.ts +64 -0
- package/src/run-tool-test-support.ts +126 -0
- package/src/run-tool.ts +276 -0
- package/src/run-tree-component.ts +50 -0
- package/src/run-tree-host.ts +56 -0
- package/src/run-trees.ts +91 -0
- package/src/setup-workspace-command.ts +45 -0
- package/src/setup-workspace-tool.ts +45 -0
- package/src/setup-workspace.ts +69 -0
- package/src/spawn-run.ts +84 -0
- package/src/status-tool.ts +118 -0
- package/src/status.ts +13 -0
- package/src/stderr-buffer.ts +34 -0
- package/src/stop-tool.ts +115 -0
- package/src/system-prompt-append.ts +53 -0
- package/src/test-extension-context.ts +59 -0
- package/src/test-tui-context.ts +107 -0
- package/src/usage.ts +26 -0
- package/src/yaag-command.ts +163 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a Run's observed settlement and its failure text live, shared by
|
|
3
|
+
* the blocking, interactive, and background paths of `yaag_run`.
|
|
4
|
+
*/
|
|
5
|
+
import type { RunViewResult } from "@yaag/tui";
|
|
6
|
+
import type { RunRegistry, RunSettlement } from "./run-registry.ts";
|
|
7
|
+
import type { RunOutcome } from "./spawn-run.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The settlement the registry retained for a Run.
|
|
11
|
+
*
|
|
12
|
+
* Throws when the Run settled but the registry kept no record, because every
|
|
13
|
+
* later channel — content, details, usage — reads that record.
|
|
14
|
+
*/
|
|
15
|
+
export async function observedSettlement(
|
|
16
|
+
id: string,
|
|
17
|
+
outcome: Promise<RunOutcome>,
|
|
18
|
+
registry: RunRegistry,
|
|
19
|
+
): Promise<RunSettlement> {
|
|
20
|
+
try {
|
|
21
|
+
await outcome;
|
|
22
|
+
} catch {}
|
|
23
|
+
const found = registry.lookup(id);
|
|
24
|
+
if (found.state === "finished") return found.run.outcome;
|
|
25
|
+
throw new Error(`yaag_run: Run ${id} settled without registry retention`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The Result region's content for a settled Run, shared by the foreground view
|
|
30
|
+
* and `/yaag`'s settled background view.
|
|
31
|
+
*/
|
|
32
|
+
export function viewResult(settlement: RunSettlement): RunViewResult {
|
|
33
|
+
if (settlement.kind === "rejected")
|
|
34
|
+
return { kind: "failed", error: errorText(settlement.reason) };
|
|
35
|
+
const { outcome } = settlement;
|
|
36
|
+
return outcome.code === 0
|
|
37
|
+
? { kind: "fulfilled", result: outcome.stdout.trimEnd() }
|
|
38
|
+
: { kind: "failed", error: failure(outcome.code, ""), stderrTail: outcome.stderr };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The failure message of a Run, with the bounded tail of its own stderr. */
|
|
42
|
+
export function failure(code: number | null, stderr: string): string {
|
|
43
|
+
const tail = stderr.trimEnd();
|
|
44
|
+
const what = code === null ? "the Run was killed" : `the Run failed (exit ${code})`;
|
|
45
|
+
return tail === "" ? `yaag_run: ${what}` : `yaag_run: ${what}\n${tail}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The message of an arbitrary thrown value, for a channel that shows text.
|
|
50
|
+
*
|
|
51
|
+
* Never throws: a non-`Error` reason is stringified.
|
|
52
|
+
*/
|
|
53
|
+
export function errorText(reason: unknown): string {
|
|
54
|
+
return reason instanceof Error ? reason.message : String(reason);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* An arbitrary thrown value as an `Error`, so a rejection keeps one type.
|
|
59
|
+
*
|
|
60
|
+
* Never throws: a non-`Error` reason becomes an `Error` with its text.
|
|
61
|
+
*/
|
|
62
|
+
export function toError(reason: unknown): Error {
|
|
63
|
+
return reason instanceof Error ? reason : new Error(String(reason));
|
|
64
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { BackgroundWidget } from "./background-widget.ts";
|
|
4
|
+
import { resolveBun } from "./resolve-bun.ts";
|
|
5
|
+
import { resolveCliEntry } from "./resolve-cli.ts";
|
|
6
|
+
import type { RunDetails } from "./run-details.ts";
|
|
7
|
+
import { RunRegistry } from "./run-registry.ts";
|
|
8
|
+
import { createRunTool, type SendMessage } from "./run-tool.ts";
|
|
9
|
+
import type { RunTreeStore } from "./run-trees.ts";
|
|
10
|
+
import type { RunHandle } from "./spawn-run.ts";
|
|
11
|
+
import { createStopTool, type StopDetails } from "./stop-tool.ts";
|
|
12
|
+
import { TestExtensionContext } from "./test-extension-context.ts";
|
|
13
|
+
|
|
14
|
+
const cli = resolveCliEntry();
|
|
15
|
+
const bun = await resolveBun();
|
|
16
|
+
if (bun === null) throw new Error("these tests need bun on PATH");
|
|
17
|
+
const ctx = new TestExtensionContext(dirname(cli));
|
|
18
|
+
|
|
19
|
+
export const fixture = (name: string): string => join(dirname(cli), "fixtures", `${name}.ts`);
|
|
20
|
+
|
|
21
|
+
export interface RunParams {
|
|
22
|
+
readonly file: string;
|
|
23
|
+
readonly args?: string;
|
|
24
|
+
readonly background?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Sent {
|
|
28
|
+
readonly content: unknown;
|
|
29
|
+
readonly details: unknown;
|
|
30
|
+
readonly options: Parameters<SendMessage>[1];
|
|
31
|
+
readonly hasUsage: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function run(
|
|
35
|
+
params: RunParams,
|
|
36
|
+
opts: {
|
|
37
|
+
readonly bun?: string | null;
|
|
38
|
+
readonly signal?: AbortSignal;
|
|
39
|
+
readonly onUpdate?: (partial: AgentToolResult<RunDetails>) => void;
|
|
40
|
+
readonly registry?: RunRegistry;
|
|
41
|
+
readonly sent?: Sent[];
|
|
42
|
+
readonly start?: (options: import("./spawn-run.ts").StartRunOptions) => RunHandle;
|
|
43
|
+
/** A tui-mode context, for the interactive foreground path. */
|
|
44
|
+
readonly ctx?: ExtensionContext;
|
|
45
|
+
readonly store?: RunTreeStore;
|
|
46
|
+
readonly widget?: BackgroundWidget;
|
|
47
|
+
} = {},
|
|
48
|
+
): Promise<{
|
|
49
|
+
readonly text: string;
|
|
50
|
+
readonly details: RunDetails;
|
|
51
|
+
readonly result: AgentToolResult<RunDetails>;
|
|
52
|
+
}> {
|
|
53
|
+
const tool = createRunTool({
|
|
54
|
+
bun: opts.bun === undefined ? bun : opts.bun,
|
|
55
|
+
cli,
|
|
56
|
+
registry: opts.registry ?? new RunRegistry(),
|
|
57
|
+
...(opts.start === undefined ? {} : { start: opts.start }),
|
|
58
|
+
...(opts.store === undefined ? {} : { store: opts.store }),
|
|
59
|
+
...(opts.widget === undefined ? {} : { widget: opts.widget }),
|
|
60
|
+
sendMessage: (message, options) =>
|
|
61
|
+
void opts.sent?.push({
|
|
62
|
+
content: message.content,
|
|
63
|
+
details: message.details,
|
|
64
|
+
options,
|
|
65
|
+
hasUsage: Object.hasOwn(message, "usage"),
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
const result = await tool.execute("call-1", params, opts.signal, opts.onUpdate, opts.ctx ?? ctx);
|
|
69
|
+
const first = result.content[0];
|
|
70
|
+
return { text: first?.type === "text" ? first.text : "", details: result.details, result };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function stop(
|
|
74
|
+
registry: RunRegistry,
|
|
75
|
+
id: string,
|
|
76
|
+
): Promise<{
|
|
77
|
+
readonly text: string;
|
|
78
|
+
readonly details: StopDetails;
|
|
79
|
+
readonly result: AgentToolResult<StopDetails>;
|
|
80
|
+
}> {
|
|
81
|
+
const result = await createStopTool(registry).execute(
|
|
82
|
+
"call-2",
|
|
83
|
+
{ id },
|
|
84
|
+
undefined,
|
|
85
|
+
undefined,
|
|
86
|
+
ctx,
|
|
87
|
+
);
|
|
88
|
+
const first = result.content[0];
|
|
89
|
+
return { text: first?.type === "text" ? first.text : "", details: result.details, result };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Narrows a caught value to `Error` with a runtime check.
|
|
94
|
+
*
|
|
95
|
+
* Throws when the value is not an `Error`, so a test that asserts on a message
|
|
96
|
+
* fails at the boundary instead of hiding a non-Error rejection behind a cast.
|
|
97
|
+
*/
|
|
98
|
+
export function asError(value: unknown): Error {
|
|
99
|
+
if (value instanceof Error) return value;
|
|
100
|
+
throw new TypeError(`expected an Error, got ${typeof value}: ${String(value)}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function until(predicate: () => Promise<boolean>, ms = 10_000): Promise<boolean> {
|
|
104
|
+
const deadline = Date.now() + ms;
|
|
105
|
+
while (Date.now() < deadline) {
|
|
106
|
+
if (await predicate()) return true;
|
|
107
|
+
await Bun.sleep(50);
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function running(pattern: string): Promise<boolean> {
|
|
113
|
+
const found = Bun.spawn({ cmd: ["pgrep", "-f", pattern], stdout: "pipe", stderr: "ignore" });
|
|
114
|
+
const output = await new Response(found.stdout).text();
|
|
115
|
+
await found.exited;
|
|
116
|
+
return output.trim() !== "";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function toolDescription(): string {
|
|
120
|
+
return createRunTool({
|
|
121
|
+
bun,
|
|
122
|
+
cli,
|
|
123
|
+
registry: new RunRegistry(),
|
|
124
|
+
sendMessage: () => undefined,
|
|
125
|
+
}).description;
|
|
126
|
+
}
|
package/src/run-tool.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
5
|
+
import { initialSummary } from "@yaag/runtime";
|
|
6
|
+
import { Type } from "typebox";
|
|
7
|
+
import { type BackgroundWidget, createBackgroundWidget } from "./background-widget.ts";
|
|
8
|
+
import type { RunDetails } from "./run-details.ts";
|
|
9
|
+
import { foregroundInteractive, foregroundResult, interactiveAvailable } from "./run-foreground.ts";
|
|
10
|
+
import { RunTreeComponent } from "./run-tree-component.ts";
|
|
11
|
+
import { RunTreeStore } from "./run-trees.ts";
|
|
12
|
+
|
|
13
|
+
export type { RunDetails } from "./run-details.ts";
|
|
14
|
+
|
|
15
|
+
import type { LiveRun, RunRegistry, RunSettlement } from "./run-registry.ts";
|
|
16
|
+
import { errorText, failure, observedSettlement } from "./run-settlement.ts";
|
|
17
|
+
import { type RunHandle, type RunOutcome, type StartRunOptions, startRun } from "./spawn-run.ts";
|
|
18
|
+
import { statusReport } from "./status.ts";
|
|
19
|
+
|
|
20
|
+
const parameters = Type.Object({
|
|
21
|
+
file: Type.String({ description: "Path to the Orchestration Program file" }),
|
|
22
|
+
args: Type.Optional(
|
|
23
|
+
Type.String({ description: "The program's arguments, as a JSON object string" }),
|
|
24
|
+
),
|
|
25
|
+
background: Type.Optional(
|
|
26
|
+
Type.Boolean({ description: "Start the Run in the background and return its Run id" }),
|
|
27
|
+
),
|
|
28
|
+
record: Type.Optional(
|
|
29
|
+
Type.String({ description: "Write the Run's Cassette artifact to this path" }),
|
|
30
|
+
),
|
|
31
|
+
resume: Type.Optional(
|
|
32
|
+
Type.String({
|
|
33
|
+
description:
|
|
34
|
+
"Resume from this Cassette: matching Asks replay free, then the Run continues live",
|
|
35
|
+
}),
|
|
36
|
+
),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** pi's own message channel, narrowed to what a background Run needs. */
|
|
40
|
+
export type SendMessage = ExtensionAPI["sendMessage"];
|
|
41
|
+
|
|
42
|
+
export interface RunToolOptions {
|
|
43
|
+
readonly bun: string | null;
|
|
44
|
+
readonly cli: string;
|
|
45
|
+
readonly registry: RunRegistry;
|
|
46
|
+
readonly sendMessage: SendMessage;
|
|
47
|
+
/** Test seam for a controlled child Run handle. */
|
|
48
|
+
readonly start?: (options: StartRunOptions) => RunHandle;
|
|
49
|
+
/** Per-session renderer projections; defaulted so a test may omit it. */
|
|
50
|
+
readonly store?: RunTreeStore;
|
|
51
|
+
/** The combined inline widget; defaulted so a test may omit it. */
|
|
52
|
+
readonly widget?: BackgroundWidget;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DESCRIPTION = [
|
|
56
|
+
"Run an Orchestration Program and return its result.",
|
|
57
|
+
"",
|
|
58
|
+
"An Orchestration Program is a user-authored TypeScript file that exports a run",
|
|
59
|
+
"definition as its default export; programs live in a Program Directory — a",
|
|
60
|
+
"directory containing `.yaag/`. Use your file tools to find a program — yaag_describe describes",
|
|
61
|
+
"one known path and does not list programs.",
|
|
62
|
+
"",
|
|
63
|
+
"Use yaag_describe({ file }) to discover a program's declared arguments instead",
|
|
64
|
+
"of reading its source. `args` is passed through untouched as a JSON object string.",
|
|
65
|
+
"Programs declaring an args schema reject invalid arguments before any Agent is spawned.",
|
|
66
|
+
"The call blocks until the Run ends and returns the Run's value.",
|
|
67
|
+
"",
|
|
68
|
+
"Set `background: true` for a long Run: the call returns at once with a short",
|
|
69
|
+
"Run id (r1, r2, …), progress keeps streaming, and the result arrives as a",
|
|
70
|
+
"later message. Every Run gets an id and can be inspected with `yaag_status`;",
|
|
71
|
+
"background Runs may overlap and each is stopped with `yaag_stop({ id })`.",
|
|
72
|
+
"",
|
|
73
|
+
"Set `record` to write the Run's Cassette artifact. If a recorded Run fails,",
|
|
74
|
+
"pass its artifact as `resume` on the retry: Asks that already succeeded",
|
|
75
|
+
"replay instantly and free, and the Run goes live where it diverges. Record",
|
|
76
|
+
"the retry too (to a different path) to keep every attempt resumable.",
|
|
77
|
+
].join("\n");
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The `yaag_run` tool: runs one Orchestration Program through the Bun CLI and
|
|
81
|
+
* returns its value (ADR-0005).
|
|
82
|
+
*
|
|
83
|
+
* `execute` throws on any failure — a missing Bun, a path that is not there, a
|
|
84
|
+
* Run that failed — which is how pi marks a tool result as an error. On failure
|
|
85
|
+
* the message carries the tail of the CLI's own error output.
|
|
86
|
+
*/
|
|
87
|
+
export function createRunTool(deps: RunToolOptions): ToolDefinition<typeof parameters, RunDetails> {
|
|
88
|
+
const { bun, cli, registry, sendMessage } = deps;
|
|
89
|
+
const start = deps.start ?? startRun;
|
|
90
|
+
const store = deps.store ?? new RunTreeStore();
|
|
91
|
+
const widget = deps.widget ?? createBackgroundWidget({ registry, store });
|
|
92
|
+
return {
|
|
93
|
+
name: "yaag_run",
|
|
94
|
+
label: "Run",
|
|
95
|
+
description: DESCRIPTION,
|
|
96
|
+
parameters,
|
|
97
|
+
renderCall(params, theme, context) {
|
|
98
|
+
const text =
|
|
99
|
+
context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
100
|
+
text.setText(
|
|
101
|
+
theme.fg("toolTitle", theme.bold("yaag_run")) + theme.fg("muted", `(${params.file})`),
|
|
102
|
+
);
|
|
103
|
+
return text;
|
|
104
|
+
},
|
|
105
|
+
renderResult(result, _options, _theme, context) {
|
|
106
|
+
const details = result.details;
|
|
107
|
+
if (details === undefined) return new Text(contentText(result), 0, 0);
|
|
108
|
+
const tree =
|
|
109
|
+
context.lastComponent instanceof RunTreeComponent
|
|
110
|
+
? context.lastComponent
|
|
111
|
+
: new RunTreeComponent(details);
|
|
112
|
+
tree.update(details);
|
|
113
|
+
return tree;
|
|
114
|
+
},
|
|
115
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
116
|
+
if (bun === null) throw new Error(statusReport(null, cli));
|
|
117
|
+
|
|
118
|
+
const file = resolve(params.file);
|
|
119
|
+
try {
|
|
120
|
+
await access(file);
|
|
121
|
+
} catch {
|
|
122
|
+
throw new Error(`yaag_run: no such Orchestration Program: ${file}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const id = registry.mint();
|
|
126
|
+
const background = params.background === true;
|
|
127
|
+
const interactive = interactiveAvailable(ctx, background);
|
|
128
|
+
// Attached before start(): the Ask ledger folds from events, so a state
|
|
129
|
+
// created when a view opens would miss every early Ask.
|
|
130
|
+
const state = store.attach(id, background ? "background" : "foreground");
|
|
131
|
+
const tree = interactive ? state : undefined;
|
|
132
|
+
const run = registeredRun({
|
|
133
|
+
bun,
|
|
134
|
+
cli,
|
|
135
|
+
file,
|
|
136
|
+
args: params.args,
|
|
137
|
+
...cassetteOptions(params),
|
|
138
|
+
id,
|
|
139
|
+
registry,
|
|
140
|
+
start,
|
|
141
|
+
onUpdate,
|
|
142
|
+
store,
|
|
143
|
+
onIngest: () => view?.touch(),
|
|
144
|
+
});
|
|
145
|
+
let view: { touch(): void } | undefined;
|
|
146
|
+
|
|
147
|
+
if (background) {
|
|
148
|
+
// Attached before returning, so a Run that ends immediately still
|
|
149
|
+
// announces itself (ADR-0005).
|
|
150
|
+
void announce(id, run.outcome, registry, sendMessage, widget);
|
|
151
|
+
widget.refresh();
|
|
152
|
+
return {
|
|
153
|
+
content: [{ type: "text", text: `Run ${id} started in the background.` }],
|
|
154
|
+
details: { summary: run.summary, id },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (tree === undefined) return await foregroundResult({ run, registry, signal });
|
|
159
|
+
return await foregroundInteractive({
|
|
160
|
+
run,
|
|
161
|
+
registry,
|
|
162
|
+
signal,
|
|
163
|
+
ctx,
|
|
164
|
+
state: tree,
|
|
165
|
+
store,
|
|
166
|
+
announce: () => void announce(id, run.outcome, registry, sendMessage, widget),
|
|
167
|
+
onOpen: (opened) => {
|
|
168
|
+
view = opened;
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Cassette paths resolved like `file`; the CLI owns every rule about them —
|
|
177
|
+
* combinations, validation, refusals — and its own message surfaces on failure.
|
|
178
|
+
*/
|
|
179
|
+
function cassetteOptions(params: { readonly record?: string; readonly resume?: string }): {
|
|
180
|
+
record?: string;
|
|
181
|
+
resume?: string;
|
|
182
|
+
} {
|
|
183
|
+
return {
|
|
184
|
+
...(params.record === undefined ? {} : { record: resolve(params.record) }),
|
|
185
|
+
...(params.resume === undefined ? {} : { resume: resolve(params.resume) }),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Waits for a background Run and delivers its end as a follow-up message that
|
|
191
|
+
* triggers a turn, so the model sees the result without being asked (ADR-0005).
|
|
192
|
+
*/
|
|
193
|
+
async function announce(
|
|
194
|
+
id: string,
|
|
195
|
+
outcome: Promise<RunOutcome>,
|
|
196
|
+
registry: RunRegistry,
|
|
197
|
+
sendMessage: SendMessage,
|
|
198
|
+
widget: BackgroundWidget,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
const settlement = await observedSettlement(id, outcome, registry);
|
|
201
|
+
// The Run left `registry.live`, so its widget entry must go with it.
|
|
202
|
+
widget.refresh();
|
|
203
|
+
const content = completionContent(id, settlement);
|
|
204
|
+
const details = completionDetails(id, settlement, registry);
|
|
205
|
+
await sendMessage<RunDetails>(
|
|
206
|
+
{ customType: "yaag-run-complete", content, display: true, details },
|
|
207
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function registeredRun(options: {
|
|
212
|
+
readonly bun: string;
|
|
213
|
+
readonly cli: string;
|
|
214
|
+
readonly file: string;
|
|
215
|
+
readonly args?: string;
|
|
216
|
+
readonly record?: string;
|
|
217
|
+
readonly resume?: string;
|
|
218
|
+
readonly id: string;
|
|
219
|
+
readonly registry: RunRegistry;
|
|
220
|
+
readonly start: (options: StartRunOptions) => RunHandle;
|
|
221
|
+
readonly onUpdate?: (partial: { readonly content: []; readonly details: RunDetails }) => void;
|
|
222
|
+
/** The projection store; it ingests each occurrence exactly once. */
|
|
223
|
+
readonly store: RunTreeStore;
|
|
224
|
+
/** Redraw request for the pushed path: fd 3 fold → ingest → requestRender. */
|
|
225
|
+
readonly onIngest?: () => void;
|
|
226
|
+
}): LiveRun {
|
|
227
|
+
let run: LiveRun;
|
|
228
|
+
const handle = options.start({
|
|
229
|
+
bun: options.bun,
|
|
230
|
+
cli: options.cli,
|
|
231
|
+
file: options.file,
|
|
232
|
+
args: options.args,
|
|
233
|
+
record: options.record,
|
|
234
|
+
resume: options.resume,
|
|
235
|
+
onProgress: (summary, event, sequence) => {
|
|
236
|
+
run.summary = summary;
|
|
237
|
+
options.store.ingest(options.id, { summary, event, sequence, id: options.id });
|
|
238
|
+
options.onUpdate?.({ content: [], details: { summary, event, sequence, id: options.id } });
|
|
239
|
+
options.onIngest?.();
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
run = { id: options.id, stop: handle.stop, outcome: handle.outcome, summary: initialSummary() };
|
|
243
|
+
options.registry.add(run);
|
|
244
|
+
return run;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function completionContent(id: string, settlement: RunSettlement): string {
|
|
248
|
+
if (settlement.kind === "rejected") return `Run ${id} failed: ${errorText(settlement.reason)}`;
|
|
249
|
+
const { outcome } = settlement;
|
|
250
|
+
return outcome.code === 0
|
|
251
|
+
? `Run ${id} finished.\n${outcome.stdout.trimEnd()}`
|
|
252
|
+
: `Run ${id}: ${failure(outcome.code, outcome.stderr)}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function completionDetails(
|
|
256
|
+
id: string,
|
|
257
|
+
settlement: RunSettlement,
|
|
258
|
+
registry: RunRegistry,
|
|
259
|
+
): RunDetails {
|
|
260
|
+
const found = registry.lookup(id);
|
|
261
|
+
if (found.state !== "finished") throw new Error(`yaag_run: Run ${id} is not retained`);
|
|
262
|
+
return {
|
|
263
|
+
summary: found.run.summary,
|
|
264
|
+
id,
|
|
265
|
+
...(settlement.kind === "fulfilled" && settlement.outcome.code === 0
|
|
266
|
+
? { result: settlement.outcome.stdout.trimEnd() }
|
|
267
|
+
: {}),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function contentText(result: { readonly content: readonly { readonly type: string }[] }): string {
|
|
272
|
+
return result.content
|
|
273
|
+
.filter((content) => content.type === "text" && "text" in content)
|
|
274
|
+
.map((content) => ("text" in content ? String(content.text) : ""))
|
|
275
|
+
.join("\n");
|
|
276
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Host Session details component: the Run tree rendered wherever pi shows a
|
|
3
|
+
* `yaag_run` tool result or its details-only progress frames.
|
|
4
|
+
*
|
|
5
|
+
* It is not interactive — the rpc-mode and details-only path has no input focus
|
|
6
|
+
* (spec §4). The interactive foreground view is `run-foreground-view.ts`.
|
|
7
|
+
*/
|
|
8
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { renderTree, TreeState } from "@yaag/tui";
|
|
10
|
+
import type { RunDetails } from "./run-details.ts";
|
|
11
|
+
|
|
12
|
+
/** Injection points for the details component; all optional. */
|
|
13
|
+
export interface RunTreeComponentOptions {
|
|
14
|
+
/** The clock the tree reads for durations; defaults to `Date.now`. */
|
|
15
|
+
readonly now?: () => number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A pi component backed by `TreeState`'s bounded, sequence-keyed projection.
|
|
20
|
+
*
|
|
21
|
+
* It ingests each fd 3 occurrence exactly once, so a redraw at any terminal
|
|
22
|
+
* width can neither count an Ask twice nor show an output tail twice (spec §5).
|
|
23
|
+
*/
|
|
24
|
+
export class RunTreeComponent {
|
|
25
|
+
readonly #state: TreeState;
|
|
26
|
+
readonly #now: () => number;
|
|
27
|
+
|
|
28
|
+
constructor(details: RunDetails, options: RunTreeComponentOptions = {}) {
|
|
29
|
+
this.#state = TreeState.fromSummary(details.summary);
|
|
30
|
+
this.#now = options.now ?? Date.now;
|
|
31
|
+
this.#state.ingest(details);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Folds one tool result or details-only progress frame. */
|
|
35
|
+
update(details: RunDetails): void {
|
|
36
|
+
this.#state.ingest(details);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Renders the four-region tree, plus the Result region once the Run settled. */
|
|
40
|
+
render(width: number): string[] {
|
|
41
|
+
const id = this.#state.runId;
|
|
42
|
+
return renderTree(this.#state, {
|
|
43
|
+
now: this.#now(),
|
|
44
|
+
width,
|
|
45
|
+
...(id === undefined ? {} : { label: id }),
|
|
46
|
+
}).map((line) => truncateToWidth(line, width));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
invalidate(): void {}
|
|
50
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read-only half of `RunTreeViewHost`, adapted from a pi context.
|
|
3
|
+
*
|
|
4
|
+
* Both Run surfaces — the blocking foreground view and `/yaag` — compose it,
|
|
5
|
+
* so the property that a Peek observes and never sends is asserted in one
|
|
6
|
+
* place: nothing here can prompt an Agent. The caller adds `stop`, `detach`,
|
|
7
|
+
* and `done`.
|
|
8
|
+
*/
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import { copyToClipboard, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
12
|
+
import type { AgentInfo } from "@yaag/runtime";
|
|
13
|
+
import type { NamedKeybindings, RunTreeViewHost, TreeState } from "@yaag/tui";
|
|
14
|
+
|
|
15
|
+
/** The pi services one open view holds. */
|
|
16
|
+
export interface RunTreeHostOptions {
|
|
17
|
+
readonly ui: Pick<ExtensionUIContext, "notify" | "editor">;
|
|
18
|
+
readonly tui: TUI;
|
|
19
|
+
readonly keybindings: NamedKeybindings;
|
|
20
|
+
/** The Run's projection; the host only reads it. */
|
|
21
|
+
readonly state: TreeState;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The read-only capabilities every Run view shares. */
|
|
25
|
+
export type ReadOnlyRunTreeHost = Omit<RunTreeViewHost, "stop" | "detach" | "done">;
|
|
26
|
+
|
|
27
|
+
/** Builds the read-only host; it never writes to an Agent or to the Run. */
|
|
28
|
+
export function createRunTreeHost(options: RunTreeHostOptions): ReadOnlyRunTreeHost {
|
|
29
|
+
const { state, tui, ui } = options;
|
|
30
|
+
return {
|
|
31
|
+
keybindings: options.keybindings,
|
|
32
|
+
agentInfo: (agent) => state.summary.agents[agent],
|
|
33
|
+
readSession: (agent) => readSession(state.summary.agents[agent]),
|
|
34
|
+
sessionPath: (agent) => state.summary.agents[agent]?.sessionFile ?? null,
|
|
35
|
+
copyPath: (text) => copyToClipboard(text),
|
|
36
|
+
openEditor: async (title, body) => {
|
|
37
|
+
await ui.editor(title, body);
|
|
38
|
+
},
|
|
39
|
+
notify: (message, level) => ui.notify(message, level),
|
|
40
|
+
requestRender: () => tui.requestRender(),
|
|
41
|
+
rows: () => Math.max(1, tui.terminal.rows - 1),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Reads an Agent's session file, or null when it is missing or unreadable. */
|
|
46
|
+
async function readSession(info: AgentInfo | undefined): Promise<string | null> {
|
|
47
|
+
const path = info?.sessionFile;
|
|
48
|
+
if (path === undefined || path === null) return null;
|
|
49
|
+
try {
|
|
50
|
+
return await readFile(path, "utf8");
|
|
51
|
+
} catch {
|
|
52
|
+
// The file does not exist until the first Ask completes (ADR-0012), and a
|
|
53
|
+
// Cassette-playback Agent never had one; both degrade to the labelled stub.
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/run-trees.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-session store of renderer projections, one `TreeState` per Run.
|
|
3
|
+
*
|
|
4
|
+
* `RunRegistry` stays the domain record — ids, stop capabilities, outcomes —
|
|
5
|
+
* and this store holds the bounded renderer-only fold beside it, so the inline
|
|
6
|
+
* widget and `/yaag` read the same state the foreground view reads. A
|
|
7
|
+
* `TreeState` is bounded on every axis (node cap, Ask-ledger pruning, 2-line
|
|
8
|
+
* output tails), so keeping one per Run for the session is bounded memory.
|
|
9
|
+
*/
|
|
10
|
+
import { TreeState, type TreeUpdate } from "@yaag/tui";
|
|
11
|
+
|
|
12
|
+
/** Which surface owns a Run: the blocking tool call, or the widget. */
|
|
13
|
+
export type RunKind = "foreground" | "background";
|
|
14
|
+
|
|
15
|
+
interface Entry {
|
|
16
|
+
readonly state: TreeState;
|
|
17
|
+
kind: RunKind;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Notified with the Run id after each ingested occurrence, and on adopt. */
|
|
21
|
+
export type RunTreeListener = (id: string) => void;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The per-session projection store: one bounded `TreeState` per Run.
|
|
25
|
+
*
|
|
26
|
+
* Every method for an unknown Run id is a no-op or returns `undefined`.
|
|
27
|
+
* Ingest is exactly-once per fd 3 occurrence sequence, so a replayed
|
|
28
|
+
* occurrence cannot duplicate an Ask fact.
|
|
29
|
+
*
|
|
30
|
+
* Failure mode: `adopt()` and `ingest()` invoke subscriber callbacks, and a
|
|
31
|
+
* throwing subscriber propagates out of that call. Every other method throws
|
|
32
|
+
* nothing.
|
|
33
|
+
*/
|
|
34
|
+
export class RunTreeStore {
|
|
35
|
+
readonly #entries = new Map<string, Entry>();
|
|
36
|
+
readonly #listeners = new Set<RunTreeListener>();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Creates the projection for a Run before it starts, so no early Ask is
|
|
40
|
+
* missed. Re-attaching a known id returns the existing state untouched.
|
|
41
|
+
*/
|
|
42
|
+
attach(id: string, kind: RunKind): TreeState {
|
|
43
|
+
const found = this.#entries.get(id);
|
|
44
|
+
if (found !== undefined) return found.state;
|
|
45
|
+
const state = new TreeState();
|
|
46
|
+
this.#entries.set(id, { state, kind });
|
|
47
|
+
return state;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Converts a foreground Run to a background Run after a detach, then
|
|
52
|
+
* notifies subscribers so the combined inline widget picks the Run up at
|
|
53
|
+
* once, without waiting for a later Lifecycle Event.
|
|
54
|
+
*/
|
|
55
|
+
adopt(id: string): void {
|
|
56
|
+
const found = this.#entries.get(id);
|
|
57
|
+
if (found === undefined) return;
|
|
58
|
+
found.kind = "background";
|
|
59
|
+
this.#notify(id);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Folds one occurrence into the Run's state, then notifies subscribers. */
|
|
63
|
+
ingest(id: string, update: TreeUpdate): void {
|
|
64
|
+
const found = this.#entries.get(id);
|
|
65
|
+
if (found === undefined) return;
|
|
66
|
+
found.state.ingest(update);
|
|
67
|
+
this.#notify(id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The Run's projection, or undefined for a Run that carried none. */
|
|
71
|
+
get(id: string): TreeState | undefined {
|
|
72
|
+
return this.#entries.get(id)?.state;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Which surface owns the Run, or undefined for an unknown Run. */
|
|
76
|
+
kind(id: string): RunKind | undefined {
|
|
77
|
+
return this.#entries.get(id)?.kind;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Subscribes to every ingest; the returned function unsubscribes. */
|
|
81
|
+
subscribe(listener: RunTreeListener): () => void {
|
|
82
|
+
this.#listeners.add(listener);
|
|
83
|
+
return () => {
|
|
84
|
+
this.#listeners.delete(listener);
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#notify(id: string): void {
|
|
89
|
+
for (const listener of this.#listeners) listener(id);
|
|
90
|
+
}
|
|
91
|
+
}
|