@vzlentin/harness 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -9
- package/dist/answer.js +2 -2
- package/dist/cli.js +29 -14
- package/dist/client.d.ts +7 -3
- package/dist/client.js +9 -5
- package/dist/context.js +2 -1
- package/dist/herdr.d.ts +11 -23
- package/dist/herdr.js +30 -28
- package/dist/host.d.ts +1 -1
- package/dist/host.js +28 -111
- package/dist/load-entries.d.ts +18 -0
- package/dist/load-entries.js +91 -0
- package/dist/mirror.d.ts +80 -0
- package/dist/mirror.js +263 -0
- package/dist/protocol.d.ts +18 -27
- package/dist/protocol.js +9 -2
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +3 -2
- package/dist/tool.js +1 -1
- package/dist/view-change.d.ts +38 -0
- package/dist/view-change.js +55 -0
- package/dist/view-state.d.ts +22 -6
- package/dist/view-state.js +45 -7
- package/package.json +2 -1
- package/pi/attachment.ts +296 -0
- package/pi/chat.ts +286 -0
- package/pi/index.ts +10 -8
- package/pi/mirror.ts +206 -0
- package/pi/model-sync.ts +102 -0
- package/pi/package.json +3 -2
- package/pi/usage-line.ts +59 -0
- package/dist/chat.d.ts +0 -48
- package/dist/chat.js +0 -208
- package/dist/chrome.d.ts +0 -18
- package/dist/chrome.js +0 -74
- package/dist/picker.d.ts +0 -22
- package/dist/picker.js +0 -70
- package/dist/theme.d.ts +0 -3
- package/dist/theme.js +0 -19
- package/dist/viewer.d.ts +0 -20
- package/dist/viewer.js +0 -307
|
@@ -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
|
+
}
|
package/dist/mirror.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
/** 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<{
|
|
8
|
+
project: Type.TString;
|
|
9
|
+
handle: Type.TString;
|
|
10
|
+
}>;
|
|
11
|
+
export type MirrorTarget = Static<typeof MirrorTarget>;
|
|
12
|
+
/** Whether the data of a session entry is a mirror target. */
|
|
13
|
+
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. */
|
|
15
|
+
export declare const PI_FLAGS: {
|
|
16
|
+
readonly project: "harness";
|
|
17
|
+
readonly child: "harness-child";
|
|
18
|
+
};
|
|
19
|
+
/** The arguments of `pi` that open a mirror session of `target`. */
|
|
20
|
+
export declare function piArgs(target: MirrorTarget): string[];
|
|
21
|
+
/**
|
|
22
|
+
* The arguments of the `harness` CLI that open `target` in Pi: `<project>` for the project agent, `--project <project>
|
|
23
|
+
* attach <handle>` for a child.
|
|
24
|
+
*/
|
|
25
|
+
export declare function harnessArgs(target: MirrorTarget): string[];
|
|
26
|
+
/** The title of the agent of `target` before the host gives its name. */
|
|
27
|
+
export declare function targetTitle(target: MirrorTarget): string;
|
|
28
|
+
/** A change of the model or the thinking level of the mirrored conversation. */
|
|
29
|
+
export type MirrorConfigure = Omit<Extract<Request, {
|
|
30
|
+
type: "configure";
|
|
31
|
+
}>, "type" | "handle">;
|
|
32
|
+
/**
|
|
33
|
+
* The connection of a mirror: before the first one, open, connecting again after the host closed it, or failed for
|
|
34
|
+
* good, with the reason.
|
|
35
|
+
*/
|
|
36
|
+
export type MirrorStatus = {
|
|
37
|
+
readonly kind: "connecting" | "open" | "reconnecting";
|
|
38
|
+
} | {
|
|
39
|
+
readonly kind: "failed";
|
|
40
|
+
readonly reason: string;
|
|
41
|
+
};
|
|
42
|
+
export type MirrorOptions = {
|
|
43
|
+
/** The waits before the tries to connect again after the host closed the connection. Default 0, 2 and 5 s. */
|
|
44
|
+
readonly retryDelaysMs?: readonly number[];
|
|
45
|
+
/** Whether a connection starts the host when none runs. Default true. */
|
|
46
|
+
readonly start?: boolean;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* A live copy of one conversation of a harness host, and the requests that act on it. It connects at once, which starts
|
|
50
|
+
* the host when none runs, and connects again when the host closes the connection, with one try after each of
|
|
51
|
+
* `retryDelaysMs`. It fails for good when the first connection fails, when all the tries fail, when the child is
|
|
52
|
+
* stopped, and when the host closes a connection that was opened again less than 10 s before, so that a host that fails
|
|
53
|
+
* at each start does not start again and again. `onChange` runs after each change of `status`, `view` and `child`, with
|
|
54
|
+
* the status before the change.
|
|
55
|
+
*/
|
|
56
|
+
export declare class Mirror {
|
|
57
|
+
#private;
|
|
58
|
+
readonly target: MirrorTarget;
|
|
59
|
+
constructor(target: MirrorTarget, onChange: (previous: MirrorStatus) => void, options?: MirrorOptions);
|
|
60
|
+
get status(): MirrorStatus;
|
|
61
|
+
/** The conversation, as the last connection gave it. It is undefined before the first connection. */
|
|
62
|
+
get view(): ConversationView | undefined;
|
|
63
|
+
/** The child or the project agent, as the last connection gave it. It is undefined before the first connection. */
|
|
64
|
+
get child(): ChildInfo | undefined;
|
|
65
|
+
/** Whether the conversation works, as an open connection tells. */
|
|
66
|
+
get busy(): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Sends `message`: a new turn when the conversation is idle, else a steer, or a follow-up with `followUp`. It resolves
|
|
69
|
+
* once the host has the message. The message waits while the mirror connects. When its connection closes before the
|
|
70
|
+
* host answers, it goes again on the next connection with the same request ID, so that the host takes it once. It
|
|
71
|
+
* rejects when the host refuses the message, and when the mirror fails or closes before the host has it.
|
|
72
|
+
*/
|
|
73
|
+
send(message: string, followUp: boolean): Promise<void>;
|
|
74
|
+
/** Aborts the turn, and gives the texts of the queued messages that the host withdraws, steers first. */
|
|
75
|
+
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. */
|
|
77
|
+
configure(change: MirrorConfigure): Promise<void>;
|
|
78
|
+
/** Closes the connection and stops connecting. The conversation keeps running in the host. */
|
|
79
|
+
close(): void;
|
|
80
|
+
}
|
package/dist/mirror.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A mirror session is a Pi session that shows a conversation of a harness host and sends what the user types to it.
|
|
3
|
+
* The conversation stays in the host; the Pi session keeps only its target, in one custom entry. This module has no
|
|
4
|
+
* runtime import of Pi, chord or pi-durable, so the Pi extension can load it.
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import { Value } from "typebox/value";
|
|
10
|
+
import { connect } from "./client.js";
|
|
11
|
+
import { AGENT_HANDLE, errorMessage, runDisplayName, } from "./protocol.js";
|
|
12
|
+
import { applyChange } from "./view-change.js";
|
|
13
|
+
import { inboxOf, withdrawnTexts } from "./view-state.js";
|
|
14
|
+
/** The `customType` of the session entry that holds the target of a mirror session. */
|
|
15
|
+
export const MIRROR_ENTRY = "harness-mirror";
|
|
16
|
+
/** The conversation that a mirror session shows: the child or project agent `handle` of the host of `project`. */
|
|
17
|
+
export const MirrorTarget = Type.Object({ project: Type.String(), handle: Type.String() });
|
|
18
|
+
/** Whether the data of a session entry is a mirror target. */
|
|
19
|
+
export function isMirrorTarget(data) {
|
|
20
|
+
return Value.Check(MirrorTarget, data);
|
|
21
|
+
}
|
|
22
|
+
/** The Pi flags that open a mirror session of a target: the project, and the child in place of the project agent. */
|
|
23
|
+
export const PI_FLAGS = { project: "harness", child: "harness-child" };
|
|
24
|
+
/** The arguments of `pi` that open a mirror session of `target`. */
|
|
25
|
+
export function piArgs(target) {
|
|
26
|
+
const child = target.handle === AGENT_HANDLE ? [] : [`--${PI_FLAGS.child}`, target.handle];
|
|
27
|
+
return [`--${PI_FLAGS.project}`, target.project, ...child];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The arguments of the `harness` CLI that open `target` in Pi: `<project>` for the project agent, `--project <project>
|
|
31
|
+
* attach <handle>` for a child.
|
|
32
|
+
*/
|
|
33
|
+
export function harnessArgs(target) {
|
|
34
|
+
return target.handle === AGENT_HANDLE ? [target.project] : ["--project", target.project, "attach", target.handle];
|
|
35
|
+
}
|
|
36
|
+
/** The title of the agent of `target` before the host gives its name. */
|
|
37
|
+
export function targetTitle(target) {
|
|
38
|
+
return target.handle === AGENT_HANDLE ? `${target.project} agent` : target.handle;
|
|
39
|
+
}
|
|
40
|
+
const RETRY_DELAYS_MS = [0, 2000, 5000];
|
|
41
|
+
/** A connection that was opened again and ends sooner than this is not opened again: the host fails at each start. */
|
|
42
|
+
const SHORT_CONNECTION_MS = 10_000;
|
|
43
|
+
const HOST_CLOSED = "The harness host closed the connection";
|
|
44
|
+
/**
|
|
45
|
+
* A live copy of one conversation of a harness host, and the requests that act on it. It connects at once, which starts
|
|
46
|
+
* the host when none runs, and connects again when the host closes the connection, with one try after each of
|
|
47
|
+
* `retryDelaysMs`. It fails for good when the first connection fails, when all the tries fail, when the child is
|
|
48
|
+
* stopped, and when the host closes a connection that was opened again less than 10 s before, so that a host that fails
|
|
49
|
+
* at each start does not start again and again. `onChange` runs after each change of `status`, `view` and `child`, with
|
|
50
|
+
* the status before the change.
|
|
51
|
+
*/
|
|
52
|
+
export class Mirror {
|
|
53
|
+
target;
|
|
54
|
+
#onChange;
|
|
55
|
+
#retryDelaysMs;
|
|
56
|
+
#start;
|
|
57
|
+
/** Stops the wait for a host that starts, and the waits between the tries, when the mirror closes. */
|
|
58
|
+
#closing = new AbortController();
|
|
59
|
+
/** The messages that the host does not have yet, in the order of `send`. */
|
|
60
|
+
#outgoing = [];
|
|
61
|
+
#status = { kind: "connecting" };
|
|
62
|
+
#view;
|
|
63
|
+
#child;
|
|
64
|
+
#client;
|
|
65
|
+
#sending = false;
|
|
66
|
+
constructor(target, onChange, options = {}) {
|
|
67
|
+
this.target = target;
|
|
68
|
+
this.#onChange = onChange;
|
|
69
|
+
this.#retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS;
|
|
70
|
+
this.#start = options.start ?? true;
|
|
71
|
+
void this.#run();
|
|
72
|
+
}
|
|
73
|
+
get status() {
|
|
74
|
+
return this.#status;
|
|
75
|
+
}
|
|
76
|
+
/** The conversation, as the last connection gave it. It is undefined before the first connection. */
|
|
77
|
+
get view() {
|
|
78
|
+
return this.#view;
|
|
79
|
+
}
|
|
80
|
+
/** The child or the project agent, as the last connection gave it. It is undefined before the first connection. */
|
|
81
|
+
get child() {
|
|
82
|
+
return this.#child;
|
|
83
|
+
}
|
|
84
|
+
/** Whether the conversation works, as an open connection tells. */
|
|
85
|
+
get busy() {
|
|
86
|
+
return this.#status.kind === "open" && this.#child?.state === "busy";
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Sends `message`: a new turn when the conversation is idle, else a steer, or a follow-up with `followUp`. It resolves
|
|
90
|
+
* once the host has the message. The message waits while the mirror connects. When its connection closes before the
|
|
91
|
+
* host answers, it goes again on the next connection with the same request ID, so that the host takes it once. It
|
|
92
|
+
* rejects when the host refuses the message, and when the mirror fails or closes before the host has it.
|
|
93
|
+
*/
|
|
94
|
+
send(message, followUp) {
|
|
95
|
+
if (this.#status.kind === "failed")
|
|
96
|
+
return Promise.reject(new Error(this.#status.reason));
|
|
97
|
+
if (this.#closing.signal.aborted)
|
|
98
|
+
return Promise.reject(new Error("The mirror is closed"));
|
|
99
|
+
const outgoing = { requestId: randomUUID(), message, followUp, sent: Promise.withResolvers() };
|
|
100
|
+
this.#outgoing.push(outgoing);
|
|
101
|
+
void this.#flush();
|
|
102
|
+
return outgoing.sent.promise;
|
|
103
|
+
}
|
|
104
|
+
/** Aborts the turn, and gives the texts of the queued messages that the host withdraws, steers first. */
|
|
105
|
+
async abort() {
|
|
106
|
+
const { client, view } = this.#connection();
|
|
107
|
+
const texts = withdrawnTexts(inboxOf(view));
|
|
108
|
+
this.#setChild(await client.request({ type: "abort", handle: this.target.handle }));
|
|
109
|
+
return texts;
|
|
110
|
+
}
|
|
111
|
+
/** Changes the model or the thinking level. The host refuses a model it does not know and a level it does not have. */
|
|
112
|
+
async configure(change) {
|
|
113
|
+
const { client } = this.#connection();
|
|
114
|
+
this.#setChild(await client.request({ type: "configure", handle: this.target.handle, ...change }));
|
|
115
|
+
}
|
|
116
|
+
/** Closes the connection and stops connecting. The conversation keeps running in the host. */
|
|
117
|
+
close() {
|
|
118
|
+
this.#closing.abort();
|
|
119
|
+
this.#client?.close();
|
|
120
|
+
this.#reject(new Error("The mirror is closed"));
|
|
121
|
+
}
|
|
122
|
+
/** Connects, follows the watch, and connects again after the host closed the connection, until a failure or `close()`. */
|
|
123
|
+
async #run() {
|
|
124
|
+
let attempt = await this.#connect([0]);
|
|
125
|
+
let again = false;
|
|
126
|
+
while (!(attempt instanceof Error)) {
|
|
127
|
+
const opened = Date.now();
|
|
128
|
+
this.#client = attempt.client;
|
|
129
|
+
this.#view = attempt.view;
|
|
130
|
+
this.#child = attempt.child;
|
|
131
|
+
this.#setStatus({ kind: "open" });
|
|
132
|
+
void this.#flush();
|
|
133
|
+
const end = await this.#follow(attempt.view, attempt.lines);
|
|
134
|
+
this.#client = undefined;
|
|
135
|
+
attempt.client.close();
|
|
136
|
+
if (this.#closing.signal.aborted)
|
|
137
|
+
return;
|
|
138
|
+
if (end === "stopped") {
|
|
139
|
+
this.#fail(`${runDisplayName(this.#child ?? attempt.child)} was stopped`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (again && Date.now() - opened < SHORT_CONNECTION_MS) {
|
|
143
|
+
this.#fail(`${HOST_CLOSED}. Run /reload to attach again`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
again = true;
|
|
147
|
+
this.#setStatus({ kind: "reconnecting" });
|
|
148
|
+
attempt = await this.#connect(this.#retryDelaysMs);
|
|
149
|
+
}
|
|
150
|
+
if (this.#closing.signal.aborted)
|
|
151
|
+
return;
|
|
152
|
+
this.#fail(again ? `${HOST_CLOSED}. Connecting again failed: ${attempt.message}` : attempt.message);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* A connection with the watch of the target, after one try at each delay of `delays`. It is the error of the last try
|
|
156
|
+
* when all the tries fail, and an error after `close()`.
|
|
157
|
+
*/
|
|
158
|
+
async #connect(delays) {
|
|
159
|
+
const { signal } = this.#closing;
|
|
160
|
+
let error = new Error("Could not connect to the harness host");
|
|
161
|
+
for (const delay of delays) {
|
|
162
|
+
if (delay > 0)
|
|
163
|
+
await sleep(delay, undefined, { signal }).catch(() => { });
|
|
164
|
+
if (signal.aborted)
|
|
165
|
+
break;
|
|
166
|
+
let client;
|
|
167
|
+
try {
|
|
168
|
+
client = await connect({ start: this.#start, project: this.target.project, signal });
|
|
169
|
+
if (client === undefined)
|
|
170
|
+
throw new Error("Could not connect to the harness host");
|
|
171
|
+
const watched = await client.watch(this.target.handle);
|
|
172
|
+
if (signal.aborted) {
|
|
173
|
+
client.close();
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
return { ...watched, client };
|
|
177
|
+
}
|
|
178
|
+
catch (cause) {
|
|
179
|
+
client?.close();
|
|
180
|
+
error = new Error(errorMessage(cause));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return signal.aborted ? new Error("The mirror is closed") : error;
|
|
184
|
+
}
|
|
185
|
+
/** Applies each change of the watch until it ends: `stopped` when the child was stopped, else `closed`. */
|
|
186
|
+
async #follow(view, lines) {
|
|
187
|
+
let current = view;
|
|
188
|
+
for await (const line of lines) {
|
|
189
|
+
if (line.stopped)
|
|
190
|
+
return "stopped";
|
|
191
|
+
current = applyChange(current, line.change);
|
|
192
|
+
this.#view = current;
|
|
193
|
+
this.#child = line.child;
|
|
194
|
+
this.#onChange(this.#status);
|
|
195
|
+
}
|
|
196
|
+
return "closed";
|
|
197
|
+
}
|
|
198
|
+
/** Sends the waiting messages on the open connection, one at a time, in order. */
|
|
199
|
+
async #flush() {
|
|
200
|
+
if (this.#sending)
|
|
201
|
+
return;
|
|
202
|
+
this.#sending = true;
|
|
203
|
+
try {
|
|
204
|
+
for (let next = this.#outgoing[0]; next !== undefined; next = this.#outgoing[0]) {
|
|
205
|
+
const client = this.#client;
|
|
206
|
+
if (client === undefined)
|
|
207
|
+
return;
|
|
208
|
+
const { requestId, message, followUp } = next;
|
|
209
|
+
try {
|
|
210
|
+
const reply = await client.request({
|
|
211
|
+
type: "input",
|
|
212
|
+
requestId,
|
|
213
|
+
handle: this.target.handle,
|
|
214
|
+
message,
|
|
215
|
+
followUp,
|
|
216
|
+
});
|
|
217
|
+
// `close()` and a failure settle the waiting messages themselves.
|
|
218
|
+
if (this.#outgoing[0] !== next)
|
|
219
|
+
return;
|
|
220
|
+
this.#outgoing.shift();
|
|
221
|
+
this.#setChild(reply.child);
|
|
222
|
+
next.sent.resolve();
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
// After the end of the connection, the host may have the message or not, so it goes again.
|
|
226
|
+
if (client.ended || this.#outgoing[0] !== next)
|
|
227
|
+
return;
|
|
228
|
+
this.#outgoing.shift();
|
|
229
|
+
next.sent.reject(error);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
this.#sending = false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** The open connection and its view, or an error without one. */
|
|
238
|
+
#connection() {
|
|
239
|
+
const client = this.#client;
|
|
240
|
+
const view = this.#view;
|
|
241
|
+
if (client === undefined || view === undefined)
|
|
242
|
+
throw new Error("Not connected to the harness host");
|
|
243
|
+
return { client, view };
|
|
244
|
+
}
|
|
245
|
+
#fail(reason) {
|
|
246
|
+
this.#reject(new Error(reason));
|
|
247
|
+
this.#setStatus({ kind: "failed", reason });
|
|
248
|
+
}
|
|
249
|
+
/** Rejects the waiting messages with `error`. */
|
|
250
|
+
#reject(error) {
|
|
251
|
+
for (const outgoing of this.#outgoing.splice(0))
|
|
252
|
+
outgoing.sent.reject(error);
|
|
253
|
+
}
|
|
254
|
+
#setStatus(status) {
|
|
255
|
+
const previous = this.#status;
|
|
256
|
+
this.#status = status;
|
|
257
|
+
this.#onChange(previous);
|
|
258
|
+
}
|
|
259
|
+
#setChild(child) {
|
|
260
|
+
this.#child = child;
|
|
261
|
+
this.#onChange(this.#status);
|
|
262
|
+
}
|
|
263
|
+
}
|
package/dist/protocol.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { Socket } from "node:net";
|
|
2
2
|
import type { JsonValue } from "@earendil-works/chord";
|
|
3
|
-
import type { Op } from "@earendil-works/chord/delta";
|
|
4
3
|
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
5
4
|
import type { ConversationView, TaskId, TaskOutcome, TaskState } from "@earendil-works/pi-durable";
|
|
6
5
|
import { type Static, Type } from "typebox";
|
|
6
|
+
import type { ViewChange } from "./view-change.ts";
|
|
7
7
|
/** The version of the protocol between the CLI and the host. A client refuses a host with another version. */
|
|
8
|
-
export declare const PROTOCOL_VERSION =
|
|
8
|
+
export declare const PROTOCOL_VERSION = 8;
|
|
9
9
|
/**
|
|
10
10
|
* The handle of the project agent, the host's root conversation, in `watch`, `input`, `abort` and `configure`. It is not
|
|
11
11
|
* hex, so no child has it.
|
|
@@ -37,15 +37,6 @@ export type TaskInfo = {
|
|
|
37
37
|
readonly status: TaskState<JsonValue, JsonValue>["status"];
|
|
38
38
|
readonly outcome?: TaskOutcome<JsonValue>;
|
|
39
39
|
};
|
|
40
|
-
/** A model that the host can run, with what the viewer needs to show and change it. */
|
|
41
|
-
export type ModelInfo = {
|
|
42
|
-
readonly provider: string;
|
|
43
|
-
readonly id: string;
|
|
44
|
-
readonly name: string;
|
|
45
|
-
readonly contextWindow: number;
|
|
46
|
-
/** The thinking levels of the model, from `off`. */
|
|
47
|
-
readonly thinkingLevels: readonly ModelThinkingLevel[];
|
|
48
|
-
};
|
|
49
40
|
/** Any JSON value. A request line is JSON, so `start` takes any input. */
|
|
50
41
|
export declare const Json: Type.TCyclic<{
|
|
51
42
|
Json: Type.TUnion<[Type.TNull, Type.TBoolean, Type.TNumber, Type.TString, Type.TArray<Type.TRef<"Json">>, Type.TRecord<"^.*$", Type.TRef<"Json">>]>;
|
|
@@ -96,25 +87,21 @@ export type Request = {
|
|
|
96
87
|
readonly handle: string;
|
|
97
88
|
}
|
|
98
89
|
/**
|
|
99
|
-
* The
|
|
100
|
-
* conversation and after each rename. Every other line on the connection stays a
|
|
101
|
-
* connection carries one watch.
|
|
90
|
+
* The stream of the child for a mirror session of the Pi extension: the reply, then one `{ event: WatchLine }` line
|
|
91
|
+
* after each commit to the child's conversation and after each rename. Every other line on the connection stays a
|
|
92
|
+
* reply, in request order. A connection carries one watch.
|
|
102
93
|
*/
|
|
103
94
|
| {
|
|
104
95
|
readonly type: "watch";
|
|
105
96
|
readonly handle: string;
|
|
106
97
|
}
|
|
107
|
-
/** Stops the child's current turn.
|
|
98
|
+
/** Stops the child's current turn. A mirror session sends it. */
|
|
108
99
|
| {
|
|
109
100
|
readonly type: "abort";
|
|
110
101
|
readonly handle: string;
|
|
111
102
|
}
|
|
112
|
-
/** The models that have a login, for the viewer's model picker and context window. */
|
|
113
|
-
| {
|
|
114
|
-
readonly type: "models";
|
|
115
|
-
}
|
|
116
103
|
/**
|
|
117
|
-
*
|
|
104
|
+
* A mirror session's change of the child's model or thinking level. It has no caller. A new model keeps the thinking level
|
|
118
105
|
* when the model has it, and takes the nearest level that it has otherwise. The host refuses a level that the model
|
|
119
106
|
* does not have.
|
|
120
107
|
*/
|
|
@@ -125,7 +112,7 @@ export type Request = {
|
|
|
125
112
|
readonly model?: string;
|
|
126
113
|
readonly thinking?: ModelThinkingLevel;
|
|
127
114
|
}
|
|
128
|
-
/**
|
|
115
|
+
/** A mirror session's message to the child. It has no caller. */
|
|
129
116
|
| {
|
|
130
117
|
readonly type: "input";
|
|
131
118
|
readonly requestId: string;
|
|
@@ -209,8 +196,6 @@ export declare const RequestLine: Type.TUnion<[Type.TObject<{
|
|
|
209
196
|
}>, Type.TObject<{
|
|
210
197
|
type: Type.TLiteral<"abort">;
|
|
211
198
|
handle: Type.TString;
|
|
212
|
-
}>, Type.TObject<{
|
|
213
|
-
type: Type.TLiteral<"models">;
|
|
214
199
|
}>, Type.TObject<{
|
|
215
200
|
type: Type.TLiteral<"configure">;
|
|
216
201
|
handle: Type.TString;
|
|
@@ -269,7 +254,6 @@ export type Replies = {
|
|
|
269
254
|
};
|
|
270
255
|
readonly abort: ChildInfo;
|
|
271
256
|
readonly input: Replies["send"];
|
|
272
|
-
readonly models: readonly ModelInfo[];
|
|
273
257
|
readonly configure: ChildInfo;
|
|
274
258
|
readonly start: TaskInfo;
|
|
275
259
|
readonly task: TaskInfo;
|
|
@@ -277,11 +261,11 @@ export type Replies = {
|
|
|
277
261
|
readonly "abort-task": TaskInfo;
|
|
278
262
|
};
|
|
279
263
|
/**
|
|
280
|
-
* An event of a watch after its reply, sent as `{ event: WatchLine }`: the
|
|
281
|
-
* when the child was stopped.
|
|
264
|
+
* An event of a watch after its reply, sent as `{ event: WatchLine }`: the change of one commit, an empty change after a
|
|
265
|
+
* rename, or the end when the child was stopped.
|
|
282
266
|
*/
|
|
283
267
|
export type WatchLine = {
|
|
284
|
-
readonly
|
|
268
|
+
readonly change: ViewChange;
|
|
285
269
|
readonly child: ChildInfo;
|
|
286
270
|
readonly stopped?: undefined;
|
|
287
271
|
} | {
|
|
@@ -317,6 +301,13 @@ export declare function runDisplayName(metadata: {
|
|
|
317
301
|
readonly handle: string;
|
|
318
302
|
readonly name?: string | undefined;
|
|
319
303
|
}): string;
|
|
304
|
+
/** The title of a child in a client: `<name> (<handle>)`, or only the name for the project agent, which says what it is. */
|
|
305
|
+
export declare function childTitle(child: {
|
|
306
|
+
readonly handle: string;
|
|
307
|
+
readonly name?: string | undefined;
|
|
308
|
+
}): string;
|
|
309
|
+
/** The message of a thrown value. */
|
|
310
|
+
export declare function errorMessage(cause: unknown): string;
|
|
320
311
|
/** The line of one child in a list. */
|
|
321
312
|
export declare function childLine(child: ChildInfo): string;
|
|
322
313
|
/** Calls `onLine` with each line that arrives on `socket`, without its newline. */
|
package/dist/protocol.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
5
|
/** The version of the protocol between the CLI and the host. A client refuses a host with another version. */
|
|
6
|
-
export const PROTOCOL_VERSION =
|
|
6
|
+
export const PROTOCOL_VERSION = 8;
|
|
7
7
|
/**
|
|
8
8
|
* The handle of the project agent, the host's root conversation, in `watch`, `input`, `abort` and `configure`. It is not
|
|
9
9
|
* hex, so no child has it.
|
|
@@ -54,7 +54,6 @@ export const RequestLine = Type.Union([
|
|
|
54
54
|
Type.Object({ type: Type.Literal("stop"), caller: Type.String(), handle: Type.String() }),
|
|
55
55
|
Type.Object({ type: Type.Literal("watch"), handle: Type.String() }),
|
|
56
56
|
Type.Object({ type: Type.Literal("abort"), handle: Type.String() }),
|
|
57
|
-
Type.Object({ type: Type.Literal("models") }),
|
|
58
57
|
Type.Object({
|
|
59
58
|
type: Type.Literal("configure"),
|
|
60
59
|
handle: Type.String(),
|
|
@@ -107,6 +106,14 @@ export function childLabel(child) {
|
|
|
107
106
|
export function runDisplayName(metadata) {
|
|
108
107
|
return metadata.name ? `${metadata.name} (${metadata.handle})` : metadata.handle;
|
|
109
108
|
}
|
|
109
|
+
/** The title of a child in a client: `<name> (<handle>)`, or only the name for the project agent, which says what it is. */
|
|
110
|
+
export function childTitle(child) {
|
|
111
|
+
return child.handle === AGENT_HANDLE ? childLabel(child) : runDisplayName(child);
|
|
112
|
+
}
|
|
113
|
+
/** The message of a thrown value. */
|
|
114
|
+
export function errorMessage(cause) {
|
|
115
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
116
|
+
}
|
|
110
117
|
/** The line of one child in a list. */
|
|
111
118
|
export function childLine(child) {
|
|
112
119
|
return `${runDisplayName(child)} ${child.state.padEnd(8)} ${child.provider}/${child.model} ${child.thinking}`;
|
package/dist/setup.d.ts
CHANGED
|
@@ -10,3 +10,5 @@ export declare function setup(packageDir: string): void;
|
|
|
10
10
|
* throws; an error is a warning.
|
|
11
11
|
*/
|
|
12
12
|
export declare function setupOnce(packageDir: string): void;
|
|
13
|
+
/** The `PATH` without the `node_modules/.bin` folders that npm adds: pi-coding-agent there has its own `pi`. */
|
|
14
|
+
export declare function userPath(): string;
|
package/dist/setup.js
CHANGED
|
@@ -10,6 +10,7 @@ import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
|
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { Type } from "typebox";
|
|
12
12
|
import { Value } from "typebox/value";
|
|
13
|
+
import { errorMessage } from "./protocol.js";
|
|
13
14
|
const PackageJson = Type.Object({ name: Type.String(), version: Type.String() });
|
|
14
15
|
const PiSettings = Type.Object({
|
|
15
16
|
packages: Type.Optional(Type.Array(Type.Union([Type.String(), Type.Object({ source: Type.String() })]))),
|
|
@@ -42,7 +43,7 @@ export function setupOnce(packageDir) {
|
|
|
42
43
|
writeStamp(installed);
|
|
43
44
|
}
|
|
44
45
|
catch (error) {
|
|
45
|
-
process.stderr.write(`harness: setup failed: ${
|
|
46
|
+
process.stderr.write(`harness: setup failed: ${errorMessage(error)}\n`);
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
/**
|
|
@@ -137,7 +138,7 @@ function expandHome(path) {
|
|
|
137
138
|
return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
|
|
138
139
|
}
|
|
139
140
|
/** The `PATH` without the `node_modules/.bin` folders that npm adds: pi-coding-agent there has its own `pi`. */
|
|
140
|
-
function userPath() {
|
|
141
|
+
export function userPath() {
|
|
141
142
|
return (process.env.PATH ?? "")
|
|
142
143
|
.split(delimiter)
|
|
143
144
|
.filter((entry) => !entry.endsWith(join("node_modules", ".bin")))
|
package/dist/tool.js
CHANGED
|
@@ -30,7 +30,7 @@ export function toolSchemas(Type) {
|
|
|
30
30
|
model: text("spawn: the model; default yours"),
|
|
31
31
|
thinking: text("spawn: off, minimal, low, medium, high, xhigh or max; default yours"),
|
|
32
32
|
cwd: text("spawn: the child's working directory; default yours"),
|
|
33
|
-
tools: texts("spawn: the only tools the child gets; default read, grep, find, ls, write, edit, bash and the package tools"),
|
|
33
|
+
tools: texts("spawn: the only tools the child gets; default read, grep, find, ls, write, edit, bash, codemode and the package tools"),
|
|
34
34
|
prompts: texts("spawn: the task, in parts"),
|
|
35
35
|
files: texts("spawn: files whose text goes before the task"),
|
|
36
36
|
handle: text("send, wait, status, rename, stop: the hex ID that spawn prints in parentheses, not the name"),
|