@vzlentin/harness 0.2.3 → 0.3.1

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
 
@@ -58,5 +61,5 @@ XDG_STATE_HOME=/tmp/harness-dev node src/cli.ts list
58
61
  stores with the hosts of the installed `harness`.
59
62
 
60
63
  The checkout runs from its TypeScript source. The package contains `dist/`, the Pi extension in `pi/` and its
61
- `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
64
+ `postinstall` script. To release, merge the release PR `release: <version>` that CI keeps open: CI then publishes that version. `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) => {
@@ -183,6 +183,11 @@ export type Children = {
183
183
  } | {
184
184
  readonly id: string;
185
185
  }, context: Context): Promise<Child | undefined>;
186
+ /**
187
+ * The lineage, conversation and state of the child with the conversation ID `id`, of any owner, without its owner
188
+ * operations. Nothing for a root, an unknown ID, or text that is not an ID.
189
+ */
190
+ inspect(place: Place, id: string, context: Context): Promise<Pick<Child, "lineage" | "conversation" | "state"> | undefined>;
186
191
  /** Where the conversation `id` sits in its tree; nothing for a root, or another conversation the library did not open. */
187
192
  lineage(store: Pick<Store, "snapshot">, id: string, context: Context): Promise<Lineage | undefined>;
188
193
  /**
package/dist/children.js CHANGED
@@ -245,7 +245,8 @@ export function createChildren(durable) {
245
245
  },
246
246
  };
247
247
  }
248
- const find = async (place, owner, which, context) => {
248
+ /** The child of a key or a conversation ID, of any owner, with its document. */
249
+ async function load(place, which, context) {
249
250
  const id = "key" in which
250
251
  ? (await place.store.snapshot(Keys, which.key, context))?.conversation
251
252
  : conversationIdOf(which.id);
@@ -255,15 +256,24 @@ export function createChildren(durable) {
255
256
  const conversation = await place.store.conversation(id, context);
256
257
  if (doc === undefined || conversation === undefined)
257
258
  return undefined;
258
- if (!sameOwner(doc.owner, ownerState(owner)))
259
- throw new Error(`The child ${id} belongs to another owner`);
260
- return makeChild(place, id, lineageOf(doc), conversation);
259
+ return { child: makeChild(place, id, lineageOf(doc), conversation), owner: doc.owner };
260
+ }
261
+ const find = async (place, owner, which, context) => {
262
+ const loaded = await load(place, which, context);
263
+ if (loaded === undefined)
264
+ return undefined;
265
+ if (!sameOwner(loaded.owner, ownerState(owner))) {
266
+ throw new Error(`The child ${loaded.child.id} belongs to another owner`);
267
+ }
268
+ return loaded.child;
261
269
  };
270
+ const inspect = async (place, id, context) => (await load(place, { id }, context))?.child;
262
271
  return {
263
272
  extension,
264
273
  answerExtension: answers.extension,
265
274
  lineage,
266
275
  find,
276
+ inspect,
267
277
  turn,
268
278
  async open(place, owner, key, spec, context, setup) {
269
279
  const existing = await find(place, owner, { key }, context);
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);
@@ -20,6 +24,7 @@ function usage() {
20
24
  harness stop <handle>
21
25
  harness list
22
26
  harness attach <handle>
27
+ harness attach --conversation <id>
23
28
  harness create <name> --cwd <dir>
24
29
  harness projects
25
30
  Any command but harness <project> takes --project <name> before it; without it, the project is HARNESS_PROJECT, then the cwd.`);
@@ -178,25 +183,42 @@ async function hostCommand(args, project) {
178
183
  await host.closed;
179
184
  process.exit(0);
180
185
  }
181
- /** Opens the viewer of a child in this terminal. */
186
+ /** Opens a child, or a conversation of the children library with `--conversation <id>`, in Pi in this terminal. */
182
187
  async function attachCommand(args) {
183
- const [handle] = args;
184
- if (args.length !== 1 || handle === undefined)
188
+ const [first, id] = args;
189
+ let ref;
190
+ if (args.length === 1 && first !== undefined && first !== "--conversation")
191
+ ref = { handle: first };
192
+ else if (args.length === 2 && first === "--conversation" && id)
193
+ ref = { conversation: id };
194
+ else
185
195
  usage();
186
- await openViewer(handle);
196
+ await openInPi({ project: resolveProject(process.env, process.cwd()), ...ref });
187
197
  }
188
- /** Opens the viewer of the agent of the project `name`, which is `main` or a project, in this terminal. */
198
+ /** Opens the agent of the project `name`, which is `main` or a project, in Pi in this terminal. */
189
199
  async function agentCommand(name, project) {
190
200
  if (project !== undefined)
191
201
  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);
202
+ await openInPi({ project: name, handle: AGENT_HANDLE });
195
203
  }
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 });
204
+ /**
205
+ * Runs Pi in this terminal with a mirror session of `target` (see the Pi extension), and exits with its code. It starts
206
+ * the host when none runs. A first watch fails before Pi starts for an unknown child, an unknown conversation and a
207
+ * project agent without a model.
208
+ */
209
+ async function openInPi(target) {
210
+ const ref = target.conversation === undefined ? { handle: target.handle } : { conversation: target.conversation };
211
+ await request({ type: "watch", ...ref }, { start: true, project: target.project });
212
+ const result = spawnSync("pi", piArgs(target), {
213
+ stdio: "inherit",
214
+ env: { ...piEnv(process.env), PATH: userPath() },
215
+ });
216
+ if (result.error !== undefined && "code" in result.error && result.error.code === "ENOENT") {
217
+ fail("pi is not on PATH. Install Pi to open the agent");
218
+ }
219
+ if (result.error !== undefined)
220
+ throw result.error;
221
+ process.exitCode = result.status ?? 1;
200
222
  }
201
223
  /**
202
224
  * 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 +268,6 @@ async function main() {
246
268
  process.stdout.write(text);
247
269
  }
248
270
  main().catch((cause) => {
249
- process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`);
271
+ process.stderr.write(`${errorMessage(cause)}\n`);
250
272
  process.exitCode = 1;
251
273
  });
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Socket } from "node:net";
2
- import { type Replies, type Request, type WatchLine } from "./protocol.ts";
2
+ import { type Replies, type Request, type Target, type WatchLine } from "./protocol.ts";
3
3
  /**
4
4
  * A connection to the host. Requests are answered in order, one reply line for each request line. After `watch`, the
5
5
  * event lines of the watch come between the replies.
@@ -11,14 +11,16 @@ 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]>;
17
19
  /**
18
- * Sends the `watch` request of `handle`. Its `lines` are the events of the watch, and end when the connection closes.
19
- * Other requests can follow on this client. Call it once.
20
+ * Sends the `watch` request of `target`, a handle or a conversation ID. Its `lines` are the events of the watch, and
21
+ * end when the connection closes. Other requests can follow on this client. Call it once.
20
22
  */
