@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
package/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# @yaag/extension
|
|
2
|
+
|
|
3
|
+
The yaag pi extension: run Orchestration Programs from a pi session.
|
|
4
|
+
|
|
5
|
+
It is loaded by pi through jiti, in **Node**, and bridges to the `yaag` CLI,
|
|
6
|
+
which runs on **Bun** (ADR-0005, ADR-0015).
|
|
7
|
+
|
|
8
|
+
## Prerequisites
|
|
9
|
+
|
|
10
|
+
**Bun is required.** The extension itself runs in pi's Node process, but every
|
|
11
|
+
Orchestration Program runs on Bun. Bun must be on `PATH`, or at `~/.bun/bin/bun`:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
curl -fsSL https://bun.sh/install | bash
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
A missing Bun is reported once, when the extension loads, naming what to
|
|
18
|
+
install — never as an `ENOENT` at the first tool call.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pi install ./packages/extension # from the monorepo root
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Pi records the local path without copying, so the `workspace:*` dependency on
|
|
27
|
+
the CLI resolves through the monorepo's `node_modules`. No publish, no bundle,
|
|
28
|
+
no version bump.
|
|
29
|
+
|
|
30
|
+
For a throwaway session instead: `pi -e ./packages/extension`.
|
|
31
|
+
|
|
32
|
+
## What it provides
|
|
33
|
+
|
|
34
|
+
### `yaag_run`
|
|
35
|
+
|
|
36
|
+
| Parameter | Type | Meaning |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `file` | `string` | Path to the Orchestration Program file |
|
|
39
|
+
| `args` | `string?` | The program's arguments, as a JSON object string |
|
|
40
|
+
| `background` | `boolean?` | Start the Run in the background and return its Run id |
|
|
41
|
+
| `record` | `string?` | Write this Run's Cassette artifact to this path |
|
|
42
|
+
| `resume` | `string?` | Replay a matching Cassette prefix, then continue live |
|
|
43
|
+
|
|
44
|
+
Blocking by default: the call returns when the Run ends, and its content is the
|
|
45
|
+
Run's return value. With `background: true` it returns at once with a short Run
|
|
46
|
+
id (`r1`, `r2`, …) and the result arrives later as a follow-up message that
|
|
47
|
+
triggers a turn. Background Runs may overlap; each has its own id and stop handle.
|
|
48
|
+
|
|
49
|
+
The model sees only the Run's value (or the id). The user additionally sees the
|
|
50
|
+
Run Summary — program, Agents, Asks settled, cost — refreshed after every
|
|
51
|
+
Lifecycle Event. Blocking completion and `yaag_stop` report token usage on pi's
|
|
52
|
+
nested-usage channel. Natural background completion is the exception: pi custom
|
|
53
|
+
follow-ups have no usage field, so their summaries remain available for rendering
|
|
54
|
+
without changing Host Session token or cost totals.
|
|
55
|
+
|
|
56
|
+
### `yaag_describe`
|
|
57
|
+
|
|
58
|
+
| Parameter | Type | Meaning |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `file` | `string` | Path to the Orchestration Program file |
|
|
61
|
+
|
|
62
|
+
Returns the program's declared `{ name, description, args }` contract verbatim.
|
|
63
|
+
Use it to discover a known program's arguments before calling `yaag_run`; it does
|
|
64
|
+
not execute the Run or enumerate programs.
|
|
65
|
+
|
|
66
|
+
### `yaag_setup_workspace`
|
|
67
|
+
|
|
68
|
+
| Parameter | Type | Meaning |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `dir` | `string?` | Workspace root; omitted uses pi's current working directory |
|
|
71
|
+
|
|
72
|
+
Use this when an Orchestration Program author sees missing `@yaag/runtime` editor/type
|
|
73
|
+
resolution in their workspace. `dir` is optional and defaults to pi's current working
|
|
74
|
+
directory. Normally run setup once for that workspace, and rerun it when an upgrade
|
|
75
|
+
needs to refresh generated declarations. Its content is the CLI's verbatim per-artifact
|
|
76
|
+
written/skipped report. Setup is not a Run: it has no Run id, summary, progress, usage,
|
|
77
|
+
cassette, or follow-up behavior.
|
|
78
|
+
|
|
79
|
+
### `yaag_stop`
|
|
80
|
+
|
|
81
|
+
| Parameter | Type | Meaning |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `id` | `string` | The Run id to stop, as returned by `yaag_run` |
|
|
84
|
+
|
|
85
|
+
Reaps the Run's Agents and reports what it got through and what it spent:
|
|
86
|
+
program, Agents seen, Asks settled, cost and tokens. Costs are reported as
|
|
87
|
+
"at least" when the Summary is incomplete.
|
|
88
|
+
|
|
89
|
+
### `/yaag-status`
|
|
90
|
+
|
|
91
|
+
Shows the resolved `bun` executable and CLI entry point.
|
|
92
|
+
|
|
93
|
+
### `/yaag-setup-workspace [dir]`
|
|
94
|
+
|
|
95
|
+
Creates or refreshes editor type support for Orchestration Programs under `.yaag/`.
|
|
96
|
+
The directory argument is optional and defaults to pi's current working directory.
|
|
97
|
+
It displays the CLI's per-artifact written/skipped report. This is the user-facing
|
|
98
|
+
counterpart of `yaag_setup_workspace`, not a Run: it has no Run id, summary,
|
|
99
|
+
progress, usage, cassette, or follow-up behavior.
|
|
100
|
+
|
|
101
|
+
## Where Orchestration Programs live
|
|
102
|
+
|
|
103
|
+
By **convention**, in a `.yaag/` directory in your project — but nothing enforces
|
|
104
|
+
that location, and `file` accepts any path. Run `yaag_setup_workspace` once to create
|
|
105
|
+
`.yaag/tsconfig.json` and refresh `.yaag/types/`. The CLI vendors its runtime and
|
|
106
|
+
TypeBox declarations there, so editors and type checkers work without installing
|
|
107
|
+
workspace dependencies; execution separately uses the runtime shipped with the CLI.
|
|
108
|
+
Setup creates no program scaffold: authored programs remain yours, separate from its
|
|
109
|
+
generated declarations. In this monorepo the convention is
|
|
110
|
+
[`examples/`](../../examples). A program is a user-authored TypeScript file whose
|
|
111
|
+
default export is `defineRun({ run })`. Use file tools to find a known program, then use
|
|
112
|
+
`yaag_describe({ file })` to discover its declared contract and arguments.
|
|
113
|
+
|
|
114
|
+
## A worked example
|
|
115
|
+
|
|
116
|
+
`.yaag/review.ts`:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { defineRun, prompt } from "@yaag/runtime";
|
|
120
|
+
|
|
121
|
+
export default defineRun({
|
|
122
|
+
name: "review",
|
|
123
|
+
description: "Review the working tree from one angle.",
|
|
124
|
+
run: async (ctx) => {
|
|
125
|
+
const agent = await ctx.spawn({
|
|
126
|
+
name: "reviewer",
|
|
127
|
+
tools: ["read", "grep"],
|
|
128
|
+
disallowedTools: ["yaag_run"],
|
|
129
|
+
skills: ["review"],
|
|
130
|
+
disallowedSkills: [],
|
|
131
|
+
});
|
|
132
|
+
return agent.ask(
|
|
133
|
+
prompt`
|
|
134
|
+
Audit this repo for issues.
|
|
135
|
+
Report the top three.
|
|
136
|
+
`,
|
|
137
|
+
{
|
|
138
|
+
maxTurns: 4,
|
|
139
|
+
maxToolCalls: 12,
|
|
140
|
+
maxDurationMs: 60_000,
|
|
141
|
+
wrapUpPrompt: "Give the findings now.",
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Program author controls
|
|
149
|
+
|
|
150
|
+
`prompt\`…\`` dedents static prompt text while preserving interpolated values.
|
|
151
|
+
`tools` / `disallowedTools` and `skills` / `disallowedSkills` are spawn-level
|
|
152
|
+
restrictions: a skill is a portable name, never a `SKILL.md` path. Tool allowlists
|
|
153
|
+
are applied before denylists; explicit empty allowlists disable flag-controllable
|
|
154
|
+
items.
|
|
155
|
+
|
|
156
|
+
`maxTurns`, `maxToolCalls`, and `maxDurationMs` are per-Ask soft limits. They
|
|
157
|
+
steer an Agent to wrap up, then abort only after grace; an `ASK_LIMIT` rejection
|
|
158
|
+
is recoverable and the Handle can be asked again. `wrapUpPrompt` replaces the
|
|
159
|
+
default steering message. This is intentionally different from `timeoutMs`, the
|
|
160
|
+
destructive fallback that rejects with `ASK_TIMEOUT` and closes the Agent.
|
|
161
|
+
|
|
162
|
+
The tool call:
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
{ "file": ".yaag/review.ts", "args": "{\"focus\":\"security\"}" }
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
For this blocking call, what comes back:
|
|
169
|
+
|
|
170
|
+
- **content** — the Run's return value, the reviewer's report (this is what the
|
|
171
|
+
model reads)
|
|
172
|
+
- **details** — the Run Summary, rendered for the user
|
|
173
|
+
- **usage** — the token breakdown: input, output, cache read, cache write, total
|
|
174
|
+
|
|
175
|
+
The background variant:
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
{ "file": ".yaag/review.ts", "background": true }
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
returns `Run r1 started in the background.` immediately; progress keeps
|
|
182
|
+
streaming into the fold, and when the Run ends a follow-up message arrives with
|
|
183
|
+
its value and final Run Summary in non-model-visible `details`. Pi's custom
|
|
184
|
+
follow-up API has no nested-usage field, so natural completion does not alter
|
|
185
|
+
Host Session token or cost totals. `yaag_stop({ "id": "r1" })` ends it early
|
|
186
|
+
and reports its nested usage through the stop tool result.
|
|
187
|
+
|
|
188
|
+
## Both entry points, one execution path
|
|
189
|
+
|
|
190
|
+
```sh
|
|
191
|
+
bun apps/yaag/src/cli.ts run .yaag/review.ts
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
and `yaag_run` are the same execution path. The extension spawns exactly this
|
|
195
|
+
CLI as a child — `bun cli.ts run <file> --events-fd 3 --args <json>` — and reads
|
|
196
|
+
structured Lifecycle Events from descriptor 3 while the CLI's own stderr format
|
|
197
|
+
stays unchanged. **What you debug in a terminal is what the session runs.**
|
|
198
|
+
|
|
199
|
+
## How stopping works
|
|
200
|
+
|
|
201
|
+
One ladder, taken by every route: pressing Esc during a blocking Run, calling
|
|
202
|
+
`yaag_stop` on a background Run, or closing the session.
|
|
203
|
+
|
|
204
|
+
1. **stdin EOF** — the extension closes the child's stdin; the CLI unwinds
|
|
205
|
+
through the runtime's own Agent reap ladder.
|
|
206
|
+
2. **SIGTERM** — if the CLI is still there.
|
|
207
|
+
3. **group SIGKILL** — only if the CLI ignored both.
|
|
208
|
+
|
|
209
|
+
It is the runtime's Agent reap ladder (ADR-0008), one process level up.
|
|
210
|
+
|
|
211
|
+
## Docs
|
|
212
|
+
|
|
213
|
+
Provided tools: `yaag_run`, `yaag_describe`, `yaag_setup_workspace`, `yaag_stop`.
|
|
214
|
+
Provided commands: `/yaag-status`, `/yaag-setup-workspace [dir]`.
|
|
215
|
+
|
|
216
|
+
- [`../../docs/architecture.md`](../../docs/architecture.md) — the diagrams
|
|
217
|
+
- [`../../docs/adr/`](../../docs/adr) — why it is built this way
|
|
218
|
+
- [ADR-0018: foreign workspaces use CLI aliases and vendored types](../../docs/adr/0018-foreign-workspaces-use-cli-aliases-and-vendored-types.md) — workspace setup and foreign-program support
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yaag/extension",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"!src/**/*.test.ts"
|
|
10
|
+
],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"pi-package"
|
|
14
|
+
],
|
|
15
|
+
"pi": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"./src/index.ts"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"test": "for file in src/*.test.ts; do bun test \"$file\" || exit 1; done",
|
|
23
|
+
"test:e2e": "bun test e2e"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@earendil-works/pi-tui": "^0.84.0",
|
|
27
|
+
"@yaag/runtime": "0.0.0",
|
|
28
|
+
"@yaag/tui": "0.0.0",
|
|
29
|
+
"@yaag/cli": "0.0.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
33
|
+
"typebox": "*"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
37
|
+
"@types/node": "^22.0.0",
|
|
38
|
+
"typebox": "1.3.7"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single combined inline widget for every live background Run (spec §1).
|
|
3
|
+
*
|
|
4
|
+
* One pi widget key holds every Run, so a Run starting or ending costs no
|
|
5
|
+
* extra scrollback. The frame is a `string[]`, which pi never routes input to,
|
|
6
|
+
* so the widget consumes no key by construction.
|
|
7
|
+
*/
|
|
8
|
+
import { renderRunsWidget, type WidgetRun } from "@yaag/tui";
|
|
9
|
+
import type { RunRegistry } from "./run-registry.ts";
|
|
10
|
+
import type { RunTreeStore } from "./run-trees.ts";
|
|
11
|
+
|
|
12
|
+
/** The one pi widget key; every Run shares it. */
|
|
13
|
+
export const WIDGET_KEY = "yaag-runs";
|
|
14
|
+
|
|
15
|
+
/** The only pi capability the widget needs. */
|
|
16
|
+
export interface WidgetSurface {
|
|
17
|
+
setWidget(key: string, lines: string[] | undefined): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What the combined widget needs: the Run registry it reads live Runs from,
|
|
22
|
+
* the projection store it reads their `TreeState` from, and the render budget.
|
|
23
|
+
*/
|
|
24
|
+
export interface BackgroundWidgetOptions {
|
|
25
|
+
readonly registry: RunRegistry;
|
|
26
|
+
readonly store: RunTreeStore;
|
|
27
|
+
readonly now?: () => number;
|
|
28
|
+
readonly width?: number;
|
|
29
|
+
readonly maxLines?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The widget controller: bind it once, then refresh it on every occurrence. */
|
|
33
|
+
export interface BackgroundWidget {
|
|
34
|
+
/**
|
|
35
|
+
* Binds the pi UI surface, once the Host Session has one, and draws the
|
|
36
|
+
* current frame. A `WidgetSurface.setWidget()` that throws propagates here.
|
|
37
|
+
*/
|
|
38
|
+
bind(surface: WidgetSurface): void;
|
|
39
|
+
/**
|
|
40
|
+
* Redraws the frame; drops the redraw before `bind`, and skips an unchanged
|
|
41
|
+
* frame. A bound `WidgetSurface.setWidget()` that throws propagates here.
|
|
42
|
+
*/
|
|
43
|
+
refresh(): void;
|
|
44
|
+
/**
|
|
45
|
+
* Unsubscribes from the store and clears the widget. A bound
|
|
46
|
+
* `WidgetSurface.setWidget()` that throws propagates here.
|
|
47
|
+
*/
|
|
48
|
+
dispose(): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Builds the combined widget over this session's Run registry and projections.
|
|
53
|
+
*
|
|
54
|
+
* An unbound controller drops every refresh, and a Run without a projection is
|
|
55
|
+
* skipped. Construction itself never throws, but `bind()`, `refresh()`, and
|
|
56
|
+
* `dispose()` call the caller's `WidgetSurface.setWidget()` and propagate
|
|
57
|
+
* whatever it throws; the store subscription also propagates it into the
|
|
58
|
+
* `ingest()` that caused the redraw.
|
|
59
|
+
*/
|
|
60
|
+
export function createBackgroundWidget(options: BackgroundWidgetOptions): BackgroundWidget {
|
|
61
|
+
const now = options.now ?? Date.now;
|
|
62
|
+
const width = options.width ?? 100;
|
|
63
|
+
let surface: WidgetSurface | undefined;
|
|
64
|
+
let drawn: readonly string[] | undefined;
|
|
65
|
+
|
|
66
|
+
const entries = (): readonly WidgetRun[] => {
|
|
67
|
+
const runs: WidgetRun[] = [];
|
|
68
|
+
for (const run of options.registry.live) {
|
|
69
|
+
if (options.store.kind(run.id) !== "background") continue;
|
|
70
|
+
const state = options.store.get(run.id);
|
|
71
|
+
if (state !== undefined) runs.push({ id: run.id, state });
|
|
72
|
+
}
|
|
73
|
+
return runs;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const refresh = (): void => {
|
|
77
|
+
if (surface === undefined) return;
|
|
78
|
+
const lines = renderRunsWidget(entries(), {
|
|
79
|
+
now: now(),
|
|
80
|
+
width,
|
|
81
|
+
...(options.maxLines === undefined ? {} : { maxLines: options.maxLines }),
|
|
82
|
+
});
|
|
83
|
+
if (drawn !== undefined && sameLines(drawn, lines)) return;
|
|
84
|
+
drawn = lines;
|
|
85
|
+
surface.setWidget(WIDGET_KEY, lines.length === 0 ? undefined : [...lines]);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const unsubscribe = options.store.subscribe(() => refresh());
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
bind(bound: WidgetSurface): void {
|
|
92
|
+
surface = bound;
|
|
93
|
+
drawn = undefined;
|
|
94
|
+
refresh();
|
|
95
|
+
},
|
|
96
|
+
refresh,
|
|
97
|
+
dispose(): void {
|
|
98
|
+
unsubscribe();
|
|
99
|
+
surface?.setWidget(WIDGET_KEY, undefined);
|
|
100
|
+
surface = undefined;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sameLines(left: readonly string[], right: readonly string[]): boolean {
|
|
106
|
+
return left.length === right.length && left.every((line, index) => line === right[index]);
|
|
107
|
+
}
|
package/src/cli-child.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { Readable, Writable } from "node:stream";
|
|
3
|
+
import { type ReapTarget, reap } from "@yaag/runtime";
|
|
4
|
+
import { StderrTail } from "./stderr-buffer.ts";
|
|
5
|
+
|
|
6
|
+
/** The process-level output of one Bun CLI child. */
|
|
7
|
+
export interface CliChildOutcome {
|
|
8
|
+
readonly code: number | null;
|
|
9
|
+
readonly stdout: string;
|
|
10
|
+
readonly stderr: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CliChildOptions {
|
|
14
|
+
readonly bun: string;
|
|
15
|
+
readonly cli: string;
|
|
16
|
+
readonly argv: readonly string[];
|
|
17
|
+
/** Run reserves fd 3 for Lifecycle Events; ordinary CLI calls do not. */
|
|
18
|
+
readonly events?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A detached Bun CLI child and the common reap ladder used to stop it. */
|
|
22
|
+
export interface CliChild {
|
|
23
|
+
readonly outcome: Promise<CliChildOutcome>;
|
|
24
|
+
readonly events: Readable | undefined;
|
|
25
|
+
stop(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Starts the resolved Bun CLI in its own process group.
|
|
30
|
+
*
|
|
31
|
+
* The returned `stop` closes stdin, then escalates through SIGTERM and group
|
|
32
|
+
* SIGKILL via `reap()`. It never interprets CLI argv, stdout, or fd 3.
|
|
33
|
+
*/
|
|
34
|
+
export function startCliChild(options: CliChildOptions): CliChild {
|
|
35
|
+
const child = spawn(options.bun, [options.cli, ...options.argv], {
|
|
36
|
+
stdio: options.events === true ? ["pipe", "pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
|
|
37
|
+
detached: true,
|
|
38
|
+
});
|
|
39
|
+
const stderr = new StderrTail();
|
|
40
|
+
const exited = observeExit(child);
|
|
41
|
+
const target: ReapTarget = {
|
|
42
|
+
exited,
|
|
43
|
+
endStdin: () => endStdin(child.stdin),
|
|
44
|
+
terminate: () => void child.kill("SIGTERM"),
|
|
45
|
+
destroyGroup: () => destroyGroup(child),
|
|
46
|
+
};
|
|
47
|
+
const outcome = Promise.all([
|
|
48
|
+
text(child.stdout),
|
|
49
|
+
drain(child.stderr, (chunk) => stderr.write(chunk)),
|
|
50
|
+
exited,
|
|
51
|
+
]).then(([stdout]) => ({ code: child.exitCode, stdout, stderr: stderr.text() }));
|
|
52
|
+
const eventStream = options.events === true ? child.stdio[3] : undefined;
|
|
53
|
+
return {
|
|
54
|
+
outcome,
|
|
55
|
+
events: isReadable(eventStream) ? eventStream : undefined,
|
|
56
|
+
stop: () => void reap(target),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function endStdin(stdin: Writable | null): void {
|
|
61
|
+
stdin?.end();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Last resort: the whole group, including processes the CLI may have spawned. */
|
|
65
|
+
function destroyGroup(child: ReturnType<typeof spawn>): void {
|
|
66
|
+
try {
|
|
67
|
+
if (child.pid !== undefined) process.kill(-child.pid, "SIGKILL");
|
|
68
|
+
else child.kill("SIGKILL");
|
|
69
|
+
} catch {
|
|
70
|
+
child.kill("SIGKILL");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isReadable(stream: unknown): stream is Readable {
|
|
75
|
+
return typeof stream === "object" && stream !== null && "on" in stream;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function text(stream: Readable | null): Promise<string> {
|
|
79
|
+
if (stream === null) return "";
|
|
80
|
+
let out = "";
|
|
81
|
+
stream.setEncoding("utf8");
|
|
82
|
+
for await (const chunk of stream) out += String(chunk);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function drain(stream: Readable | null, onChunk: (chunk: string) => void): Promise<void> {
|
|
87
|
+
if (stream === null) return;
|
|
88
|
+
stream.setEncoding("utf8");
|
|
89
|
+
for await (const chunk of stream) onChunk(String(chunk));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function observeExit(child: ReturnType<typeof spawn>): Promise<void> {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
child.on("close", () => resolve());
|
|
95
|
+
child.on("error", reject);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { startCliChild } from "./cli-child.ts";
|
|
5
|
+
import { statusReport } from "./status.ts";
|
|
6
|
+
|
|
7
|
+
const parameters = Type.Object({
|
|
8
|
+
file: Type.String({ description: "Path to the Orchestration Program file" }),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export interface DescribeToolOptions {
|
|
12
|
+
readonly bun: string | null;
|
|
13
|
+
readonly cli: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DESCRIPTION = [
|
|
17
|
+
"Return an Orchestration Program's declared name, description, and args schema.",
|
|
18
|
+
"",
|
|
19
|
+
"For program authors: use the prompt`…` helper for clean prompts. ctx.spawn restrictions",
|
|
20
|
+
"are spawn-level: tools/disallowedTools, and skills/disallowedSkills are names, not paths.",
|
|
21
|
+
"handle.ask supports maxTurns, maxToolCalls, maxDurationMs, and wrapUpPrompt as recoverable",
|
|
22
|
+
"ASK_LIMIT controls. These differ from timeoutMs, the destructive Agent-killing fallback.",
|
|
23
|
+
"",
|
|
24
|
+
"Describing imports the program and executes its module top level. Programs should keep",
|
|
25
|
+
"module top level side-effect free; use this only for reviewed, user-authored files.",
|
|
26
|
+
].join("\n");
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Creates `yaag_describe`, which returns the CLI's program contract verbatim.
|
|
30
|
+
*
|
|
31
|
+
* Throws an actionable missing-Bun, CLI failure, or cancellation error. It has
|
|
32
|
+
* no Run registry, Lifecycle Event, progress, summary, or follow-up dependency.
|
|
33
|
+
*/
|
|
34
|
+
export function createDescribeTool(
|
|
35
|
+
options: DescribeToolOptions,
|
|
36
|
+
): ToolDefinition<typeof parameters, undefined> {
|
|
37
|
+
const { bun, cli } = options;
|
|
38
|
+
return {
|
|
39
|
+
name: "yaag_describe",
|
|
40
|
+
label: "Describe Program",
|
|
41
|
+
description: DESCRIPTION,
|
|
42
|
+
parameters,
|
|
43
|
+
async execute(_id, params, signal) {
|
|
44
|
+
if (bun === null) throw new Error(statusReport(null, cli));
|
|
45
|
+
const child = startCliChild({ bun, cli, argv: ["describe", resolve(params.file)] });
|
|
46
|
+
signal?.addEventListener("abort", child.stop, { once: true });
|
|
47
|
+
if (wasAborted(signal)) child.stop();
|
|
48
|
+
try {
|
|
49
|
+
const outcome = await child.outcome;
|
|
50
|
+
if (wasAborted(signal)) throw new Error("yaag_describe: cancelled");
|
|
51
|
+
if (outcome.code !== 0) throw new Error(failure(outcome.code, outcome.stderr));
|
|
52
|
+
return { content: [{ type: "text", text: outcome.stdout }], details: undefined };
|
|
53
|
+
} finally {
|
|
54
|
+
signal?.removeEventListener("abort", child.stop);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function wasAborted(signal: AbortSignal | undefined): boolean {
|
|
61
|
+
return signal?.aborted === true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function failure(code: number | null, stderr: string): string {
|
|
65
|
+
const tail = stderr.trimEnd();
|
|
66
|
+
const what =
|
|
67
|
+
code === null ? "the description was killed" : `the description failed (exit ${code})`;
|
|
68
|
+
return tail === "" ? `yaag_describe: ${what}` : `yaag_describe: ${what}\n${tail}`;
|
|
69
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
import type { LifecycleEvent } from "@yaag/runtime";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reads Lifecycle Events from the CLI's dedicated descriptor: one JSON object
|
|
6
|
+
* per line, wrapped in the `{ v: 1, ... }` envelope (ADR-0016).
|
|
7
|
+
*
|
|
8
|
+
* Resolves when the stream ends. Never rejects on content: a line that is not
|
|
9
|
+
* JSON, or carries a wire version this host does not know, is skipped — a
|
|
10
|
+
* malformed record must not take down the Run that produced it.
|
|
11
|
+
*/
|
|
12
|
+
export async function readEvents(
|
|
13
|
+
stream: Readable,
|
|
14
|
+
onEvent: (event: LifecycleEvent) => void,
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
let pending = "";
|
|
17
|
+
stream.setEncoding("utf8");
|
|
18
|
+
for await (const chunk of stream) {
|
|
19
|
+
pending += String(chunk);
|
|
20
|
+
const lines = pending.split("\n");
|
|
21
|
+
pending = lines.pop() ?? "";
|
|
22
|
+
for (const line of lines) emit(line, onEvent);
|
|
23
|
+
}
|
|
24
|
+
emit(pending, onEvent);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function emit(line: string, onEvent: (event: LifecycleEvent) => void): void {
|
|
28
|
+
const event = parseEnvelope(line);
|
|
29
|
+
if (event !== null) onEvent(event);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseEnvelope(line: string): LifecycleEvent | null {
|
|
33
|
+
if (line.trim() === "") return null;
|
|
34
|
+
let value: unknown;
|
|
35
|
+
try {
|
|
36
|
+
value = JSON.parse(line);
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (typeof value !== "object" || value === null) return null;
|
|
41
|
+
if (!("v" in value) || value.v !== 1) return null;
|
|
42
|
+
if (!("type" in value) || typeof value.type !== "string") return null;
|
|
43
|
+
const { v: _version, ...event } = value as { v: number } & Record<string, unknown>;
|
|
44
|
+
// Narrowed as far as the wire allows: the envelope is versioned, and the
|
|
45
|
+
// Summary fold ignores any type it does not recognise.
|
|
46
|
+
return event as unknown as LifecycleEvent;
|
|
47
|
+
}
|