@vzlentin/harness 0.3.0 → 0.4.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
@@ -1,6 +1,6 @@
1
1
  # harness
2
2
 
3
- Observable, persistent subagents on [Pi](https://github.com/earendil-works/pi)'s libraries and
3
+ Observable, persistent (sub)agents 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
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
 
@@ -13,22 +13,23 @@ with `/login` in `pi`, or set the API key of the provider, such as `ANTHROPIC_AP
13
13
  npm install -g @vzlentin/harness
14
14
  ```
15
15
 
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` 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
16
+ This works with npm 11 and npm 12. It gives you the `harness` command, and a setup runs `pi install` on
17
+ `npm:@vzlentin/pi-agents` at the same version, which adds the `subagent` tool, the `/subagent` and `/harness` commands
18
+ and the subagents widget to Pi. Pi and the `harness` command use the same host. npm 12 runs no install script by default, so there the first
19
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
- pi install "$(realpath "$(npm root -g)/@vzlentin/harness/pi")"
22
+ pi install "npm:@vzlentin/pi-agents@$(harness --version)"
23
23
  ```
24
24
 
25
- When you install with `--prefix`, give the same `--prefix` to `npm root -g`.
25
+ With only Pi, run `pi install npm:@vzlentin/pi-agents` and start `pi`. The extension asks to install harness with
26
+ `npm install -g`. Answer yes, then run `/reload`.
26
27
 
27
28
  ### Update and remove
28
29
 
29
30
  ```sh
30
- npm install -g @vzlentin/harness@latest
31
- pi remove "$(realpath "$(npm root -g)/@vzlentin/harness/pi")" # first: npm runs no script on uninstall
31
+ npm install -g @vzlentin/harness@latest # the setup pins the extension to this version
32
+ pi remove npm:@vzlentin/pi-agents # npm runs no script on uninstall
32
33
  npm uninstall -g @vzlentin/harness
33
34
  ```
34
35
 
@@ -60,6 +61,7 @@ XDG_STATE_HOME=/tmp/harness-dev node src/cli.ts list
60
61
  `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
61
62
  stores with the hosts of the installed `harness`.
62
63
 
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
65
- extension in Pi, run `npm run build`, then `pi install ./pi`, and build again after each change in `src/`.
64
+ The checkout runs from its TypeScript source. The package contains `dist/` and its `postinstall` script. The Pi
65
+ extension in `pi/` is the package `@vzlentin/pi-agents`. To release, merge the release PR `release: <version>` that CI keeps open: CI then publishes both packages at that version. `npm run check` builds `dist/` too, before the type check. The extension
66
+ loads `dist/` of the global harness, so to try the checkout's extension in Pi, run `npm run build`, `npm link`, then
67
+ `pi install ./pi`, and build again after each change in `src/`.
@@ -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,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { readFileSync } from "node:fs";
4
5
  import { fileURLToPath } from "node:url";
6
+ import { Type } from "typebox";
7
+ import { Value } from "typebox/value";
5
8
  import { request } from "./client.js";
6
9
  import { piEnv } from "./herdr.js";
7
10
  import { piArgs } from "./mirror.js";
@@ -24,8 +27,10 @@ function usage() {
24
27
  harness stop <handle>
25
28
  harness list
26
29
  harness attach <handle>
30
+ harness attach --conversation <id>
27
31
  harness create <name> --cwd <dir>
28
32
  harness projects
33
+ harness --version
29
34
  Any command but harness <project> takes --project <name> before it; without it, the project is HARNESS_PROJECT, then the cwd.`);
30
35
  }
