@vzlentin/harness 0.2.2 → 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 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 watch it live in a viewer.
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` command and the subagents widget to Pi. Pi and
18
- the `harness` command use the same host. npm 12 runs no install script by default, so there the first `harness` command
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> # live viewer
42
+ harness attach <handle> # open it in Pi
43
43
  harness stop <handle>
44
- harness main # talk to the agent of the main project
44
+ harness main # open the agent of the main project in Pi
45
45
  ```
46
46
 
47
- [docs/spec.md](docs/spec.md) describes all the commands, the tool, the viewer, projects, the host and its state.
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
 
@@ -57,6 +60,6 @@ XDG_STATE_HOME=/tmp/harness-dev node src/cli.ts list
57
60
  `node src/cli.ts` runs the CLI of this checkout. Give it its own `XDG_STATE_HOME`, so its hosts do not share sockets and
58
61
  stores with the hosts of the installed `harness`.
59
62
 
60
- The checkout runs from its TypeScript source. `npm publish` builds `dist/` first, and the package contains `dist/`,
61
- the Pi extension in `pi/` and its `postinstall` script. `npm run check` builds `dist/` too, before the type check. The extension imports `../dist/`, so to try the checkout's
63
+ The checkout runs from its TypeScript source. The package contains `dist/`, the Pi extension in `pi/` and its
64
+ `postinstall` script. Change `version` in `package.json` and push to `main`: CI publishes it. `npm run check` builds `dist/` too, before the type check. The extension imports `../dist/`, so to try the checkout's
62
65
  extension in Pi, run `npm run build`, then `pi install ./pi`, and build again after each change in `src/`.
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: error instanceof Error ? error.message : String(error) }],
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 { addProject, checkProject, MAIN_PROJECT, projectDir, readProjects } from "./projects.js";
5
- import { AGENT_HANDLE, statePaths } from "./protocol.js";
6
- import { setupOnce } from "./setup.js";
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 the viewer of a child in this terminal. */
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 openViewer(handle);
190
+ await openInPi({ project: resolveProject(process.env, process.cwd()), handle });
187
191
  }
188
- /** Opens the viewer of the agent of the project `name`, which is `main` or a project, in this terminal. */
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
- // The viewer's connection resolves the project from the env.
193
- process.env.HARNESS_PROJECT = name;
194
- await openViewer(AGENT_HANDLE);
196
+ await openInPi({ project: name, handle: AGENT_HANDLE });
195
197
  }
196
- async function openViewer(handle) {
197
- const { ProcessTerminal } = await import("@earendil-works/pi-tui");
198
- const { attach } = await import("./viewer.js");
199
- process.exitCode = await attach(handle, { terminal: new ProcessTerminal(), env: process.env });
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(`${cause instanceof Error ? cause.message : String(cause)}\n`);
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`), after its `hello`.
30
- * When no host listens and `start` is true, it starts one, detached, with its output in `host.log`, and tries to
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`), after its `hello`.
91
- * When no host listens and `start` is true, it starts one, detached, with its output in `host.log`, and tries to
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}: ${error instanceof Error ? error.message : String(error)}; no project instructions or skills\n`);
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
- * How the viewer of `handle` on `project` shows and resumes it: the title of its child, the `--project` words, and the
13
- * words after them that open it again. The project agent is `harness <project>`, with no `--project`; a child is
14
- * `harness --project <project> attach <handle>`.
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 runs the viewer of `child`, on the project of `env` and this process's cwd. It rejects when a
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 openViewerTab(child: ChildInfo, env: NodeJS.ProcessEnv): Promise<void>;
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 this process, with the viewer of `handle` on `project` as
35
- * the resume command. Both methods do nothing outside Herdr.
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, handle: string, project: string): AgentReporter;
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 { AGENT_HANDLE, childLabel, cliPath, runDisplayName } from "./protocol.js";
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
- * How the viewer of `handle` on `project` shows and resumes it: the title of its child, the `--project` words, and the
11
- * words after them that open it again. The project agent is `harness <project>`, with no `--project`; a child is
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
- export function viewerTarget(handle, project) {
15
- // The agent's name already says what it is; a child's shows its handle too.
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
- * The words of the command that runs the viewer of `handle`, with this Node and this package's CLI, so it does not
22
- * need the CLI on PATH. Herdr wants a plain command name as the first word of a resume command, so it starts with
23
- * `env`.
24
- */
25
- export function viewerCommand(handle, project) {
26
- const cli = cliPath();
27
- const target = viewerTarget(handle, project);
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 runs the viewer of `child`, on the project of `env` and this process's cwd. It rejects when a
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 openViewerTab(child, env) {
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 viewer = viewerCommand(child.handle, project)
76
+ const command = openCommand({ project, handle: child.handle })
75
77
  .map((word) => `'${word.replaceAll("'", "'\\''")}'`)
76
78
  .join(" ");
77
- await herdr(["pane", "run", pane, viewer], env);
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 this process, with the viewer of `handle` on `project` as
81
- * the resume command. Both methods do nothing outside Herdr.
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, handle, project) {
85
- const pane = env.HERDR_ENV ? env.HERDR_PANE_ID : undefined;
86
- const resume = pane === undefined ? [] : ["--", ...viewerCommand(handle, project)];
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
@@ -1,4 +1,4 @@
1
- import { type Models } from "@earendil-works/pi-ai/models";
1
+ import type { Models } from "@earendil-works/pi-ai/models";
2
2
  export type Host = {
3
3
  /** Resolves once the host has closed, by `close()`, the idle exit, or a store failure. */
4
4
  readonly closed: Promise<void>;
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 { existsSync, mkdirSync, rmSync } from "node:fs";
12
- import { readFile } from "node:fs/promises";
3
+ import { mkdirSync, rmSync } from "node:fs";
13
4
  import { createServer } from "node:net";
14
- import { join, resolve as resolvePath, sep } from "node:path";
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 { getSupportedThinkingLevels } from "@earendil-works/pi-ai/models";
20
- import { createEventBus, DefaultPackageManager, getAgentDir, ProjectTrustStore, SettingsManager, } from "@earendil-works/pi-coding-agent";
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 { readEntries } from "./entries.js";
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: ${cause instanceof Error ? cause.message : String(cause)}\n`);
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
- const selected = [ChildContext, tool, ...entries.extensions];
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 = error instanceof Error ? error.message : String(error);
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: error instanceof Error ? error.message : String(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 viewer input, they are the caller's requests known when the wait started.
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 and each rename gives one `{ event: WatchLine }` line. It waits for a slow socket
623
- * to drain, so pi-durable replaces the frames it holds back with one frame of the whole view. The watches stop when the
624
- * socket closes, when setup fails, or when the connection ends; a wait for `drain` then stays on the closed socket and
625
- * goes with it. When the child stops, the viewer gets a `stopped` event and the connection ends; when the conversation
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 = (ops) => {
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({ ops, child }))
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((_view, ops) => send(ops));
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
- }