@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/dist/host.js CHANGED
@@ -1,62 +1,34 @@
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
- import { AgentDoc, createRegistry, defineDoc, Harness, } from "@earendil-works/pi-durable";
12
+ import { AgentDoc, createRegistry, 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
- import { createChildren, resolveModel, } from "./children.js";
18
+ import { 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 { errorMessage, onLines, PROTOCOL_VERSION, RequestLine, } from "./protocol.js";
22
+ import { Children, childInfo, childOf, childRecord, childTarget, library, ownRecord, recordOf, target, } from "./targets.js";
31
23
  import { abortTask, startableTasks, startTask, taskInfo, waitTask } from "./tasks.js";
32
24
  import { subagentTool } from "./tool.js";
25
+ import { viewChange } from "./view-change.js";
33
26
  /** The host exits after this long with no connection, no live task and no request. */
34
27
  const IDLE_EXIT_MS = 5 * 60_000;
35
28
  const IDLE_CHECK_MS = 60_000;
36
29
  const context = BACKGROUND_CONTEXT;
37
30
  /** The `detail` of a failed turn that is an error object. */
38
31
  const ErrorDetail = Type.Object({ message: Type.String() });
39
- /**
40
- * The children by handle, and the handle of each spawn request ID. Version 2 leaves each child's last request to the
41
- * children library; a store with version 1 has children that the library does not know.
42
- */
43
- const Children = defineDoc({
44
- kind: "subagent.children",
45
- version: 2,
46
- scope: "session",
47
- initial: () => ({ children: {}, requests: {} }),
48
- });
49
- /** The request ID of the project agent's newest input, which its `error` state comes from. */
50
- const AgentState = defineDoc({
51
- kind: "harness.agent",
52
- version: 1,
53
- scope: "conversation",
54
- history: "latest",
55
- fork: "initial",
56
- initial: () => ({ lastRequest: null }),
57
- });
58
- /** The children library, on this process's pi-durable. */
59
- const library = createChildren(durable);
60
32
  /**
61
33
  * Runs the host of `project` in this process: the store in `stateDir`, the children's models from `models`, the
62
34
  * project agent in `cwd`, and the socket. It is undefined when another host holds the lock.
@@ -74,7 +46,7 @@ export async function runHost(options) {
74
46
  const settings = SettingsManager.create(stateDir, agentDir, {
75
47
  projectTrusted: new ProjectTrustStore(agentDir).get(stateDir) === true,
76
48
  });
77
- const warn = (cause) => process.stderr.write(`warning: ${cause instanceof Error ? cause.message : String(cause)}\n`);
49
+ const warn = (cause) => process.stderr.write(`warning: ${errorMessage(cause)}\n`);
78
50
  // The store is there once the harness is open; entries get `place` before, and use the store in their tasks.
79
51
  let harness;
80
52
  const place = {
@@ -88,7 +60,7 @@ export async function runHost(options) {
88
60
  return registry.snapshot();
89
61
  },
90
62
  };
91
- const entries = await loadEntries(stateDir, agentDir, settings, place, warn).catch((cause) => {
63
+ const entries = await loadEntries(stateDir, agentDir, settings, place, library, warn).catch((cause) => {
92
64
  lock.close();
93
65
  throw cause;
94
66
  });
@@ -107,7 +79,9 @@ export async function runHost(options) {
107
79
  caller: async (api) => String(api.conversationId),
108
80
  send: (request, signal) => handleRequest(request, { ...base, signal }),
109
81
  });
110
- const selected = [ChildContext, tool, ...entries.extensions];
82
+ // Each script runs in a worker thread, in a VM of at most 256 MB, as in Pi.
83
+ const sandbox = new CodemodeSandbox({ memoryLimitBytes: 256 * 1024 * 1024 });
84
+ const selected = [ChildContext, tool, createCodemodeExtension({ sandbox }), ...entries.extensions];
111
85
  try {
112
86
  for (const extension of selected)
113
87
  registry.install(extension);
@@ -148,7 +122,7 @@ export async function runHost(options) {
148
122
  }
149
123
  catch (error) {
150
124
  await harness.close(context);
151
- const message = error instanceof Error ? error.message : String(error);
125
+ const message = errorMessage(error);
152
126
  if (!message.includes("(subagent.children) requires migration from version"))
153
127
  throw error;
154
128
  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 +162,7 @@ export async function runHost(options) {
188
162
  };
189
163
  }
190
164
  catch (error) {
191
- reply = { ok: false, error: error instanceof Error ? error.message : String(error) };
165
+ reply = { ok: false, error: errorMessage(error) };
192
166
  }
193
167
  if (socket.writable)
194
168
  socket.write(`${JSON.stringify(reply)}\n`);
@@ -210,6 +184,7 @@ export async function runHost(options) {
210
184
  });
211
185
  }).catch(async (cause) => {
212
186
  await entries.close();
187
+ await sandbox.close();
213
188
  await harness.close(context);
214
189
  lock.close();
215
190
  throw cause;
@@ -224,8 +199,10 @@ export async function runHost(options) {
224
199
  for (const abort of aborts)
225
200
  abort.abort();
226
201
  try {
227
- // Entries close first: an entry may still need the open harness.
202
+ // Entries close first: an entry may still need the open harness. The sandbox closes before the harness, so a
203
+ // script that runs gets no outcome and continues after a restart.
228
204
  await entries.close();
205
+ await sandbox.close();
229
206
  await harness.close(context);
230
207
  for (const env of envs.values())
231
208
  await env.cleanup(context);
@@ -270,7 +247,8 @@ async function handleRequest(request, handler) {
270
247
  }
271
248
  }
272
249
  case "input":
273
- 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));
274
252
  case "wait":
275
253
  return waitChild(request, handler);
276
254
  case "status":
@@ -279,12 +257,10 @@ async function handleRequest(request, handler) {
279
257
  return abortChild(request, handler);
280
258
  case "configure":
281
259
  return configureChild(request, handler);
282
- case "models":
283
- return modelInfos(handler.place.models);
284
260
  case "watch":
285
261
  if (handler.connection === undefined)
286
262
  throw new Error("A watch needs a connection");
287
- return serveWatch(handler, request.handle, handler.connection);
263
+ return serveWatch(handler, request, handler.connection);
288
264
  case "start":
289
265
  // A child that a task opens without a model takes the project agent's, so the agent is set up first. Without
290
266
  // a model for the agent, the task still starts: its children may name their own.
@@ -452,7 +428,7 @@ async function waitChild(request, handler) {
452
428
  /**
453
429
  * The requests of `sent` from `read` on that a wait for `main` gives before the answer to `main`, and the new `read`.
454
430
  * 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.
431
+ * stays unread. Otherwise, such as for the input of a mirror session, they are the caller's requests known when the wait started.
456
432
  */
457
433
  function unreadBefore(sent, read, main, atStart) {
458
434
  const index = main === null ? -1 : sent.indexOf(main);
@@ -488,79 +464,18 @@ async function markSent(harness, handle, requestId, add) {
488
464
  }, context);
489
465
  }
490
466
  async function abortChild(request, handler) {
491
- const found = await target(handler, request.handle);
467
+ const found = await target(handler, request);
492
468
  // Without `background`, an abort of the project agent does not reach the anchors of the children, which keep running.
493
469
  await found.conversation.abort(context);
494
470
  return found.info();
495
471
  }
496
472
  async function configureChild(request, handler) {
497
- 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 });
498
475
  const agent = await found.conversation.agent(context);
