@vzlentin/harness 0.2.3 → 0.3.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 +10 -7
- package/dist/answer.js +2 -2
- package/dist/cli.js +29 -14
- package/dist/client.d.ts +7 -3
- package/dist/client.js +9 -5
- package/dist/context.js +2 -1
- package/dist/herdr.d.ts +11 -23
- package/dist/herdr.js +30 -28
- package/dist/host.d.ts +1 -1
- package/dist/host.js +28 -111
- package/dist/load-entries.d.ts +18 -0
- package/dist/load-entries.js +91 -0
- package/dist/mirror.d.ts +80 -0
- package/dist/mirror.js +263 -0
- package/dist/protocol.d.ts +18 -27
- package/dist/protocol.js +9 -2
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +3 -2
- package/dist/tool.js +1 -1
- package/dist/view-change.d.ts +38 -0
- package/dist/view-change.js +55 -0
- package/dist/view-state.d.ts +22 -6
- package/dist/view-state.js +45 -7
- package/package.json +2 -1
- package/pi/attachment.ts +296 -0
- package/pi/chat.ts +286 -0
- package/pi/index.ts +10 -8
- package/pi/mirror.ts +206 -0
- package/pi/model-sync.ts +102 -0
- package/pi/package.json +3 -2
- package/pi/usage-line.ts +59 -0
- package/dist/chat.d.ts +0 -48
- package/dist/chat.js +0 -208
- package/dist/chrome.d.ts +0 -18
- package/dist/chrome.js +0 -74
- package/dist/picker.d.ts +0 -22
- package/dist/picker.js +0 -70
- package/dist/theme.d.ts +0 -3
- package/dist/theme.js +0 -19
- package/dist/viewer.d.ts +0 -20
- package/dist/viewer.js +0 -307
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Observable, persistent subagents on [Pi](https://github.com/earendil-works/pi)'s libraries and
|
|
4
4
|
[pi-durable](https://www.npmjs.com/package/@earendil-works/pi-durable). Each subagent is a conversation in a durable
|
|
5
|
-
host process. You can spawn it, check its status, wait for its answer, steer it, and
|
|
5
|
+
host process. You can spawn it, check its status, wait for its answer, steer it, and talk to it live in Pi.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -14,9 +14,9 @@ npm install -g @vzlentin/harness
|
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
This works with npm 11 and npm 12. It gives you the `harness` command, and a setup runs `pi install` on the `pi/`
|
|
17
|
-
folder of the package, which adds the `subagent` tool, the `/subagent`
|
|
18
|
-
the `harness` command use the same host. npm 12 runs no install script by default, so there the first
|
|
19
|
-
does the setup. When `pi` is not on your `PATH`, or the step fails, the setup prints the command to run later:
|
|
17
|
+
folder of the package, which adds the `subagent` tool, the `/subagent` and `/harness` commands and the subagents widget
|
|
18
|
+
to Pi. Pi and the `harness` command use the same host. npm 12 runs no install script by default, so there the first
|
|
19
|
+
`harness` command does the setup. When `pi` is not on your `PATH`, or the step fails, the setup prints the command to run later:
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
22
|
pi install "$(realpath "$(npm root -g)/@vzlentin/harness/pi")"
|
|
@@ -39,12 +39,15 @@ harness spawn --name review --prompt "Review the current diff"
|
|
|
39
39
|
harness list
|
|
40
40
|
harness wait <handle>
|
|
41
41
|
harness send <handle> "Focus on error handling"
|
|
42
|
-
harness attach <handle> #
|
|
42
|
+
harness attach <handle> # open it in Pi
|
|
43
43
|
harness stop <handle>
|
|
44
|
-
harness main #
|
|
44
|
+
harness main # open the agent of the main project in Pi
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
In Pi, `/harness <project>` opens a session that shows the agent of the project in Pi's own chat, and sends what you
|
|
48
|
+
type to it. Without a project, it asks for one. `pi --harness <project>` starts Pi in such a session.
|
|
49
|
+
|
|
50
|
+
[docs/spec.md](docs/spec.md) describes all the commands, the tool, the Pi extension, projects, the host and its state.
|
|
48
51
|
|
|
49
52
|
## Development
|
|
50
53
|
|
package/dist/answer.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { copyJson } from "@earendil-works/chord";
|
|
6
6
|
import { Type, validateToolArguments } from "@earendil-works/pi-ai";
|
|
7
7
|
import { Value } from "typebox/value";
|
|
8
|
-
import { Json } from "./protocol.js";
|
|
8
|
+
import { errorMessage, Json } from "./protocol.js";
|
|
9
9
|
/**
|
|
10
10
|
* The user message that asks again for a `respond` call after an answer of a `schema` ask without one. A reminder is
|
|
11
11
|
* known by its exact text, so a user prompt with the same text counts as one.
|
|
@@ -49,7 +49,7 @@ export function createAnswers(durable) {
|
|
|
49
49
|
catch (error) {
|
|
50
50
|
return {
|
|
51
51
|
isError: true,
|
|
52
|
-
output: [{ type: "text", text:
|
|
52
|
+
output: [{ type: "text", text: errorMessage(error) }],
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
await api.commit(async (tx) => {
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { request } from "./client.js";
|
|
6
|
+
import { piEnv } from "./herdr.js";
|
|
7
|
+
import { piArgs } from "./mirror.js";
|
|
8
|
+
import { addProject, checkProject, MAIN_PROJECT, projectDir, readProjects, resolveProject } from "./projects.js";
|
|
9
|
+
import { AGENT_HANDLE, errorMessage, statePaths } from "./protocol.js";
|
|
10
|
+
import { setupOnce, userPath } from "./setup.js";
|
|
7
11
|
import { runTool, sendToHost } from "./tool.js";
|
|
8
12
|
function fail(message) {
|
|
9
13
|
throw new Error(message);
|
|
@@ -178,25 +182,36 @@ async function hostCommand(args, project) {
|
|
|
178
182
|
await host.closed;
|
|
179
183
|
process.exit(0);
|
|
180
184
|
}
|
|
181
|
-
/** Opens
|
|
185
|
+
/** Opens a child in Pi in this terminal. */
|
|
182
186
|
async function attachCommand(args) {
|
|
183
187
|
const [handle] = args;
|
|
184
188
|
if (args.length !== 1 || handle === undefined)
|
|
185
189
|
usage();
|
|
186
|
-
await
|
|
190
|
+
await openInPi({ project: resolveProject(process.env, process.cwd()), handle });
|
|
187
191
|
}
|
|
188
|
-
/** Opens the
|
|
192
|
+
/** Opens the agent of the project `name`, which is `main` or a project, in Pi in this terminal. */
|
|
189
193
|
async function agentCommand(name, project) {
|
|
190
194
|
if (project !== undefined)
|
|
191
195
|
fail(`harness ${name} takes no --project`);
|
|
192
|
-
|
|
193
|
-
process.env.HARNESS_PROJECT = name;
|
|
194
|
-
await openViewer(AGENT_HANDLE);
|
|
196
|
+
await openInPi({ project: name, handle: AGENT_HANDLE });
|
|
195
197
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Runs Pi in this terminal with a mirror session of `target` (see the Pi extension), and exits with its code. It starts
|
|
200
|
+
* the host when none runs. A first watch fails before Pi starts for an unknown child and for a project agent without a
|
|
201
|
+
* model.
|
|
202
|
+
*/
|
|
203
|
+
async function openInPi(target) {
|
|
204
|
+
await request({ type: "watch", handle: target.handle }, { start: true, project: target.project });
|
|
205
|
+
const result = spawnSync("pi", piArgs(target), {
|
|
206
|
+
stdio: "inherit",
|
|
207
|
+
env: { ...piEnv(process.env), PATH: userPath() },
|
|
208
|
+
});
|
|
209
|
+
if (result.error !== undefined && "code" in result.error && result.error.code === "ENOENT") {
|
|
210
|
+
fail("pi is not on PATH. Install Pi to open the agent");
|
|
211
|
+
}
|
|
212
|
+
if (result.error !== undefined)
|
|
213
|
+
throw result.error;
|
|
214
|
+
process.exitCode = result.status ?? 1;
|
|
200
215
|
}
|
|
201
216
|
/**
|
|
202
217
|
* Runs a command as a call of the `subagent` tool. The caller is `PI_SESSION_ID`, or `cli` outside Pi, and the defaults
|
|
@@ -246,6 +261,6 @@ async function main() {
|
|
|
246
261
|
process.stdout.write(text);
|
|
247
262
|
}
|
|
248
263
|
main().catch((cause) => {
|
|
249
|
-
process.stderr.write(`${
|
|
264
|
+
process.stderr.write(`${errorMessage(cause)}\n`);
|
|
250
265
|
process.exitCode = 1;
|
|
251
266
|
});
|
package/dist/client.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export declare class Client {
|
|
|
11
11
|
/** The project of the host. `connect` sets it. */
|
|
12
12
|
project: string;
|
|
13
13
|
constructor(socket: Socket);
|
|
14
|
+
/** Whether the connection closed. A request that failed then may have reached the host or not. */
|
|
15
|
+
get ended(): boolean;
|
|
14
16
|
request<T extends Request["type"]>(request: Extract<Request, {
|
|
15
17
|
type: T;
|
|
16
18
|
}>): Promise<Replies[T]>;
|
|
@@ -26,14 +28,16 @@ export declare class Client {
|
|
|
26
28
|
abort(cause: unknown): void;
|
|
27
29
|
}
|
|
28
30
|
/**
|
|
29
|
-
* A connection to the host of the project of `HARNESS_PROJECT` or the cwd (see `resolveProject`),
|
|
30
|
-
* When no host listens and `start` is true, it starts one, detached, with its output in `host.log`,
|
|
31
|
-
* connect for 10 s, or until `signal` aborts. When no host listens and `start` is false, it is undefined.
|
|
31
|
+
* A connection to the host of `project`, or of the project of `HARNESS_PROJECT` or the cwd (see `resolveProject`),
|
|
32
|
+
* after its `hello`. When no host listens and `start` is true, it starts one, detached, with its output in `host.log`,
|
|
33
|
+
* and tries to connect for 10 s, or until `signal` aborts. When no host listens and `start` is false, it is undefined.
|
|
32
34
|
*/
|
|
33
35
|
export declare function connect(options: ConnectOptions): Promise<Client | undefined>;
|
|
34
36
|
type ConnectOptions = {
|
|
35
37
|
readonly start: boolean;
|
|
36
38
|
readonly signal?: AbortSignal | undefined;
|
|
39
|
+
/** `main` or a project name. Without it, the project comes from `HARNESS_PROJECT` and the cwd. */
|
|
40
|
+
readonly project?: string;
|
|
37
41
|
};
|
|
38
42
|
type OneShot<T extends Request["type"]> = Extract<Request, {
|
|
39
43
|
type: T;
|
package/dist/client.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createConnection } from "node:net";
|
|
|
4
4
|
import { Readable } from "node:stream";
|
|
5
5
|
import { Type } from "typebox";
|
|
6
6
|
import { Value } from "typebox/value";
|
|
7
|
-
import { MAIN_PROJECT, resolveProject } from "./projects.js";
|
|
7
|
+
import { checkProject, MAIN_PROJECT, resolveProject } from "./projects.js";
|
|
8
8
|
import { cliPath, onLines, PROTOCOL_VERSION, statePaths, } from "./protocol.js";
|
|
9
9
|
const START_TIMEOUT_MS = 10_000;
|
|
10
10
|
/** A line from the host: a reply, or an event of the watch. The value of a reply and the event are not checked here. */
|
|
@@ -50,6 +50,10 @@ export class Client {
|
|
|
50
50
|
socket.on("error", () => this.#end());
|
|
51
51
|
socket.on("close", () => this.#end());
|
|
52
52
|
}
|
|
53
|
+
/** Whether the connection closed. A request that failed then may have reached the host or not. */
|
|
54
|
+
get ended() {
|
|
55
|
+
return this.#ended;
|
|
56
|
+
}
|
|
53
57
|
request(request) {
|
|
54
58
|
if (this.#ended)
|
|
55
59
|
return Promise.reject(new Error("The harness host closed the connection"));
|
|
@@ -87,12 +91,12 @@ export class Client {
|
|
|
87
91
|
}
|
|
88
92
|
}
|
|
89
93
|
/**
|
|
90
|
-
* A connection to the host of the project of `HARNESS_PROJECT` or the cwd (see `resolveProject`),
|
|
91
|
-
* When no host listens and `start` is true, it starts one, detached, with its output in `host.log`,
|
|
92
|
-
* connect for 10 s, or until `signal` aborts. When no host listens and `start` is false, it is undefined.
|
|
94
|
+
* A connection to the host of `project`, or of the project of `HARNESS_PROJECT` or the cwd (see `resolveProject`),
|
|
95
|
+
* after its `hello`. When no host listens and `start` is true, it starts one, detached, with its output in `host.log`,
|
|
96
|
+
* and tries to connect for 10 s, or until `signal` aborts. When no host listens and `start` is false, it is undefined.
|
|
93
97
|
*/
|
|
94
98
|
export async function connect(options) {
|
|
95
|
-
const project = resolveProject(process.env, process.cwd());
|
|
99
|
+
const project = options.project === undefined ? resolveProject(process.env, process.cwd()) : checkProject(options.project);
|
|
96
100
|
const paths = statePaths(project);
|
|
97
101
|
let socket = await tryConnect(paths.socket);
|
|
98
102
|
if (socket === undefined) {
|
package/dist/context.js
CHANGED
|
@@ -7,6 +7,7 @@ import { join } from "node:path";
|
|
|
7
7
|
import { createFindTool, createGrepTool, createLsTool, DefaultPackageManager, formatSkillsForPrompt, getAgentDir, loadProjectContextFiles, loadSkills, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { defineExtension, defineTool, section, } from "@earendil-works/pi-durable";
|
|
9
9
|
import { createBashTool, createEditTool, createReadTool, createWriteTool } from "@earendil-works/pi-durable/tools";
|
|
10
|
+
import { errorMessage } from "./protocol.js";
|
|
10
11
|
import { savedTrust } from "./trust.js";
|
|
11
12
|
/** A Pi coding agent tool as a durable tool that runs in the calling conversation's directory. */
|
|
12
13
|
function piTool(create) {
|
|
@@ -45,7 +46,7 @@ async function resourcesOf(cwd) {
|
|
|
45
46
|
return await found;
|
|
46
47
|
}
|
|
47
48
|
catch (error) {
|
|
48
|
-
process.stderr.write(`warning: ${cwd}: ${
|
|
49
|
+
process.stderr.write(`warning: ${cwd}: ${errorMessage(error)}; no project instructions or skills\n`);
|
|
49
50
|
return { context: [], skills: [] };
|
|
50
51
|
}
|
|
51
52
|
}
|
package/dist/herdr.d.ts
CHANGED
|
@@ -1,38 +1,26 @@
|
|
|
1
|
+
import { type MirrorTarget } from "./mirror.ts";
|
|
1
2
|
import { type ChildInfo } from "./protocol.ts";
|
|
2
|
-
export type ViewerTarget = {
|
|
3
|
-
title(child: ChildInfo): string;
|
|
4
|
-
project: string[];
|
|
5
|
-
resume: string[];
|
|
6
|
-
};
|
|
7
3
|
export type AgentReporter = {
|
|
8
4
|
report(busy: boolean): void;
|
|
9
5
|
release(): Promise<void>;
|
|
10
6
|
};
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
*/
|
|
16
|
-
export declare function viewerTarget(handle: string, project: string): ViewerTarget;
|
|
17
|
-
/**
|
|
18
|
-
* The words of the command that runs the viewer of `handle`, with this Node and this package's CLI, so it does not
|
|
19
|
-
* need the CLI on PATH. Herdr wants a plain command name as the first word of a resume command, so it starts with
|
|
20
|
-
* `env`.
|
|
21
|
-
*/
|
|
22
|
-
export declare function viewerCommand(handle: string, project: string): string[];
|
|
7
|
+
/** Whether this process runs in a Herdr pane, also as the Pi that the CLI starts there. */
|
|
8
|
+
export declare function inHerdr(env: NodeJS.ProcessEnv): boolean;
|
|
9
|
+
/** The environment of the Pi that the CLI starts: in a Herdr pane, `HARNESS_HERDR_PANE` in place of `HERDR_ENV`. */
|
|
10
|
+
export declare function piEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
23
11
|
/**
|
|
24
12
|
* Runs `herdr` with `args` and gives its output. When `herdr` fails, it rejects with the stderr of `herdr`, or with the
|
|
25
13
|
* error of `execFile` when stderr is empty.
|
|
26
14
|
*/
|
|
27
15
|
export declare function herdr(args: readonly string[], env: NodeJS.ProcessEnv): Promise<string>;
|
|
28
16
|
/**
|
|
29
|
-
* Opens a Herdr tab that
|
|
17
|
+
* Opens a Herdr tab that opens `child` in Pi, on the project of `env` and this process's cwd. It rejects when a
|
|
30
18
|
* `herdr` call fails or gives no pane ID.
|
|
31
19
|
*/
|
|
32
|
-
export declare function
|
|
20
|
+
export declare function openChildTab(child: ChildInfo, env: NodeJS.ProcessEnv): Promise<void>;
|
|
33
21
|
/**
|
|
34
|
-
* Reports the state of the agent `label` to the Herdr pane of
|
|
35
|
-
* the resume command. Both methods do nothing
|
|
36
|
-
* The calls run one after another, so `--seq` reaches Herdr in order.
|
|
22
|
+
* Reports the state of the agent `label` to the Herdr pane of `HARNESS_HERDR_PANE`, with the command that opens
|
|
23
|
+
* `target` as the resume command. Both methods do nothing without that variable, so only a Pi that the CLI
|
|
24
|
+
* started in a Herdr pane reports. The calls run one after another, so `--seq` reaches Herdr in order.
|
|
37
25
|
*/
|
|
38
|
-
export declare function agentReporter(env: NodeJS.ProcessEnv, label: string,
|
|
26
|
+
export declare function agentReporter(env: NodeJS.ProcessEnv, label: string, target: MirrorTarget): AgentReporter;
|
package/dist/herdr.js
CHANGED
|
@@ -1,31 +1,33 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { Value } from "typebox/value";
|
|
4
|
+
import { harnessArgs } from "./mirror.js";
|
|
4
5
|
import { resolveProject } from "./projects.js";
|
|
5
|
-
import {
|
|
6
|
+
import { childLabel, cliPath } from "./protocol.js";
|
|
6
7
|
const HERDR_SOURCE = "custom:harness";
|
|
8
|
+
/**
|
|
9
|
+
* The variable that gives the Herdr pane to the Pi that the CLI starts there, in place of `HERDR_ENV`. Without
|
|
10
|
+
* `HERDR_ENV`, Herdr's own Pi integration does not report, so the pane shows the state of the mirrored agent, which the
|
|
11
|
+
* extension reports, and not the state of Pi's own agent, which does not run.
|
|
12
|
+
*/
|
|
13
|
+
const PANE_VARIABLE = "HARNESS_HERDR_PANE";
|
|
7
14
|
/** The part of the output of `herdr tab create` that has the pane of the tab. */
|
|
8
15
|
const TabCreated = Type.Object({ result: Type.Object({ root_pane: Type.Object({ pane_id: Type.String() }) }) });
|
|
9
16
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `harness --project <project> attach <handle>`.
|
|
17
|
+
* The words of the command that opens `target` in Pi, with this Node and this package's CLI, so it does not need the
|
|
18
|
+
* CLI on PATH. Herdr wants a plain command name as the first word of a resume command, so it starts with `env`.
|
|
13
19
|
*/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (handle === AGENT_HANDLE)
|
|
17
|
-
return { title: childLabel, project: [], resume: [project] };
|
|
18
|
-
return { title: runDisplayName, project: ["--project", project], resume: ["attach", handle] };
|
|
20
|
+
function openCommand(target) {
|
|
21
|
+
return ["env", process.execPath, cliPath(), ...harnessArgs(target)];
|
|
19
22
|
}
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
*/
|
|
25
|
-
export function
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
return ["env", process.execPath, cli, ...target.project, ...target.resume];
|
|
23
|
+
/** Whether this process runs in a Herdr pane, also as the Pi that the CLI starts there. */
|
|
24
|
+
export function inHerdr(env) {
|
|
25
|
+
return Boolean(env.HERDR_ENV || env[PANE_VARIABLE]);
|
|
26
|
+
}
|
|
27
|
+
/** The environment of the Pi that the CLI starts: in a Herdr pane, `HARNESS_HERDR_PANE` in place of `HERDR_ENV`. */
|
|
28
|
+
export function piEnv(env) {
|
|
29
|
+
const { HERDR_ENV, ...rest } = env;
|
|
30
|
+
return HERDR_ENV && env.HERDR_PANE_ID ? { ...rest, [PANE_VARIABLE]: env.HERDR_PANE_ID } : env;
|
|
29
31
|
}
|
|
30
32
|
/**
|
|
31
33
|
* Runs `herdr` with `args` and gives its output. When `herdr` fails, it rejects with the stderr of `herdr`, or with the
|
|
@@ -42,10 +44,10 @@ export function herdr(args, env) {
|
|
|
42
44
|
});
|
|
43
45
|
}
|
|
44
46
|
/**
|
|
45
|
-
* Opens a Herdr tab that
|
|
47
|
+
* Opens a Herdr tab that opens `child` in Pi, on the project of `env` and this process's cwd. It rejects when a
|
|
46
48
|
* `herdr` call fails or gives no pane ID.
|
|
47
49
|
*/
|
|
48
|
-
export async function
|
|
50
|
+
export async function openChildTab(child, env) {
|
|
49
51
|
const project = resolveProject(env, process.cwd());
|
|
50
52
|
// The tab must find the same host and projects as `statePaths()` in protocol.ts and `projectsPath()` in projects.ts.
|
|
51
53
|
const state = env.XDG_STATE_HOME;
|
|
@@ -71,19 +73,19 @@ export async function openViewerTab(child, env) {
|
|
|
71
73
|
if (!Value.Check(TabCreated, output))
|
|
72
74
|
throw new Error(`herdr tab create gave no pane ID: ${created.trim()}`);
|
|
73
75
|
const pane = output.result.root_pane.pane_id;
|
|
74
|
-
const
|
|
76
|
+
const command = openCommand({ project, handle: child.handle })
|
|
75
77
|
.map((word) => `'${word.replaceAll("'", "'\\''")}'`)
|
|
76
78
|
.join(" ");
|
|
77
|
-
await herdr(["pane", "run", pane,
|
|
79
|
+
await herdr(["pane", "run", pane, command], env);
|
|
78
80
|
}
|
|
79
81
|
/**
|
|
80
|
-
* Reports the state of the agent `label` to the Herdr pane of
|
|
81
|
-
* the resume command. Both methods do nothing
|
|
82
|
-
* The calls run one after another, so `--seq` reaches Herdr in order.
|
|
82
|
+
* Reports the state of the agent `label` to the Herdr pane of `HARNESS_HERDR_PANE`, with the command that opens
|
|
83
|
+
* `target` as the resume command. Both methods do nothing without that variable, so only a Pi that the CLI
|
|
84
|
+
* started in a Herdr pane reports. The calls run one after another, so `--seq` reaches Herdr in order.
|
|
83
85
|
*/
|
|
84
|
-
export function agentReporter(env, label,
|
|
85
|
-
const pane = env
|
|
86
|
-
const resume = pane === undefined ? [] : ["--", ...
|
|
86
|
+
export function agentReporter(env, label, target) {
|
|
87
|
+
const pane = env[PANE_VARIABLE] || undefined;
|
|
88
|
+
const resume = pane === undefined ? [] : ["--", ...openCommand(target)];
|
|
87
89
|
let calls = Promise.resolve();
|
|
88
90
|
let seq = 0;
|
|
89
91
|
let reported;
|
package/dist/host.d.ts
CHANGED
package/dist/host.js
CHANGED
|
@@ -1,35 +1,27 @@
|
|
|
1
|
-
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
-
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
-
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
-
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
-
});
|
|
6
|
-
}
|
|
7
|
-
return path;
|
|
8
|
-
};
|
|
9
1
|
import { randomBytes } from "node:crypto";
|
|
10
2
|
import { once } from "node:events";
|
|
11
|
-
import {
|
|
12
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
13
4
|
import { createServer } from "node:net";
|
|
14
|
-
import { join
|
|
5
|
+
import { join } from "node:path";
|
|
15
6
|
import { DatabaseSync } from "node:sqlite";
|
|
16
|
-
import { pathToFileURL } from "node:url";
|
|
17
7
|
import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context";
|
|
18
8
|
import * as ai from "@earendil-works/pi-ai";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
9
|
+
import { CodemodeSandbox } from "@earendil-works/pi-codemode";
|
|
10
|
+
import { getAgentDir, ProjectTrustStore, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
21
11
|
import * as durable from "@earendil-works/pi-durable";
|
|
22
12
|
import { AgentDoc, createRegistry, defineDoc, Harness, } from "@earendil-works/pi-durable";
|
|
13
|
+
import { createCodemodeExtension } from "@earendil-works/pi-durable/codemode";
|
|
23
14
|
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
|
|
24
15
|
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
|
|
25
16
|
import { Type } from "typebox";
|
|
26
17
|
import { Value } from "typebox/value";
|
|
27
18
|
import { createChildren, resolveModel, } from "./children.js";
|
|
28
19
|
import { ChildContext } from "./context.js";
|
|
29
|
-
import {
|
|
30
|
-
import { AGENT_HANDLE, onLines, PROTOCOL_VERSION, RequestLine, } from "./protocol.js";
|
|
20
|
+
import { loadEntries } from "./load-entries.js";
|
|
21
|
+
import { AGENT_HANDLE, errorMessage, onLines, PROTOCOL_VERSION, RequestLine, } from "./protocol.js";
|
|
31
22
|
import { abortTask, startableTasks, startTask, taskInfo, waitTask } from "./tasks.js";
|
|
32
23
|
import { subagentTool } from "./tool.js";
|
|
24
|
+
import { viewChange } from "./view-change.js";
|
|
33
25
|
/** The host exits after this long with no connection, no live task and no request. */
|
|
34
26
|
const IDLE_EXIT_MS = 5 * 60_000;
|
|
35
27
|
const IDLE_CHECK_MS = 60_000;
|
|
@@ -74,7 +66,7 @@ export async function runHost(options) {
|
|
|
74
66
|
const settings = SettingsManager.create(stateDir, agentDir, {
|
|
75
67
|
projectTrusted: new ProjectTrustStore(agentDir).get(stateDir) === true,
|
|
76
68
|
});
|
|
77
|
-
const warn = (cause) => process.stderr.write(`warning: ${
|
|
69
|
+
const warn = (cause) => process.stderr.write(`warning: ${errorMessage(cause)}\n`);
|
|
78
70
|
// The store is there once the harness is open; entries get `place` before, and use the store in their tasks.
|
|
79
71
|
let harness;
|
|
80
72
|
const place = {
|
|
@@ -88,7 +80,7 @@ export async function runHost(options) {
|
|
|
88
80
|
return registry.snapshot();
|
|
89
81
|
},
|
|
90
82
|
};
|
|
91
|
-
const entries = await loadEntries(stateDir, agentDir, settings, place, warn).catch((cause) => {
|
|
83
|
+
const entries = await loadEntries(stateDir, agentDir, settings, place, library, warn).catch((cause) => {
|
|
92
84
|
lock.close();
|
|
93
85
|
throw cause;
|
|
94
86
|
});
|
|
@@ -107,7 +99,9 @@ export async function runHost(options) {
|
|
|
107
99
|
caller: async (api) => String(api.conversationId),
|
|
108
100
|
send: (request, signal) => handleRequest(request, { ...base, signal }),
|
|
109
101
|
});
|
|
110
|
-
|
|
102
|
+
// Each script runs in a worker thread, in a VM of at most 256 MB, as in Pi.
|
|
103
|
+
const sandbox = new CodemodeSandbox({ memoryLimitBytes: 256 * 1024 * 1024 });
|
|
104
|
+
const selected = [ChildContext, tool, createCodemodeExtension({ sandbox }), ...entries.extensions];
|
|
111
105
|
try {
|
|
112
106
|
for (const extension of selected)
|
|
113
107
|
registry.install(extension);
|
|
@@ -148,7 +142,7 @@ export async function runHost(options) {
|
|
|
148
142
|
}
|
|
149
143
|
catch (error) {
|
|
150
144
|
await harness.close(context);
|
|
151
|
-
const message =
|
|
145
|
+
const message = errorMessage(error);
|
|
152
146
|
if (!message.includes("(subagent.children) requires migration from version"))
|
|
153
147
|
throw error;
|
|
154
148
|
throw new Error(`${join(stateDir, "durable.sqlite")} was written by an older pi-subagent (${message}). Stop the host, remove the file and run the command again.`);
|
|
@@ -188,7 +182,7 @@ export async function runHost(options) {
|
|
|
188
182
|
};
|
|
189
183
|
}
|
|
190
184
|
catch (error) {
|
|
191
|
-
reply = { ok: false, error:
|
|
185
|
+
reply = { ok: false, error: errorMessage(error) };
|
|
192
186
|
}
|
|
193
187
|
if (socket.writable)
|
|
194
188
|
socket.write(`${JSON.stringify(reply)}\n`);
|
|
@@ -210,6 +204,7 @@ export async function runHost(options) {
|
|
|
210
204
|
});
|
|
211
205
|
}).catch(async (cause) => {
|
|
212
206
|
await entries.close();
|
|
207
|
+
await sandbox.close();
|
|
213
208
|
await harness.close(context);
|
|
214
209
|
lock.close();
|
|
215
210
|
throw cause;
|
|
@@ -224,8 +219,10 @@ export async function runHost(options) {
|
|
|
224
219
|
for (const abort of aborts)
|
|
225
220
|
abort.abort();
|
|
226
221
|
try {
|
|
227
|
-
// Entries close first: an entry may still need the open harness.
|
|
222
|
+
// Entries close first: an entry may still need the open harness. The sandbox closes before the harness, so a
|
|
223
|
+
// script that runs gets no outcome and continues after a restart.
|
|
228
224
|
await entries.close();
|
|
225
|
+
await sandbox.close();
|
|
229
226
|
await harness.close(context);
|
|
230
227
|
for (const env of envs.values())
|
|
231
228
|
await env.cleanup(context);
|
|
@@ -279,8 +276,6 @@ async function handleRequest(request, handler) {
|
|
|
279
276
|
return abortChild(request, handler);
|
|
280
277
|
case "configure":
|
|
281
278
|
return configureChild(request, handler);
|
|
282
|
-
case "models":
|
|
283
|
-
return modelInfos(handler.place.models);
|
|
284
279
|
case "watch":
|
|
285
280
|
if (handler.connection === undefined)
|
|
286
281
|
throw new Error("A watch needs a connection");
|
|
@@ -452,7 +447,7 @@ async function waitChild(request, handler) {
|
|
|
452
447
|
/**
|
|
453
448
|
* The requests of `sent` from `read` on that a wait for `main` gives before the answer to `main`, and the new `read`.
|
|
454
449
|
* When `main` is a request of the caller, they are the ones before it, and `main` counts as read; a send after it
|
|
455
|
-
* stays unread. Otherwise, such as for
|
|
450
|
+
* stays unread. Otherwise, such as for the input of a mirror session, they are the caller's requests known when the wait started.
|
|
456
451
|
*/
|
|
457
452
|
function unreadBefore(sent, read, main, atStart) {
|
|
458
453
|
const index = main === null ? -1 : sent.indexOf(main);
|
|
@@ -532,15 +527,6 @@ async function childTarget(handler, handle, record) {
|
|
|
532
527
|
record,
|
|
533
528
|
};
|
|
534
529
|
}
|
|
535
|
-
async function modelInfos(models) {
|
|
536
|
-
return (await models.getAvailable()).map((model) => ({
|
|
537
|
-
provider: model.provider,
|
|
538
|
-
id: model.id,
|
|
539
|
-
name: model.name,
|
|
540
|
-
contextWindow: model.contextWindow,
|
|
541
|
-
thinkingLevels: getSupportedThinkingLevels(model),
|
|
542
|
-
}));
|
|
543
|
-
}
|
|
544
530
|
async function childRecord(harness, handle) {
|
|
545
531
|
const state = await harness.snapshot(Children, context);
|
|
546
532
|
const record = recordOf(state, handle);
|
|
@@ -619,10 +605,10 @@ async function setUpAgent(root, models, settings, project, cwd) {
|
|
|
619
605
|
}
|
|
620
606
|
/**
|
|
621
607
|
* Opens a watch of `handle` on `connection` and returns the reply, with the view and the child. After the reply, each
|
|
622
|
-
* commit to the child's conversation
|
|
623
|
-
* to drain, so pi-durable replaces the frames it holds back with one frame
|
|
624
|
-
* socket closes, when setup fails, or when the connection ends; a wait for
|
|
625
|
-
* goes with it. When the child stops, the
|
|
608
|
+
* commit to the child's conversation gives one `{ event: WatchLine }` line with its change, and each rename one with
|
|
609
|
+
* an empty change. It waits for a slow socket to drain, so pi-durable replaces the frames it holds back with one frame
|
|
610
|
+
* of the whole view. The watches stop when the socket closes, when setup fails, or when the connection ends; a wait for
|
|
611
|
+
* `drain` then stays on the closed socket and goes with it. When the child stops, the client gets a `stopped` event and the connection ends; when the conversation
|
|
626
612
|
* watch ends for another reason, the connection ends.
|
|
627
613
|
*/
|
|
628
614
|
async function serveWatch(handler, handle, connection) {
|
|
@@ -668,14 +654,14 @@ async function serveWatch(handler, handle, connection) {
|
|
|
668
654
|
// pi-durable runs the callbacks of one watch in order, but not those of two watches. Both watches write through
|
|
669
655
|
// this one chain, so a line never has older info than the line before it.
|
|
670
656
|
let queue = Promise.resolve();
|
|
671
|
-
const send = (
|
|
657
|
+
const send = (change) => {
|
|
672
658
|
queue = queue.then(async () => {
|
|
673
659
|
// Some store reads per commit; cache the agent fields if streaming gets slow. A store failure drops this
|
|
674
660
|
// line. A stopped child still gives `idle` here, and its `stopped` line follows.
|
|
675
661
|
const child = await found.info().catch(() => undefined);
|
|
676
662
|
if (child === undefined || !socket.writable)
|
|
677
663
|
return;
|
|
678
|
-
if (!write({
|
|
664
|
+
if (!write({ change, child }))
|
|
679
665
|
await once(socket, "drain").catch(() => { });
|
|
680
666
|
});
|
|
681
667
|
return queue;
|
|
@@ -690,10 +676,10 @@ async function serveWatch(handler, handle, connection) {
|
|
|
690
676
|
}
|
|
691
677
|
else if (next.name !== found.record?.name) {
|
|
692
678
|
found = { ...found, record: next, info: () => childInfo(handler, handle, next) };
|
|
693
|
-
await send(
|
|
679
|
+
await send({});
|
|
694
680
|
}
|
|
695
681
|
});
|
|
696
|
-
conversation.start((
|
|
682
|
+
conversation.start((view, ops) => send(viewChange(view, ops)));
|
|
697
683
|
});
|
|
698
684
|
return { view: conversation.value, child: last };
|
|
699
685
|
}
|
|
@@ -756,72 +742,3 @@ function liveTasks(tasks) {
|
|
|
756
742
|
}
|
|
757
743
|
return tasks.length - dead.size;
|
|
758
744
|
}
|
|
759
|
-
/** A `package.json`, which is a JSON object. */
|
|
760
|
-
const PackageJson = Type.Object({});
|
|
761
|
-
/** The part of a package's `package.json` that names its entries. */
|
|
762
|
-
const Manifest = Type.Object({
|
|
763
|
-
piDurable: Type.Optional(Type.Object({ extensions: Type.Optional(Type.Array(Type.String())) })),
|
|
764
|
-
});
|
|
765
|
-
/**
|
|
766
|
-
* The `piDurable` entries of the installed packages in Pi's `settingsManager` for `cwd`, then the entries of
|
|
767
|
-
* `entries.json` (see `readEntries`), in one shared event bus, as one entry whose `close()` closes each of them and
|
|
768
|
-
* passes its errors to `warn`. When an entry fails to load, the entries loaded before it close. Missing packages are
|
|
769
|
-
* skipped, not installed, and so is an entry module that does not exist, such as one of an uninstalled package.
|
|
770
|
-
*/
|
|
771
|
-
async function loadEntries(cwd, agentDir, settingsManager, place, warn) {
|
|
772
|
-
const modules = new Set();
|
|
773
|
-
const roots = new DefaultPackageManager({ cwd, agentDir, settingsManager })
|
|
774
|
-
.listConfiguredPackages()
|
|
775
|
-
.flatMap((found) => found.installedPath ?? []);
|
|
776
|
-
for (const root of new Set(roots)) {
|
|
777
|
-
const path = join(root, "package.json");
|
|
778
|
-
const manifest = JSON.parse(await readFile(path, "utf8").catch(() => "{}"));
|
|
779
|
-
if (!Value.Check(PackageJson, manifest)) {
|
|
780
|
-
warn(new Error(`${path} is not a valid package.json`));
|
|
781
|
-
continue;
|
|
782
|
-
}
|
|
783
|
-
if (!Value.Check(Manifest, manifest)) {
|
|
784
|
-
warn(new Error(`${path} has an invalid piDurable field`));
|
|
785
|
-
continue;
|
|
786
|
-
}
|
|
787
|
-
for (const path of manifest.piDurable?.extensions ?? [])
|
|
788
|
-
modules.add(resolvePath(root, path));
|
|
789
|
-
}
|
|
790
|
-
for (const module of Object.values(readEntries()))
|
|
791
|
-
if (existsSync(module))
|
|
792
|
-
modules.add(module);
|
|
793
|
-
const events = createEventBus();
|
|
794
|
-
const entries = [];
|
|
795
|
-
const close = async () => {
|
|
796
|
-
for (const result of await Promise.allSettled(entries.map((entry) => entry.close()))) {
|
|
797
|
-
if (result.status === "rejected")
|
|
798
|
-
warn(result.reason);
|
|
799
|
-
}
|
|
800
|
-
};
|
|
801
|
-
try {
|
|
802
|
-
for (const module of modules) {
|
|
803
|
-
const entry = await importEntry(module);
|
|
804
|
-
entries.push(await entry.default({ durable, ai, events, children: library, place }));
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
catch (error) {
|
|
808
|
-
await close();
|
|
809
|
-
throw error;
|
|
810
|
-
}
|
|
811
|
-
return {
|
|
812
|
-
extensions: entries.flatMap((entry) => entry.extensions),
|
|
813
|
-
tasks: entries.flatMap((entry) => entry.tasks ?? []),
|
|
814
|
-
close,
|
|
815
|
-
};
|
|
816
|
-
}
|
|
817
|
-
/**
|
|
818
|
-
* Imports the entry `module`. Node does not strip types under `node_modules`, where Pi installs npm packages, so jiti
|
|
819
|
-
* loads a TypeScript entry there.
|
|
820
|
-
*/
|
|
821
|
-
async function importEntry(module) {
|
|
822
|
-
if (module.endsWith(".ts") && module.split(sep).includes("node_modules")) {
|
|
823
|
-
const { createJiti } = await import("jiti");
|
|
824
|
-
return createJiti(import.meta.url).import(module);
|
|
825
|
-
}
|
|
826
|
-
return import(__rewriteRelativeImportExtension(pathToFileURL(module).href));
|
|
827
|
-
}
|