21
- watch(handle: string): Promise<Replies["watch"] & {
23
+ watch(target: string | Target): Promise<Replies["watch"] & {
22
24
  lines: AsyncIterable<WatchLine>;
23
25
  }>;
24
26
  close(): void;
@@ -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"));
@@ -61,13 +65,14 @@ export class Client {
61
65
  return reply.promise;
62
66
  }
63
67
  /**
64
- * Sends the `watch` request of `handle`. Its `lines` are the events of the watch, and end when the connection closes.
65
- * Other requests can follow on this client. Call it once.
68
+ * Sends the `watch` request of `target`, a handle or a conversation ID. Its `lines` are the events of the watch, and
69
+ * end when the connection closes. Other requests can follow on this client. Call it once.
66
70
  */
67
- async watch(handle) {
71
+ async watch(target) {
68
72
  const lines = new Readable({ objectMode: true, read() { } });
69
73
  this.#lines = lines;
70
- const reply = await this.request({ type: "watch", handle });
74
+ const ref = target instanceof Object ? target : { handle: target };
75
+ const reply = await this.request({ type: "watch", ...ref });
71
76
  return { ...reply, lines };
72
77
  }
73
78
  close() {
@@ -87,12 +92,12 @@ export class Client {
87
92
  }
88
93
  }
89
94
  /**
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.
95
+ * A connection to the host of `project`, or of the project of `HARNESS_PROJECT` or the cwd (see `resolveProject`),
96
+ * after its `hello`. When no host listens and `start` is true, it starts one, detached, with its output in `host.log`,
97
+ * and tries to connect for 10 s, or until `signal` aborts. When no host listens and `start` is false, it is undefined.
93
98
  */
94
99
  export async function connect(options) {
95
- const project = resolveProject(process.env, process.cwd());
100
+ const project = options.project === undefined ? resolveProject(process.env, process.cwd()) : checkProject(options.project);
96
101
  const paths = statePaths(project);
97
102
  let socket = await tryConnect(paths.socket);
98
103
  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,40 @@
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>;
21
+ /**
22
+ * Opens the children library conversation `conversation` in Pi, on the project of `env` and this process's cwd: in a
23
+ * new pane to the right of the Herdr pane `place.pane`, or in a new tab of the Herdr workspace `place.workspace`. The
24
+ * pane or tab gets the key of the conversation as its label. For a conversation, `ChildInfo` gives the same ID in
25
+ * `handle` and in `conversation`. It rejects when a `herdr` call fails or gives no pane ID, and then makes no more
26
+ * calls.
27
+ */
28
+ export declare function openConversationPane(conversation: ChildInfo, place: {
29
+ pane: string;
30
+ workspace?: never;
31
+ } | {
32
+ workspace: string;
33
+ pane?: never;
34
+ }, env: NodeJS.ProcessEnv): Promise<void>;
33
35
  /**
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.
36
+ * Reports the state of the agent `label` to the Herdr pane of `HARNESS_HERDR_PANE`, with the command that opens
37
+ * `target` as the resume command. Both methods do nothing without that variable, so only a Pi that the CLI
38
+ * started in a Herdr pane reports. The calls run one after another, so `--seq` reaches Herdr in order.
37
39
  */
38
- export declare function agentReporter(env: NodeJS.ProcessEnv, label: string, handle: string, project: string): AgentReporter;
40
+ export declare function agentReporter(env: NodeJS.ProcessEnv, label: string, target: MirrorTarget): AgentReporter;
package/dist/herdr.js CHANGED
@@ -1,31 +1,53 @@
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() }) }) });
16
+ /** The part of the output of `herdr pane split` that has the new pane. */
17
+ const PaneSplit = Type.Object({ result: Type.Object({ pane: Type.Object({ pane_id: Type.String() }) }) });
9
18
  /**
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>`.
19
+ * The words of the command that opens `target` in Pi, with this Node and this package's CLI, so it does not need the
20
+ * CLI on PATH. Herdr wants a plain command name as the first word of a resume command, so it starts with `env`.
13
21
  */
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] };
22
+ function openCommand(target) {
23
+ return ["env", process.execPath, cliPath(), ...harnessArgs(target)];
19
24
  }
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];
25
+ /** The `--env` flags of `herdr` that give `XDG_STATE_HOME` and `XDG_CONFIG_HOME` of `env`, when they are set. */
26
+ function envFlags(env) {
27
+ // The new pane must find the same host and projects as `statePaths()` in protocol.ts and `projectsPath()` in
28
+ // projects.ts.
29
+ const state = env.XDG_STATE_HOME;
30
+ const config = env.XDG_CONFIG_HOME;
31
+ return [
32
+ ...(state ? ["--env", `XDG_STATE_HOME=${state}`] : []),
33
+ ...(config ? ["--env", `XDG_CONFIG_HOME=${config}`] : []),
34
+ ];
35
+ }
36
+ /** Runs the command that opens `target` in Pi in the Herdr pane `pane`, with each word in single quotes. */
37
+ async function runOpen(pane, target, env) {
38
+ const command = openCommand(target)
39
+ .map((word) => `'${word.replaceAll("'", "'\\''")}'`)
40
+ .join(" ");
41
+ await herdr(["pane", "run", pane, command], env);
42
+ }
43
+ /** Whether this process runs in a Herdr pane, also as the Pi that the CLI starts there. */
44
+ export function inHerdr(env) {
45
+ return Boolean(env.HERDR_ENV || env[PANE_VARIABLE]);
46
+ }
47
+ /** The environment of the Pi that the CLI starts: in a Herdr pane, `HARNESS_HERDR_PANE` in place of `HERDR_ENV`. */
48
+ export function piEnv(env) {
49
+ const { HERDR_ENV, ...rest } = env;
50
+ return HERDR_ENV && env.HERDR_PANE_ID ? { ...rest, [PANE_VARIABLE]: env.HERDR_PANE_ID } : env;
29
51
  }