499
476
  await found.conversation.configure(resolveModel(handler.place.models, agent, request), context);
500
477
  return found.info();
501
478
  }
502
- /**
503
- * The conversation of `handle`, a child or the project agent, with its info, its input, and the record of a child.
504
- */
505
- async function target(handler, handle) {
506
- if (handle !== AGENT_HANDLE)
507
- return childTarget(handler, handle, await childRecord(handler.harness, handle));
508
- const root = await handler.agent();
509
- const { harness, place, project } = handler;
510
- const turn = library.turn(place, root.id, root, async (readContext) => (await harness.snapshot(AgentState, root.id, readContext))?.lastRequest, async (tx, requestId) => {
511
- (await tx.doc(AgentState, root.id)).lastRequest = requestId;
512
- });
513
- return {
514
- conversation: root,
515
- info: async () => ({
516
- ...(await agentFields(root)),
517
- handle,
518
- name: `${project} agent`,
519
- state: await turn.state(context),
520
- }),
521
- send: (input) => turn.send(input, context),
522
- record: undefined,
523
- };
524
- }
525
- /** The target of the child `handle`, whose record is `record`. */
526
- async function childTarget(handler, handle, record) {
527
- const child = await childOf(handler, record);
528
- return {
529
- conversation: child.conversation,
530
- info: () => childInfo(handler, handle, record),
531
- send: (input) => child.send(input, context),
532
- record,
533
- };
534
- }
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
- async function childRecord(harness, handle) {
545
- const state = await harness.snapshot(Children, context);
546
- const record = recordOf(state, handle);
547
- if (record === undefined)
548
- throw new Error(`Unknown subagent: ${handle}`);
549
- return record;
550
- }
551
- /** The record of `handle`, which must belong to `caller`. */
552
- async function ownRecord(harness, handle, caller) {
553
- const record = await childRecord(harness, handle);
554
- if (record.caller !== caller)
555
- throw new Error(`Subagent ${handle} belongs to another session`);
556
- return record;
557
- }
558
- async function childOf(handler, record) {
559
- const child = await library.find(handler.place, handler.owner, { id: String(record.conversationId) }, context);
560
- if (child === undefined)
561
- throw new Error(`Missing conversation ${record.conversationId}`);
562
- return child;
563
- }
564
479
  function failureText(handle, reason, detail) {
565
480
  if (reason === "aborted")
566
481
  return `${handle} was aborted`;
@@ -571,27 +486,6 @@ function failureText(handle, reason, detail) {
571
486
  : undefined;
572
487
  return text === undefined ? `${handle} failed: ${reason}` : `${handle} failed: ${text}`;
573
488
  }
574
- /** The info of `handle`. A caller that already has the record gives it as `record`. */
575
- async function childInfo(handler, handle, record) {
576
- record ??= await childRecord(handler.harness, handle);
577
- const child = await childOf(handler, record);
578
- const childState = await child.state(context);
579
- // A closed child is idle: the watch can still send it before its record goes and the `stopped` line follows.
580
- const state = childState === "closed" ? "idle" : childState;
581
- const fields = await agentFields(child.conversation);
582
- return record.name === undefined ? { handle, state, ...fields } : { handle, name: record.name, state, ...fields };
583
- }
584
- /** The model, thinking level and directory of the agent of `conversation`, for its info. */
585
- async function agentFields(conversation) {
586
- const agent = await conversation.agent(context);
587
- return {
588
- provider: agent.model?.provider ?? "",
589
- model: agent.model?.modelId ?? "",
590
- thinking: agent.thinkingLevel,
591
- // `spawnChild` refuses a spawn without `cwd`; the fallback only satisfies pi-durable's optional type.
592
- cwd: agent.cwd ?? "",
593
- };
594
- }
595
489
  /**
596
490
  * The root conversation as the agent of `project`. The first time, it gets Pi's default model when it has a login, else
597
491
  * the first model with a login, Pi's default thinking level, and the directory `cwd`. Without a model with a login, it
@@ -619,13 +513,14 @@ async function setUpAgent(root, models, settings, project, cwd) {
619
513
  }
620
514
  /**
621
515
  * 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
516
+ * commit to the child's conversation gives one `{ event: WatchLine }` line with its change, and each rename one with
517
+ * an empty change. It waits for a slow socket to drain, so pi-durable replaces the frames it holds back with one frame
518
+ * of the whole view. The watches stop when the socket closes, when setup fails, or when the connection ends; a wait for
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
626
520
  * watch ends for another reason, the connection ends.
627
521
  */
628
- async function serveWatch(handler, handle, connection) {
522
+ async function serveWatch(handler, ref, connection) {
523
+ const handle = ref.conversation ?? ref.handle;
629
524
  const { harness } = handler;
630
525
  const { socket } = connection;
631
526
  const write = (event) => socket.write(`${JSON.stringify({ event })}\n`);
@@ -642,8 +537,9 @@ async function serveWatch(handler, handle, connection) {
642
537
  let conversation;
643
538
  let last;
644
539
  try {
645
- found = await target(handler, handle);
646
- // 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.
647
543
  if (found.record !== undefined) {
648
544
  // The `Children` watch opens before the info, so it gets each later rename and stop of the child.
649
545
  children = await harness.watchDoc(Children, watchContext);
@@ -668,14 +564,14 @@ async function serveWatch(handler, handle, connection) {
668
564
  // pi-durable runs the callbacks of one watch in order, but not those of two watches. Both watches write through
669
565
  // this one chain, so a line never has older info than the line before it.
670
566
  let queue = Promise.resolve();
671
- const send = (ops) => {
567
+ const send = (change) => {
672
568
  queue = queue.then(async () => {
673
569
  // Some store reads per commit; cache the agent fields if streaming gets slow. A store failure drops this
674
570
  // line. A stopped child still gives `idle` here, and its `stopped` line follows.
675
571
  const child = await found.info().catch(() => undefined);
676
572
  if (child === undefined || !socket.writable)
677
573
  return;
678
- if (!write({ ops, child }))
574
+ if (!write({ change, child }))
679
575
  await once(socket, "drain").catch(() => { });
680
576
  });
681
577
  return queue;
@@ -690,17 +586,13 @@ async function serveWatch(handler, handle, connection) {
690
586
  }
691
587
  else if (next.name !== found.record?.name) {
692
588
  found = { ...found, record: next, info: () => childInfo(handler, handle, next) };
693
- await send([]);
589
+ await send({});
694
590
  }
695
591
  });
696
- conversation.start((_view, ops) => send(ops));
592
+ conversation.start((view, ops) => send(viewChange(view, ops)));
697
593
  });
698
594
  return { view: conversation.value, child: last };
699
595
  }
700
- /** The record of `handle` in the `Children` value `state`. */
701
- function recordOf(state, handle) {
702
- return state != null && Object.hasOwn(state.children, handle) ? state.children[handle] : undefined;
703
- }
704
596
  /**
705
597
  * The lock of the host, a write transaction on `lock.sqlite`, held until `close()`. The system drops it when the
706
598
  * process ends. It is undefined when another process holds it. `IMMEDIATE`, not `EXCLUSIVE`: two hosts that start at
@@ -756,72 +648,3 @@ function liveTasks(tasks) {
756
648
  }
757
649
  return tasks.length - dead.size;
758
650
  }
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
- }
@@ -0,0 +1,18 @@
1
+ import { type SettingsManager } from "@earendil-works/pi-coding-agent";
2
+ import type { AnyTask, Extension } from "@earendil-works/pi-durable";
3
+ import type { createChildren, Place } from "./children.ts";
4
+ type ChildrenLibrary = ReturnType<typeof createChildren>;
5
+ /** What the default export of an entry returns: its extensions, the tasks that `start` runs, and `close`. */
6
+ export type Entry = {
7
+ readonly extensions: readonly Extension[];
8
+ readonly tasks?: readonly AnyTask[];
9
+ close(): Promise<void>;
10
+ };
11
+ /**
12
+ * The `piDurable` entries of the installed packages in Pi's `settingsManager` for `cwd`, then the entries of
13
+ * `entries.json` (see `readEntries`), with the children library `children`, in one shared event bus, as one entry whose `close()` closes each of them and
14
+ * passes its errors to `warn`. When an entry fails to load, the entries loaded before it close. Missing packages are
15
+ * skipped, not installed, and so is an entry module that does not exist, such as one of an uninstalled package.
16
+ */
17
+ export declare function loadEntries(cwd: string, agentDir: string, settingsManager: SettingsManager, place: Place, children: ChildrenLibrary, warn: (cause: unknown) => void): Promise<Required<Entry>>;
18
+ export {};
@@ -0,0 +1,91 @@
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
+ /**
10
+ * The entries that the host loads: the `piDurable` entries of Pi's packages and the entries of `entries.json`. Each
11
+ * entry is a module whose default export gives extensions and tasks to the host.
12
+ */
13
+ import { existsSync } from "node:fs";
14
+ import { readFile } from "node:fs/promises";
15
+ import { join, resolve as resolvePath, sep } from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ import * as ai from "@earendil-works/pi-ai";
18
+ import { createEventBus, DefaultPackageManager, } from "@earendil-works/pi-coding-agent";
19
+ import * as durable from "@earendil-works/pi-durable";
20
+ import { Type } from "typebox";
21
+ import { Value } from "typebox/value";
22
+ import { readEntries } from "./entries.js";
23
+ /** A `package.json`, which is a JSON object. */
24
+ const PackageJson = Type.Object({});
25
+ /** The part of a package's `package.json` that names its entries. */
26
+ const Manifest = Type.Object({
27
+ piDurable: Type.Optional(Type.Object({ extensions: Type.Optional(Type.Array(Type.String())) })),
28
+ });
29
+ /**
30
+ * The `piDurable` entries of the installed packages in Pi's `settingsManager` for `cwd`, then the entries of
31
+ * `entries.json` (see `readEntries`), with the children library `children`, in one shared event bus, as one entry whose `close()` closes each of them and
32
+ * passes its errors to `warn`. When an entry fails to load, the entries loaded before it close. Missing packages are
33
+ * skipped, not installed, and so is an entry module that does not exist, such as one of an uninstalled package.
34
+ */
35
+ export async function loadEntries(cwd, agentDir, settingsManager, place, children, warn) {
36
+ const modules = new Set();
37
+ const roots = new DefaultPackageManager({ cwd, agentDir, settingsManager })
38
+ .listConfiguredPackages()
39
+ .flatMap((found) => found.installedPath ?? []);
40
+ for (const root of new Set(roots)) {
41
+ const path = join(root, "package.json");
42
+ const manifest = JSON.parse(await readFile(path, "utf8").catch(() => "{}"));
43
+ if (!Value.Check(PackageJson, manifest)) {
44
+ warn(new Error(`${path} is not a valid package.json`));
45
+ continue;
46
+ }
47
+ if (!Value.Check(Manifest, manifest)) {
48
+ warn(new Error(`${path} has an invalid piDurable field`));
49
+ continue;
50
+ }
51
+ for (const path of manifest.piDurable?.extensions ?? [])
52
+ modules.add(resolvePath(root, path));
53
+ }
54
+ for (const module of Object.values(readEntries()))
55
+ if (existsSync(module))
56
+ modules.add(module);
57
+ const events = createEventBus();
58
+ const entries = [];
59
+ const close = async () => {
60
+ for (const result of await Promise.allSettled(entries.map((entry) => entry.close()))) {
61
+ if (result.status === "rejected")
62
+ warn(result.reason);
63
+ }
64
+ };
65
+ try {
66
+ for (const module of modules) {
67
+ const entry = await importEntry(module);
68
+ entries.push(await entry.default({ durable, ai, events, children, place }));
69
+ }
70
+ }
71
+ catch (error) {
72
+ await close();
73
+ throw error;
74
+ }
75
+ return {
76
+ extensions: entries.flatMap((entry) => entry.extensions),
77
+ tasks: entries.flatMap((entry) => entry.tasks ?? []),
78
+ close,
79
+ };
80
+ }
81
+ /**
82
+ * Imports the entry `module`. Node does not strip types under `node_modules`, where Pi installs npm packages, so jiti
83
+ * loads a TypeScript entry there.
84
+ */
85
+ async function importEntry(module) {
86
+ if (module.endsWith(".ts") && module.split(sep).includes("node_modules")) {
87
+ const { createJiti } = await import("jiti");
88
+ return createJiti(import.meta.url).import(module);
89
+ }
90
+ return import(__rewriteRelativeImportExtension(pathToFileURL(module).href));
91
+ }
@@ -0,0 +1,95 @@
1
+ import type { ConversationView } from "@earendil-works/pi-durable";
2
+ import { type Static, Type } from "typebox";
3
+ import { type ChildInfo, type Request } from "./protocol.ts";
4
+ /** The `customType` of the session entry that holds the target of a mirror session. */
5
+ export declare const MIRROR_ENTRY = "harness-mirror";
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<{
11
+ project: Type.TString;
12
+ handle: Type.TString;
13
+ conversation: Type.TOptional<Type.TNever>;
14
+ }>, Type.TObject<{
15
+ project: Type.TString;
16
+ conversation: Type.TString;
17
+ handle: Type.TOptional<Type.TNever>;
18
+ }>]>;
19
+ export type MirrorTarget = Static<typeof MirrorTarget>;
20
+ /** Whether the data of a session entry is a mirror target. */
21
+ export declare function isMirrorTarget(data: unknown): data is MirrorTarget;
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
+ */
26
+ export declare const PI_FLAGS: {
27
+ readonly project: "harness";
28
+ readonly child: "harness-child";
29
+ readonly conversation: "harness-conversation";
30
+ };
31
+ /** The arguments of `pi` that open a mirror session of `target`. */
32
+ export declare function piArgs(target: MirrorTarget): string[];
33
+ /**
34
+ * The arguments of the `harness` CLI that open `target` in Pi: `<project>` for the project agent, `--project <project>
35
+ * attach <handle>` for a child, and `--project <project> attach --conversation <id>` for a conversation.
36
+ */
37
+ export declare function harnessArgs(target: MirrorTarget): string[];
38
+ /** The title of the agent of `target` before the host gives its name: the handle or the conversation ID of a child. */
39
+ export declare function targetTitle(target: MirrorTarget): string;
40
+ /** A change of the model or the thinking level of the mirrored conversation. */
41
+ export type MirrorConfigure = Omit<Extract<Request, {
42
+ type: "configure";
43
+ }>, "type" | "handle">;
44
+ /**
45
+ * The connection of a mirror: before the first one, open, connecting again after the host closed it, or failed for
46
+ * good, with the reason.
47
+ */
48
+ export type MirrorStatus = {
49
+ readonly kind: "connecting" | "open" | "reconnecting";
50
+ } | {
51
+ readonly kind: "failed";
52
+ readonly reason: string;
53
+ };
54
+ export type MirrorOptions = {
55
+ /** The waits before the tries to connect again after the host closed the connection. Default 0, 2 and 5 s. */
56
+ readonly retryDelaysMs?: readonly number[];
57
+ /** Whether a connection starts the host when none runs. Default true. */
58
+ readonly start?: boolean;
59
+ };
60
+ /**
61
+ * A live copy of one conversation of a harness host, and the requests that act on it. It connects at once, which starts
62
+ * the host when none runs, and connects again when the host closes the connection, with one try after each of
63
+ * `retryDelaysMs`. It fails for good when the first connection fails, when all the tries fail, when the child is
64
+ * stopped, and when the host closes a connection that was opened again less than 10 s before, so that a host that fails
65
+ * at each start does not start again and again. `onChange` runs after each change of `status`, `view` and `child`, with
66
+ * the status before the change.
67
+ */
68
+ export declare class Mirror {
69
+ #private;
70
+ readonly target: MirrorTarget;
71
+ constructor(target: MirrorTarget, onChange: (previous: MirrorStatus) => void, options?: MirrorOptions);
72
+ get status(): MirrorStatus;
73
+ /** The conversation, as the last connection gave it. It is undefined before the first connection. */
74
+ get view(): ConversationView | undefined;
75
+ /** The child or the project agent, as the last connection gave it. It is undefined before the first connection. */
76
+ get child(): ChildInfo | undefined;
77
+ /** Whether the conversation works, as an open connection tells. */
78
+ get busy(): boolean;
79
+ /**
80
+ * Sends `message`: a new turn when the conversation is idle, else a steer, or a follow-up with `followUp`. It resolves
81
+ * once the host has the message. The message waits while the mirror connects. When its connection closes before the
82
+ * host answers, it goes again on the next connection with the same request ID, so that the host takes it once. It
83
+ * rejects when the host refuses the message, and when the mirror fails or closes before the host has it.
84
+ */
85
+ send(message: string, followUp: boolean): Promise<void>;
86
+ /** Aborts the turn, and gives the texts of the queued messages that the host withdraws, steers first. */
87
+ abort(): Promise<string[]>;
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
+ */
92
+ configure(change: MirrorConfigure): Promise<void>;
93
+ /** Closes the connection and stops connecting. The conversation keeps running in the host. */
94
+ close(): void;
95
+ }