31
36
  function valueAfter(args, index, option) {
@@ -182,12 +187,17 @@ async function hostCommand(args, project) {
182
187
  await host.closed;
183
188
  process.exit(0);
184
189
  }
185
- /** Opens a child in Pi in this terminal. */
190
+ /** Opens a child, or a conversation of the children library with `--conversation <id>`, in Pi in this terminal. */
186
191
  async function attachCommand(args) {
187
- const [handle] = args;
188
- if (args.length !== 1 || handle === undefined)
192
+ const [first, id] = args;
193
+ let ref;
194
+ if (args.length === 1 && first !== undefined && first !== "--conversation")
195
+ ref = { handle: first };
196
+ else if (args.length === 2 && first === "--conversation" && id)
197
+ ref = { conversation: id };
198
+ else
189
199
  usage();
190
- await openInPi({ project: resolveProject(process.env, process.cwd()), handle });
200
+ await openInPi({ project: resolveProject(process.env, process.cwd()), ...ref });
191
201
  }
192
202
  /** Opens the agent of the project `name`, which is `main` or a project, in Pi in this terminal. */
193
203
  async function agentCommand(name, project) {
@@ -197,11 +207,12 @@ async function agentCommand(name, project) {
197
207
  }
198
208
  /**
199
209
  * 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.
210
+ * the host when none runs. A first watch fails before Pi starts for an unknown child, an unknown conversation and a
211
+ * project agent without a model.
202
212
  */
203
213
  async function openInPi(target) {
204
- await request({ type: "watch", handle: target.handle }, { start: true, project: target.project });
214
+ const ref = target.conversation === undefined ? { handle: target.handle } : { conversation: target.conversation };
215
+ await request({ type: "watch", ...ref }, { start: true, project: target.project });
205
216
  const result = spawnSync("pi", piArgs(target), {
206
217
  stdio: "inherit",
207
218
  env: { ...piEnv(process.env), PATH: userPath() },
@@ -229,7 +240,14 @@ async function main() {
229
240
  // npm 12 runs no install script by default, so an installed CLI finishes the setup. A checkout runs `cli.ts`, and
230
241
  // the host starts from a command that already did it.
231
242
  if (import.meta.url.endsWith(".js") && command !== "host")
232
- setupOnce(fileURLToPath(new URL("..", import.meta.url)));
243
+ setupOnce(fileURLToPath(new URL("..", import.meta.url)), "@vzlentin/pi-agents");
244
+ if (command === "--version") {
245
+ if (project !== undefined || args.length !== 0)
246
+ usage();
247
+ const json = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
248
+ process.stdout.write(`${Value.Parse(Type.Object({ version: Type.String() }), json).version}\n`);
249
+ return;
250
+ }
233
251
  if (command === "create")
234
252
  return createCommand(args);
235
253
  if (command === "projects")
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.
@@ -17,10 +17,10 @@ export declare class Client {
17
17
  type: T;
18
18
  }>): Promise<Replies[T]>;
19
19
  /**
20
- * Sends the `watch` request of `handle`. Its `lines` are the events of the watch, and end when the connection closes.
21
- * 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.
22
22
  */
23
- watch(handle: string): Promise<Replies["watch"] & {
23
+ watch(target: string | Target): Promise<Replies["watch"] & {
24
24
  lines: AsyncIterable<WatchLine>;
25
25
  }>;
26
26
  close(): void;
package/dist/client.js CHANGED
@@ -65,13 +65,14 @@ export class Client {
65
65
  return reply.promise;
66
66
  }
67
67
  /**
68
- * Sends the `watch` request of `handle`. Its `lines` are the events of the watch, and end when the connection closes.
69
- * 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.
70
70
  */
71
- async watch(handle) {
71
+ async watch(target) {
72
72
  const lines = new Readable({ objectMode: true, read() { } });
73
73
  this.#lines = lines;
74
- 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 });
75
76
  return { ...reply, lines };
76
77
  }
77
78
  close() {
package/dist/herdr.d.ts CHANGED
@@ -18,6 +18,20 @@ export declare function herdr(args: readonly string[], env: NodeJS.ProcessEnv):
18
18
  * `herdr` call fails or gives no pane ID.
19
19
  */
20
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>;
21
35
  /**
22
36
  * Reports the state of the agent `label` to the Herdr pane of `HARNESS_HERDR_PANE`, with the command that opens
23
37
  * `target` as the resume command. Both methods do nothing without that variable, so only a Pi that the CLI
package/dist/herdr.js CHANGED
@@ -13,6 +13,8 @@ const HERDR_SOURCE = "custom:harness";
13
13
  const PANE_VARIABLE = "HARNESS_HERDR_PANE";
14
14
  /** The part of the output of `herdr tab create` that has the pane of the tab. */
15
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() }) }) });
16
18
  /**
17
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
18
20
  * CLI on PATH. Herdr wants a plain command name as the first word of a resume command, so it starts with `env`.
@@ -20,6 +22,24 @@ const TabCreated = Type.Object({ result: Type.Object({ root_pane: Type.Object({
20
22
  function openCommand(target) {
21
23
  return ["env", process.execPath, cliPath(), ...harnessArgs(target)];
22
24
  }
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
+ }
23
43
  /** Whether this process runs in a Herdr pane, also as the Pi that the CLI starts there. */