30
52
  /**
31
53
  * Runs `herdr` with `args` and gives its output. When `herdr` fails, it rejects with the stderr of `herdr`, or with the
@@ -42,24 +64,61 @@ export function herdr(args, env) {
42
64
  });
43
65
  }
44
66
  /**
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
67
+ * Opens a Herdr tab that opens `child` in Pi, on the project of `env` and this process's cwd. It rejects when a
46
68
  * `herdr` call fails or gives no pane ID.
47
69
  */
48
- export async function openViewerTab(child, env) {
70
+ export async function openChildTab(child, env) {
49
71
  const project = resolveProject(env, process.cwd());
50
- // The tab must find the same host and projects as `statePaths()` in protocol.ts and `projectsPath()` in projects.ts.
51
- const state = env.XDG_STATE_HOME;
52
- const config = env.XDG_CONFIG_HOME;
72
+ const created = await herdr(["tab", "create", "--cwd", child.cwd, "--label", childLabel(child), "--focus", ...envFlags(env)], env);
73
+ let output;
74
+ try {
75
+ output = JSON.parse(created);
76
+ }
77
+ catch {
78
+ output = undefined;
79
+ }
80
+ if (!Value.Check(TabCreated, output))
81
+ throw new Error(`herdr tab create gave no pane ID: ${created.trim()}`);
82
+ await runOpen(output.result.root_pane.pane_id, { project, handle: child.handle }, env);
83
+ }
84
+ /**
85
+ * Opens the children library conversation `conversation` in Pi, on the project of `env` and this process's cwd: in a
86
+ * new pane to the right of the Herdr pane `place.pane`, or in a new tab of the Herdr workspace `place.workspace`. The
87
+ * pane or tab gets the key of the conversation as its label. For a conversation, `ChildInfo` gives the same ID in
88
+ * `handle` and in `conversation`. It rejects when a `herdr` call fails or gives no pane ID, and then makes no more
89
+ * calls.
90
+ */
91
+ export async function openConversationPane(conversation, place, env) {
92
+ const project = resolveProject(env, process.cwd());
93
+ const target = { project, conversation: conversation.handle };
94
+ const label = childLabel(conversation);
95
+ if (place.pane !== undefined) {
96
+ const split = await herdr(["pane", "split", place.pane, "--direction", "right", "--cwd", conversation.cwd, ...envFlags(env)], env);
97
+ let output;
98
+ try {
99
+ output = JSON.parse(split);
100
+ }
101
+ catch {
102
+ output = undefined;
103
+ }
104
+ if (!Value.Check(PaneSplit, output))
105
+ throw new Error(`herdr pane split gave no pane ID: ${split.trim()}`);
106
+ const pane = output.result.pane.pane_id;
107
+ await herdr(["pane", "rename", pane, label], env);
108
+ await runOpen(pane, target, env);
109
+ return;
110
+ }
53
111
  const created = await herdr([
54
112
  "tab",
55
113
  "create",
114
+ "--workspace",
115
+ place.workspace,
56
116
  "--cwd",
57
- child.cwd,
117
+ conversation.cwd,
58
118
  "--label",
59
- childLabel(child),
119
+ label,
60
120
  "--focus",
61
- ...(state ? ["--env", `XDG_STATE_HOME=${state}`] : []),
62
- ...(config ? ["--env", `XDG_CONFIG_HOME=${config}`] : []),
121
+ ...envFlags(env),
63
122
  ], env);
64
123
  let output;
65
124
  try {
@@ -70,20 +129,16 @@ export async function openViewerTab(child, env) {
70
129
  }
71
130
  if (!Value.Check(TabCreated, output))
72
131
  throw new Error(`herdr tab create gave no pane ID: ${created.trim()}`);
73
- const pane = output.result.root_pane.pane_id;
74
- const viewer = viewerCommand(child.handle, project)
75
- .map((word) => `'${word.replaceAll("'", "'\\''")}'`)
76
- .join(" ");
77
- await herdr(["pane", "run", pane, viewer], env);
132
+ await runOpen(output.result.root_pane.pane_id, target, env);
78
133
  }
79
134
  /**
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.
135
+ * Reports the state of the agent `label` to the Herdr pane of `HARNESS_HERDR_PANE`, with the command that opens
136
+ * `target` as the resume command. Both methods do nothing without that variable, so only a Pi that the CLI
137
+ * started in a Herdr pane reports. The calls run one after another, so `--seq` reaches Herdr in order.
83
138
  */
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)];
139
+ export function agentReporter(env, label, target) {
140
+ const pane = env[PANE_VARIABLE] || undefined;
141
+ const resume = pane === undefined ? [] : ["--", ...openCommand(target)];
87
142
  let calls = Promise.resolve();
88
143
  let seq = 0;
89
144
  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>;