24
44
  export function inHerdr(env) {
25
45
  return Boolean(env.HERDR_ENV || env[PANE_VARIABLE]);
@@ -49,19 +69,56 @@ export function herdr(args, env) {
49
69
  */
50
70
  export async function openChildTab(child, env) {
51
71
  const project = resolveProject(env, process.cwd());
52
- // The tab must find the same host and projects as `statePaths()` in protocol.ts and `projectsPath()` in projects.ts.
53
- const state = env.XDG_STATE_HOME;
54
- 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
+ }
55
111
  const created = await herdr([
56
112
  "tab",
57
113
  "create",
114
+ "--workspace",
115
+ place.workspace,
58
116
  "--cwd",
59
- child.cwd,
117
+ conversation.cwd,
60
118
  "--label",
61
- childLabel(child),
119
+ label,
62
120
  "--focus",
63
- ...(state ? ["--env", `XDG_STATE_HOME=${state}`] : []),
64
- ...(config ? ["--env", `XDG_CONFIG_HOME=${config}`] : []),
121
+ ...envFlags(env),
65
122
  ], env);
66
123
  let output;
67
124
  try {
@@ -72,11 +129,7 @@ export async function openChildTab(child, env) {
72
129
  }
73
130
  if (!Value.Check(TabCreated, output))
74
131
  throw new Error(`herdr tab create gave no pane ID: ${created.trim()}`);
75
- const pane = output.result.root_pane.pane_id;
76
- const command = openCommand({ project, handle: child.handle })
77
- .map((word) => `'${word.replaceAll("'", "'\\''")}'`)
78
- .join(" ");
79
- await herdr(["pane", "run", pane, command], env);
132
+ await runOpen(output.result.root_pane.pane_id, target, env);
80
133
  }
81
134
  /**
82
135
  * Reports the state of the agent `label` to the Herdr pane of `HARNESS_HERDR_PANE`, with the command that opens
package/dist/host.js CHANGED
@@ -9,16 +9,17 @@ import * as ai from "@earendil-works/pi-ai";
9
9
  import { CodemodeSandbox } from "@earendil-works/pi-codemode";
10
10
  import { getAgentDir, ProjectTrustStore, SettingsManager } from "@earendil-works/pi-coding-agent";
11
11
  import * as durable from "@earendil-works/pi-durable";
12
- import { AgentDoc, createRegistry, defineDoc, Harness, } from "@earendil-works/pi-durable";
12
+ import { AgentDoc, createRegistry, Harness, } from "@earendil-works/pi-durable";
13
13
  import { createCodemodeExtension } from "@earendil-works/pi-durable/codemode";
14
14
  import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
15
15
  import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
16
16
  import { Type } from "typebox";
17
17
  import { Value } from "typebox/value";
18
- import { createChildren, resolveModel, } from "./children.js";
18
+ import { resolveModel } from "./children.js";
19
19
  import { ChildContext } from "./context.js";
20
20
  import { loadEntries } from "./load-entries.js";
21
- import { AGENT_HANDLE, errorMessage, onLines, PROTOCOL_VERSION, RequestLine, } from "./protocol.js";
21
+ import { errorMessage, onLines, PROTOCOL_VERSION, RequestLine, } from "./protocol.js";
22
+ import { Children, childInfo, childOf, childRecord, childTarget, library, ownRecord, recordOf, target, } from "./targets.js";
22
23
  import { abortTask, startableTasks, startTask, taskInfo, waitTask } from "./tasks.js";
23
24
  import { subagentTool } from "./tool.js";
24
25
  import { viewChange } from "./view-change.js";
@@ -28,27 +29,6 @@ const IDLE_CHECK_MS = 60_000;
28
29
  const context = BACKGROUND_CONTEXT;
29
30
  /** The `detail` of a failed turn that is an error object. */
30
31
  const ErrorDetail = Type.Object({ message: Type.String() });
31
- /**
32
- * The children by handle, and the handle of each spawn request ID. Version 2 leaves each child's last request to the
33
- * children library; a store with version 1 has children that the library does not know.
34
- */
35
- const Children = defineDoc({
36
- kind: "subagent.children",
37
- version: 2,
38
- scope: "session",
39
- initial: () => ({ children: {}, requests: {} }),
40
- });
41
- /** The request ID of the project agent's newest input, which its `error` state comes from. */
42
- const AgentState = defineDoc({
43
- kind: "harness.agent",
44
- version: 1,
45
- scope: "conversation",
46
- history: "latest",
47
- fork: "initial",
48
- initial: () => ({ lastRequest: null }),
49
- });
50
- /** The children library, on this process's pi-durable. */
51
- const library = createChildren(durable);
52
32
  /**
53
33
  * Runs the host of `project` in this process: the store in `stateDir`, the children's models from `models`, the
54
34
  * project agent in `cwd`, and the socket. It is undefined when another host holds the lock.
@@ -267,7 +247,8 @@ async function handleRequest(request, handler) {
267
247
  }
268
248
  }
269
249
  case "input":
270
- return sendTo(request, await target(handler, request.handle));
250
+ // A conversation takes input only while it works, so its input is always a steer.
251
+ return sendTo(request.conversation === undefined ? request : { ...request, followUp: false }, await target(handler, request));
271
252
  case "wait":
272
253
  return waitChild(request, handler);
273
254
  case "status":
@@ -279,7 +260,7 @@ async function handleRequest(request, handler) {
279
260
  case "watch":
280
261
  if (handler.connection === undefined)
281
262
  throw new Error("A watch needs a connection");
282
- return serveWatch(handler, request.handle, handler.connection);
263
+ return serveWatch(handler, request, handler.connection);
283
264
  case "start":
284
265
  // A child that a task opens without a model takes the project agent's, so the agent is set up first. Without
285
266
  // a model for the agent, the task still starts: its children may name their own.
@@ -483,70 +464,18 @@ async function markSent(harness, handle, requestId, add) {
483
464
  }, context);
484
465
  }
485
466
  async function abortChild(request, handler) {
486
- const found = await target(handler, request.handle);
467
+ const found = await target(handler, request);
487
468
  // Without `background`, an abort of the project agent does not reach the anchors of the children, which keep running.
488
469
  await found.conversation.abort(context);
489
470
  return found.info();
490
471
  }
491
472
  async function configureChild(request, handler) {
492
- const found = await target(handler, request.handle);
473
+ // Only the handle: the schema of `configure` does not refuse an extra `conversation` key.
474
+ const found = await target(handler, { handle: request.handle });
493
475
  const agent = await found.conversation.agent(context);
494
476
  await found.conversation.configure(resolveModel(handler.place.models, agent, request), context);
495
477
  return found.info();
496
478
  }
497
- /**
498
- * The conversation of `handle`, a child or the project agent, with its info, its input, and the record of a child.
499
- */
500
- async function target(handler, handle) {
501
- if (handle !== AGENT_HANDLE)
502
- return childTarget(handler, handle, await childRecord(handler.harness, handle));
503
- const root = await handler.agent();
504
- const { harness, place, project } = handler;
505
- const turn = library.turn(place, root.id, root, async (readContext) => (await harness.snapshot(AgentState, root.id, readContext))?.lastRequest, async (tx, requestId) => {
506
- (await tx.doc(AgentState, root.id)).lastRequest = requestId;
507
- });
508
- return {
509
- conversation: root,
510
- info: async () => ({
511
- ...(await agentFields(root)),
512
- handle,
513
- name: `${project} agent`,
514
- state: await turn.state(context),
515
- }),
516
- send: (input) => turn.send(input, context),
517
- record: undefined,
518
- };
519
- }
520
- /** The target of the child `handle`, whose record is `record`. */
521
- async function childTarget(handler, handle, record) {
522
- const child = await childOf(handler, record);
523
- return {
524
- conversation: child.conversation,
525
- info: () => childInfo(handler, handle, record),
526
- send: (input) => child.send(input, context),
527
- record,
528
- };
529
- }
530
- async function childRecord(harness, handle) {
531
- const state = await harness.snapshot(Children, context);
532
- const record = recordOf(state, handle);
533
- if (record === undefined)
534
- throw new Error(`Unknown subagent: ${handle}`);
535
- return record;
536
- }
537
- /** The record of `handle`, which must belong to `caller`. */
538
- async function ownRecord(harness, handle, caller) {
539
- const record = await childRecord(harness, handle);
540
- if (record.caller !== caller)
541
- throw new Error(`Subagent ${handle} belongs to another session`);
542
- return record;
543
- }
544
- async function childOf(handler, record) {
545
- const child = await library.find(handler.place, handler.owner, { id: String(record.conversationId) }, context);
546
- if (child === undefined)
547
- throw new Error(`Missing conversation ${record.conversationId}`);
548
- return child;
549
- }
550
479
  function failureText(handle, reason, detail) {
551
480
  if (reason === "aborted")
552
481
  return `${handle} was aborted`;
@@ -557,27 +486,6 @@ function failureText(handle, reason, detail) {
557
486
  : undefined;
558
487
  return text === undefined ? `${handle} failed: ${reason}` : `${handle} failed: ${text}`;
559
488
  }
560
- /** The info of `handle`. A caller that already has the record gives it as `record`. */
561
- async function childInfo(handler, handle, record) {
562
- record ??= await childRecord(handler.harness, handle);
563
- const child = await childOf(handler, record);
564
- const childState = await child.state(context);
565
- // A closed child is idle: the watch can still send it before its record goes and the `stopped` line follows.
566
- const state = childState === "closed" ? "idle" : childState;
567
- const fields = await agentFields(child.conversation);
568
- return record.name === undefined ? { handle, state, ...fields } : { handle, name: record.name, state, ...fields };
569
- }
570
- /** The model, thinking level and directory of the agent of `conversation`, for its info. */
571
- async function agentFields(conversation) {
572
- const agent = await conversation.agent(context);
573
- return {
574
- provider: agent.model?.provider ?? "",
575
- model: agent.model?.modelId ?? "",
576
- thinking: agent.thinkingLevel,
577
- // `spawnChild` refuses a spawn without `cwd`; the fallback only satisfies pi-durable's optional type.
578
- cwd: agent.cwd ?? "",
579
- };
580
- }
581
489
  /**
582
490
  * The root conversation as the agent of `project`. The first time, it gets Pi's default model when it has a login, else
583
491
  * the first model with a login, Pi's default thinking level, and the directory `cwd`. Without a model with a login, it
@@ -611,7 +519,8 @@ async function setUpAgent(root, models, settings, project, cwd) {
611
519
  * `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
612
520
  * watch ends for another reason, the connection ends.
613
521
  */
614
- async function serveWatch(handler, handle, connection) {
522
+ async function serveWatch(handler, ref, connection) {
523
+ const handle = ref.conversation ?? ref.handle;
615
524
  const { harness } = handler;
616
525
  const { socket } = connection;
617
526
  const write = (event) => socket.write(`${JSON.stringify({ event })}\n`);
@@ -628,8 +537,9 @@ async function serveWatch(handler, handle, connection) {
628
537
  let conversation;
629
538
  let last;
630
539
  try {
631
- found = await target(handler, handle);
632
- // The project agent has no record, and no `Children` watch: it is never renamed or stopped.
540
+ found = await target(handler, ref);
541
+ // The project agent and a conversation target have no record and no `Children` watch: they are never renamed
542
+ // or stopped.
633
543
  if (found.record !== undefined) {
634
544
  // The `Children` watch opens before the info, so it gets each later rename and stop of the child.
635
545
  children = await harness.watchDoc(Children, watchContext);
@@ -683,10 +593,6 @@ async function serveWatch(handler, handle, connection) {
683
593
  });
684
594
  return { view: conversation.value, child: last };
685
595
  }
686
- /** The record of `handle` in the `Children` value `state`. */
687
- function recordOf(state, handle) {
688
- return state != null && Object.hasOwn(state.children, handle) ? state.children[handle] : undefined;
689
- }
690
596
  /**
691
597
  * The lock of the host, a write transaction on `lock.sqlite`, held until `close()`. The system drops it when the
692
598
  * process ends. It is undefined when another process holds it. `IMMEDIATE`, not `EXCLUSIVE`: two hosts that start at
package/dist/mirror.d.ts CHANGED
@@ -3,27 +3,39 @@ import { type Static, Type } from "typebox";
3
3
  import { type ChildInfo, type Request } from "./protocol.ts";
4
4
  /** The `customType` of the session entry that holds the target of a mirror session. */
5
5
  export declare const MIRROR_ENTRY = "harness-mirror";
6
- /** The conversation that a mirror session shows: the child or project agent `handle` of the host of `project`. */
7
- export declare const MirrorTarget: Type.TObject<{
6
+ /**
7
+ * The conversation that a mirror session shows, in the host of `project`: the child or project agent `handle`, or the
8
+ * conversation `conversation` that the children library opened. Each refuses the other key.
9
+ */
10
+ export declare const MirrorTarget: Type.TUnion<[Type.TObject<{
8
11
  project: Type.TString;
9
12
  handle: Type.TString;
10
- }>;
13
+ conversation: Type.TOptional<Type.TNever>;
14
+ }>, Type.TObject<{
15
+ project: Type.TString;
16
+ conversation: Type.TString;
17
+ handle: Type.TOptional<Type.TNever>;
18
+ }>]>;
11
19
  export type MirrorTarget = Static<typeof MirrorTarget>;
12
20
  /** Whether the data of a session entry is a mirror target. */
13
21
  export declare function isMirrorTarget(data: unknown): data is MirrorTarget;
14
- /** The Pi flags that open a mirror session of a target: the project, and the child in place of the project agent. */
22
+ /**
23
+ * The Pi flags that open a mirror session of a target: the project, and the child or the conversation in place of the
24
+ * project agent.
25
+ */
15
26
  export declare const PI_FLAGS: {
16
27
  readonly project: "harness";
17
28
  readonly child: "harness-child";
29
+ readonly conversation: "harness-conversation";
18
30
  };
19
31
  /** The arguments of `pi` that open a mirror session of `target`. */
20
32
  export declare function piArgs(target: MirrorTarget): string[];
21
33
  /**
22
34
  * The arguments of the `harness` CLI that open `target` in Pi: `<project>` for the project agent, `--project <project>
23
- * attach <handle>` for a child.
35
+ * attach <handle>` for a child, and `--project <project> attach --conversation <id>` for a conversation.
24
36
  */
25
37
  export declare function harnessArgs(target: MirrorTarget): string[];
26
- /** The title of the agent of `target` before the host gives its name. */
38
+ /** The title of the agent of `target` before the host gives its name: the handle or the conversation ID of a child. */
27
39
  export declare function targetTitle(target: MirrorTarget): string;
28
40
  /** A change of the model or the thinking level of the mirrored conversation. */
29
41
  export type MirrorConfigure = Omit<Extract<Request, {
@@ -73,7 +85,10 @@ export declare class Mirror {
73
85
  send(message: string, followUp: boolean): Promise<void>;
74
86
  /** Aborts the turn, and gives the texts of the queued messages that the host withdraws, steers first. */
75
87
  abort(): Promise<string[]>;
76
- /** Changes the model or the thinking level. The host refuses a model it does not know and a level it does not have. */
88
+ /**
89
+ * Changes the model or the thinking level. The host refuses a model it does not know and a level it does not have. A
90
+ * conversation of the children library refuses it without a request.
91
+ */
77
92
  configure(change: MirrorConfigure): Promise<void>;
78
93
  /** Closes the connection and stops connecting. The conversation keeps running in the host. */
79
94
  close(): void;