@vzlentin/harness 0.0.0-stage → 0.2.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 +66 -2
- package/dist/answer.d.ts +29 -0
- package/dist/answer.js +176 -0
- package/dist/chat.d.ts +48 -0
- package/dist/chat.js +208 -0
- package/dist/children.d.ts +215 -0
- package/dist/children.js +419 -0
- package/dist/chrome.d.ts +18 -0
- package/dist/chrome.js +74 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +251 -0
- package/dist/client.d.ts +49 -0
- package/dist/client.js +176 -0
- package/dist/context.d.ts +13 -0
- package/dist/context.js +115 -0
- package/dist/durable-entry.d.ts +13 -0
- package/dist/durable-entry.js +33 -0
- package/dist/entries.d.ts +9 -0
- package/dist/entries.js +50 -0
- package/dist/herdr.d.ts +38 -0
- package/dist/herdr.js +111 -0
- package/dist/host.d.ts +18 -0
- package/dist/host.js +827 -0
- package/dist/picker.d.ts +22 -0
- package/dist/picker.js +70 -0
- package/dist/projects.d.ts +23 -0
- package/dist/projects.js +96 -0
- package/dist/protocol.d.ts +323 -0
- package/dist/protocol.js +126 -0
- package/dist/setup.d.ts +13 -0
- package/dist/setup.js +133 -0
- package/dist/tasks.d.ts +31 -0
- package/dist/tasks.js +74 -0
- package/dist/theme.d.ts +3 -0
- package/dist/theme.js +19 -0
- package/dist/tool.d.ts +137 -0
- package/dist/tool.js +213 -0
- package/dist/trust.d.ts +5 -0
- package/dist/trust.js +15 -0
- package/dist/view-state.d.ts +19 -0
- package/dist/view-state.js +44 -0
- package/dist/viewer.d.ts +20 -0
- package/dist/viewer.js +307 -0
- package/package.json +57 -5
- package/pi/index.ts +168 -0
- package/pi/package.json +14 -0
- package/scripts/postinstall.js +13 -0
package/dist/chrome.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { calculateContextTokens } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { UsageDoc } from "@earendil-works/pi-durable";
|
|
4
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import { bold, color } from "./theme.js";
|
|
6
|
+
import { docOf } from "./view-state.js";
|
|
7
|
+
/** One line, cut to the width, with `hints` at the right end: the first hints go when they do not all fit. */
|
|
8
|
+
export class Line {
|
|
9
|
+
text = "";
|
|
10
|
+
hints = [];
|
|
11
|
+
style = (text) => text;
|
|
12
|
+
render(width) {
|
|
13
|
+
for (let first = 0; first < this.hints.length; first++) {
|
|
14
|
+
const right = `${this.hints.slice(first).join(" · ")} `;
|
|
15
|
+
const gap = width - visibleWidth(this.text) - visibleWidth(right);
|
|
16
|
+
if (gap >= 2)
|
|
17
|
+
return [`${this.text}${" ".repeat(gap)}${this.style(right)}`];
|
|
18
|
+
}
|
|
19
|
+
return [truncateToWidth(this.text, width)];
|
|
20
|
+
}
|
|
21
|
+
invalidate() { }
|
|
22
|
+
}
|
|
23
|
+
/** `harness │ <title> │ <cwd> │ <state>`. */
|
|
24
|
+
export function headerText(child, title) {
|
|
25
|
+
const state = child.state === "busy"
|
|
26
|
+
? color("warning", "◉ busy")
|
|
27
|
+
: child.state === "idle"
|
|
28
|
+
? color("success", "● idle")
|
|
29
|
+
: color("error", "⊗ error");
|
|
30
|
+
return ` ${[bold(color("accent", "harness")), title, color("muted", tildePath(child.cwd)), state].join(color("borderMuted", " │ "))}`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* `<provider>/<model> · <thinking> │ <tokens> tok $<cost> │ <used>%/<window> ctx`. The context part shows when
|
|
34
|
+
* `contextWindow` is known and the child has answered once.
|
|
35
|
+
*/
|
|
36
|
+
export function footerText(child, view, contextWindow) {
|
|
37
|
+
const usage = totalUsage(docOf(view, UsageDoc));
|
|
38
|
+
const used = lastUsage(view.entries);
|
|
39
|
+
const context = contextWindow === undefined || contextWindow === 0 || used === undefined
|
|
40
|
+
? []
|
|
41
|
+
: [`${((100 * used) / contextWindow).toFixed(1)}%/${compactCount(contextWindow)} ctx`];
|
|
42
|
+
return ` ${color("muted", [
|
|
43
|
+
`${child.provider}/${child.model} · ${child.thinking}`,
|
|
44
|
+
`${compactCount(usage.tokens)} tok $${usage.cost.toFixed(3)}`,
|
|
45
|
+
...context,
|
|
46
|
+
].join(" │ "))}`;
|
|
47
|
+
}
|
|
48
|
+
/** The tokens and the cost of every model request of the conversation. */
|
|
49
|
+
function totalUsage(usage) {
|
|
50
|
+
let tokens = 0;
|
|
51
|
+
let cost = 0;
|
|
52
|
+
for (const model of Object.values(usage?.models ?? {})) {
|
|
53
|
+
tokens += model.totalTokens;
|
|
54
|
+
cost += model.cost.total;
|
|
55
|
+
}
|
|
56
|
+
return { tokens, cost };
|
|
57
|
+
}
|
|
58
|
+
function compactCount(count) {
|
|
59
|
+
return Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(count);
|
|
60
|
+
}
|
|
61
|
+
function tildePath(path) {
|
|
62
|
+
const home = homedir();
|
|
63
|
+
return home !== "" && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
|
|
64
|
+
}
|
|
65
|
+
/** The prompt and output tokens of the last answer, which fill the context window of the next request. */
|
|
66
|
+
function lastUsage(entries) {
|
|
67
|
+
for (const entry of entries.toReversed()) {
|
|
68
|
+
const message = entry.model?.[0];
|
|
69
|
+
if (entry.kind !== "pi.assistant" || message?.role !== "assistant")
|
|
70
|
+
continue;
|
|
71
|
+
return calculateContextTokens(message.usage);
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { addProject, checkProject, MAIN_PROJECT, projectDir, readProjects } from "./projects.js";
|
|
5
|
+
import { AGENT_HANDLE, statePaths } from "./protocol.js";
|
|
6
|
+
import { setupOnce } from "./setup.js";
|
|
7
|
+
import { runTool, sendToHost } from "./tool.js";
|
|
8
|
+
function fail(message) {
|
|
9
|
+
throw new Error(message);
|
|
10
|
+
}
|
|
11
|
+
function usage() {
|
|
12
|
+
fail(`Usage:
|
|
13
|
+
harness <project>
|
|
14
|
+
harness spawn [--name <name>] [--provider <provider>] [--model <model>] [--thinking <level>]
|
|
15
|
+
[--cwd <dir>] [--tools <names>] (--prompt <text> | --file <path>)...
|
|
16
|
+
harness status <handle>
|
|
17
|
+
harness rename <handle> <name>
|
|
18
|
+
harness send <handle> [--follow-up] <message>
|
|
19
|
+
harness wait <handle> [--timeout <seconds>]
|
|
20
|
+
harness stop <handle>
|
|
21
|
+
harness list
|
|
22
|
+
harness attach <handle>
|
|
23
|
+
harness create <name> --cwd <dir>
|
|
24
|
+
harness projects
|
|
25
|
+
Any command but harness <project> takes --project <name> before it; without it, the project is HARNESS_PROJECT, then the cwd.`);
|
|
26
|
+
}
|
|
27
|
+
function valueAfter(args, index, option) {
|
|
28
|
+
const value = args[index + 1];
|
|
29
|
+
if (!value || value.startsWith("--"))
|
|
30
|
+
fail(`${option} requires a value`);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function spawnArgs(args) {
|
|
34
|
+
const spawn = { action: "spawn" };
|
|
35
|
+
const prompts = [];
|
|
36
|
+
const files = [];
|
|
37
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
38
|
+
const option = args[i];
|
|
39
|
+
switch (option) {
|
|
40
|
+
case "--name":
|
|
41
|
+
spawn.name = valueAfter(args, i, option);
|
|
42
|
+
break;
|
|
43
|
+
case "--provider":
|
|
44
|
+
spawn.provider = valueAfter(args, i, option);
|
|
45
|
+
break;
|
|
46
|
+
case "--model":
|
|
47
|
+
spawn.model = valueAfter(args, i, option);
|
|
48
|
+
break;
|
|
49
|
+
case "--thinking":
|
|
50
|
+
spawn.thinking = valueAfter(args, i, option);
|
|
51
|
+
break;
|
|
52
|
+
case "--cwd":
|
|
53
|
+
spawn.cwd = valueAfter(args, i, option);
|
|
54
|
+
break;
|
|
55
|
+
case "--tools":
|
|
56
|
+
spawn.tools = valueAfter(args, i, option).split(",");
|
|
57
|
+
break;
|
|
58
|
+
case "--prompt":
|
|
59
|
+
prompts.push(valueAfter(args, i, option));
|
|
60
|
+
break;
|
|
61
|
+
case "--file":
|
|
62
|
+
files.push(valueAfter(args, i, option));
|
|
63
|
+
break;
|
|
64
|
+
default:
|
|
65
|
+
fail(`Unknown spawn argument: ${option}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { ...spawn, prompts, files };
|
|
69
|
+
}
|
|
70
|
+
function sendArgs(args) {
|
|
71
|
+
const [handle, ...rest] = args;
|
|
72
|
+
if (!handle)
|
|
73
|
+
usage();
|
|
74
|
+
let followUp = false;
|
|
75
|
+
const messageParts = [];
|
|
76
|
+
for (const arg of rest) {
|
|
77
|
+
if (arg === "--follow-up")
|
|
78
|
+
followUp = true;
|
|
79
|
+
else if (arg.startsWith("--"))
|
|
80
|
+
fail(`Unknown send argument: ${arg}`);
|
|
81
|
+
else
|
|
82
|
+
messageParts.push(arg);
|
|
83
|
+
}
|
|
84
|
+
return { action: "send", handle, message: messageParts.join(" "), followUp };
|
|
85
|
+
}
|
|
86
|
+
function waitArgs(args) {
|
|
87
|
+
const [handle, ...rest] = args;
|
|
88
|
+
if (!handle)
|
|
89
|
+
usage();
|
|
90
|
+
if (rest.length === 0)
|
|
91
|
+
return { action: "wait", handle };
|
|
92
|
+
if (rest.length !== 2 || rest[0] !== "--timeout")
|
|
93
|
+
usage();
|
|
94
|
+
const timeout = Number(rest[1]);
|
|
95
|
+
if (!Number.isInteger(timeout) || timeout <= 0)
|
|
96
|
+
fail("--timeout must be a positive integer");
|
|
97
|
+
return { action: "wait", handle, timeout };
|
|
98
|
+
}
|
|
99
|
+
/** The tool arguments of a CLI command. */
|
|
100
|
+
function toolArgs(command, args) {
|
|
101
|
+
const [first, second] = args;
|
|
102
|
+
switch (command) {
|
|
103
|
+
case "spawn":
|
|
104
|
+
return spawnArgs(args);
|
|
105
|
+
case "send":
|
|
106
|
+
return sendArgs(args);
|
|
107
|
+
case "wait":
|
|
108
|
+
return waitArgs(args);
|
|
109
|
+
case "status":
|
|
110
|
+
case "stop":
|
|
111
|
+
if (args.length !== 1 || first === undefined)
|
|
112
|
+
usage();
|
|
113
|
+
return { action: command, handle: first };
|
|
114
|
+
case "rename":
|
|
115
|
+
if (args.length !== 2 || first === undefined || second === undefined)
|
|
116
|
+
usage();
|
|
117
|
+
return { action: command, handle: first, name: second };
|
|
118
|
+
case "list":
|
|
119
|
+
if (args.length !== 0)
|
|
120
|
+
usage();
|
|
121
|
+
return { action: command };
|
|
122
|
+
default:
|
|
123
|
+
usage();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** The commands, which a project cannot have as its name. */
|
|
127
|
+
const COMMANDS = new Set([
|
|
128
|
+
"spawn",
|
|
129
|
+
"status",
|
|
130
|
+
"rename",
|
|
131
|
+
"send",
|
|
132
|
+
"wait",
|
|
133
|
+
"stop",
|
|
134
|
+
"list",
|
|
135
|
+
"attach",
|
|
136
|
+
"host",
|
|
137
|
+
"create",
|
|
138
|
+
"projects",
|
|
139
|
+
]);
|
|
140
|
+
/** Adds the project `name` with the real path of `dir` to `projects.json`. */
|
|
141
|
+
function createCommand(args) {
|
|
142
|
+
const [name, option, dir] = args;
|
|
143
|
+
if (args.length !== 3 || name === undefined || option !== "--cwd" || dir === undefined)
|
|
144
|
+
usage();
|
|
145
|
+
if (COMMANDS.has(name))
|
|
146
|
+
fail(`A project cannot have the name ${name}`);
|
|
147
|
+
process.stdout.write(`Created ${name} (${addProject(name, dir)})\n`);
|
|
148
|
+
}
|
|
149
|
+
function projectsCommand(args) {
|
|
150
|
+
if (args.length !== 0)
|
|
151
|
+
usage();
|
|
152
|
+
for (const [name, dir] of Object.entries(readProjects()))
|
|
153
|
+
process.stdout.write(`${name} ${dir}\n`);
|
|
154
|
+
process.stdout.write(`${MAIN_PROJECT}\n`);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Runs the host of `project` in this process with Pi's models. It exits at once when another host runs. The project
|
|
158
|
+
* comes only from `--project`, not from the env: a host can start from a child's shell of another project.
|
|
159
|
+
*/
|
|
160
|
+
async function hostCommand(args, project) {
|
|
161
|
+
if (args.length !== 0)
|
|
162
|
+
usage();
|
|
163
|
+
const { createAgentSessionServices } = await import("@earendil-works/pi-coding-agent");
|
|
164
|
+
const { runHost } = await import("./host.js");
|
|
165
|
+
const { modelRuntime } = await createAgentSessionServices({ cwd: process.cwd() });
|
|
166
|
+
const host = await runHost({
|
|
167
|
+
stateDir: statePaths(project).dir,
|
|
168
|
+
project,
|
|
169
|
+
cwd: projectDir(project),
|
|
170
|
+
models: modelRuntime,
|
|
171
|
+
});
|
|
172
|
+
if (host === undefined) {
|
|
173
|
+
process.stderr.write(`Another harness host runs (pid ${process.pid} exits)\n`);
|
|
174
|
+
process.exit(0);
|
|
175
|
+
}
|
|
176
|
+
for (const signal of ["SIGINT", "SIGTERM"])
|
|
177
|
+
process.once(signal, () => void host.close());
|
|
178
|
+
await host.closed;
|
|
179
|
+
process.exit(0);
|
|
180
|
+
}
|
|
181
|
+
/** Opens the viewer of a child in this terminal. */
|
|
182
|
+
async function attachCommand(args) {
|
|
183
|
+
const [handle] = args;
|
|
184
|
+
if (args.length !== 1 || handle === undefined)
|
|
185
|
+
usage();
|
|
186
|
+
await openViewer(handle);
|
|
187
|
+
}
|
|
188
|
+
/** Opens the viewer of the agent of the project `name`, which is `main` or a project, in this terminal. */
|
|
189
|
+
async function agentCommand(name, project) {
|
|
190
|
+
if (project !== undefined)
|
|
191
|
+
fail(`harness ${name} takes no --project`);
|
|
192
|
+
// The viewer's connection resolves the project from the env.
|
|
193
|
+
process.env.HARNESS_PROJECT = name;
|
|
194
|
+
await openViewer(AGENT_HANDLE);
|
|
195
|
+
}
|
|
196
|
+
async function openViewer(handle) {
|
|
197
|
+
const { ProcessTerminal } = await import("@earendil-works/pi-tui");
|
|
198
|
+
const { attach } = await import("./viewer.js");
|
|
199
|
+
process.exitCode = await attach(handle, { terminal: new ProcessTerminal(), env: process.env });
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Runs a command as a call of the `subagent` tool. The caller is `PI_SESSION_ID`, or `cli` outside Pi, and the defaults
|
|
203
|
+
* of a spawn come from `PI_PROVIDER`, `PI_MODEL` and `PI_REASONING_LEVEL`.
|
|
204
|
+
*/
|
|
205
|
+
async function main() {
|
|
206
|
+
const all = process.argv.slice(2);
|
|
207
|
+
// Only before the command, so a message or a prompt can contain the word.
|
|
208
|
+
let project;
|
|
209
|
+
if (all[0] === "--project") {
|
|
210
|
+
project = valueAfter(all, 0, "--project");
|
|
211
|
+
all.splice(0, 2);
|
|
212
|
+
}
|
|
213
|
+
const [command, ...args] = all;
|
|
214
|
+
// npm 12 runs no install script by default, so an installed CLI finishes the setup. A checkout runs `cli.ts`, and
|
|
215
|
+
// the host starts from a command that already did it.
|
|
216
|
+
if (import.meta.url.endsWith(".js") && command !== "host")
|
|
217
|
+
setupOnce(fileURLToPath(new URL("..", import.meta.url)));
|
|
218
|
+
if (command === "create")
|
|
219
|
+
return createCommand(args);
|
|
220
|
+
if (command === "projects")
|
|
221
|
+
return projectsCommand(args);
|
|
222
|
+
if (command === "host")
|
|
223
|
+
return hostCommand(args, checkProject(project ?? MAIN_PROJECT));
|
|
224
|
+
// A project alone opens its agent. Any other word goes to the usage.
|
|
225
|
+
if (command !== undefined &&
|
|
226
|
+
!COMMANDS.has(command) &&
|
|
227
|
+
args.length === 0 &&
|
|
228
|
+
(command === MAIN_PROJECT || Object.hasOwn(readProjects(), command))) {
|
|
229
|
+
return agentCommand(command, project);
|
|
230
|
+
}
|
|
231
|
+
// Every connection resolves the project from the env, so --project goes there.
|
|
232
|
+
if (project !== undefined)
|
|
233
|
+
process.env.HARNESS_PROJECT = checkProject(project);
|
|
234
|
+
if (command === "attach")
|
|
235
|
+
return attachCommand(args);
|
|
236
|
+
const { text } = await runTool(toolArgs(command, args), {
|
|
237
|
+
caller: process.env.PI_SESSION_ID || "cli",
|
|
238
|
+
callKey: randomUUID(),
|
|
239
|
+
provider: process.env.PI_PROVIDER,
|
|
240
|
+
model: process.env.PI_MODEL,
|
|
241
|
+
thinking: process.env.PI_REASONING_LEVEL || "medium",
|
|
242
|
+
cwd: process.cwd(),
|
|
243
|
+
send: sendToHost,
|
|
244
|
+
signal: undefined,
|
|
245
|
+
});
|
|
246
|
+
process.stdout.write(text);
|
|
247
|
+
}
|
|
248
|
+
main().catch((cause) => {
|
|
249
|
+
process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`);
|
|
250
|
+
process.exitCode = 1;
|
|
251
|
+
});
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type Socket } from "node:net";
|
|
2
|
+
import { type Replies, type Request, type WatchLine } from "./protocol.ts";
|
|
3
|
+
/**
|
|
4
|
+
* A connection to the host. Requests are answered in order, one reply line for each request line. After `watch`, the
|
|
5
|
+
* event lines of the watch come between the replies.
|
|
6
|
+
*/
|
|
7
|
+
export declare class Client {
|
|
8
|
+
#private;
|
|
9
|
+
/** The process ID of the host, from its `hello` reply. */
|
|
10
|
+
pid: number;
|
|
11
|
+
/** The project of the host. `connect` sets it. */
|
|
12
|
+
project: string;
|
|
13
|
+
constructor(socket: Socket);
|
|
14
|
+
request<T extends Request["type"]>(request: Extract<Request, {
|
|
15
|
+
type: T;
|
|
16
|
+
}>): Promise<Replies[T]>;
|
|
17
|
+
/**
|
|
18
|
+
* Sends the `watch` request of `handle`. Its `lines` are the events of the watch, and end when the connection closes.
|
|
19
|
+
* Other requests can follow on this client. Call it once.
|
|
20
|
+
*/
|
|
21
|
+
watch(handle: string): Promise<Replies["watch"] & {
|
|
22
|
+
lines: AsyncIterable<WatchLine>;
|
|
23
|
+
}>;
|
|
24
|
+
close(): void;
|
|
25
|
+
/** Rejects the pending requests with `cause` and closes the connection. The host then stops them. */
|
|
26
|
+
abort(cause: unknown): void;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A connection to the host of the project of `HARNESS_PROJECT` or the cwd (see `resolveProject`), after its `hello`.
|
|
30
|
+
* When no host listens and `start` is true, it starts one, detached, with its output in `host.log`, and tries to
|
|
31
|
+
* connect for 10 s, or until `signal` aborts. When no host listens and `start` is false, it is undefined.
|
|
32
|
+
*/
|
|
33
|
+
export declare function connect(options: ConnectOptions): Promise<Client | undefined>;
|
|
34
|
+
type ConnectOptions = {
|
|
35
|
+
readonly start: boolean;
|
|
36
|
+
readonly signal?: AbortSignal | undefined;
|
|
37
|
+
};
|
|
38
|
+
type OneShot<T extends Request["type"]> = Extract<Request, {
|
|
39
|
+
type: T;
|
|
40
|
+
}>;
|
|
41
|
+
/**
|
|
42
|
+
* Connects as `connect` does, sends `request`, and closes the connection. It is undefined when no host listens and
|
|
43
|
+
* `start` is false. When `signal` aborts, it rejects with the abort reason, and the host stops the request.
|
|
44
|
+
*/
|
|
45
|
+
export declare function request<T extends Request["type"]>(request: OneShot<T>, options: ConnectOptions & {
|
|
46
|
+
readonly start: true;
|
|
47
|
+
}): Promise<Replies[T]>;
|
|
48
|
+
export declare function request<T extends Request["type"]>(request: OneShot<T>, options: ConnectOptions): Promise<Replies[T] | undefined>;
|
|
49
|
+
export {};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, mkdirSync, openSync } from "node:fs";
|
|
3
|
+
import { createConnection } from "node:net";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { Value } from "typebox/value";
|
|
7
|
+
import { MAIN_PROJECT, resolveProject } from "./projects.js";
|
|
8
|
+
import { cliPath, onLines, PROTOCOL_VERSION, statePaths, } from "./protocol.js";
|
|
9
|
+
const START_TIMEOUT_MS = 10_000;
|
|
10
|
+
/** A line from the host: a reply, or an event of the watch. The value of a reply and the event are not checked here. */
|
|
11
|
+
const HostLine = Type.Union([
|
|
12
|
+
Type.Object({ ok: Type.Literal(true), value: Type.Optional(Type.Unknown()) }),
|
|
13
|
+
Type.Object({ ok: Type.Literal(false), error: Type.String() }),
|
|
14
|
+
Type.Object({ event: Type.Unknown() }),
|
|
15
|
+
]);
|
|
16
|
+
/**
|
|
17
|
+
* A connection to the host. Requests are answered in order, one reply line for each request line. After `watch`, the
|
|
18
|
+
* event lines of the watch come between the replies.
|
|
19
|
+
*/
|
|
20
|
+
export class Client {
|
|
21
|
+
#socket;
|
|
22
|
+
#pending = [];
|
|
23
|
+
#ended = false;
|
|
24
|
+
/** The events of the watch. Only `watch` sets it; otherwise an event is dropped. */
|
|
25
|
+
#lines;
|
|
26
|
+
/** The process ID of the host, from its `hello` reply. */
|
|
27
|
+
pid = 0;
|
|
28
|
+
/** The project of the host. `connect` sets it. */
|
|
29
|
+
project = MAIN_PROJECT;
|
|
30
|
+
constructor(socket) {
|
|
31
|
+
this.#socket = socket;
|
|
32
|
+
onLines(socket, (line) => {
|
|
33
|
+
const parsed = JSON.parse(line);
|
|
34
|
+
if (!Value.Check(HostLine, parsed)) {
|
|
35
|
+
this.#pending.shift()?.reject(new Error(`The harness host sent an invalid reply: ${line}`));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if ("event" in parsed) {
|
|
39
|
+
this.#lines?.push(parsed.event);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const waiter = this.#pending.shift();
|
|
43
|
+
if (waiter === undefined)
|
|
44
|
+
return;
|
|
45
|
+
if (parsed.ok)
|
|
46
|
+
waiter.resolve(parsed.value);
|
|
47
|
+
else
|
|
48
|
+
waiter.reject(new Error(parsed.error));
|
|
49
|
+
});
|
|
50
|
+
socket.on("error", () => this.#end());
|
|
51
|
+
socket.on("close", () => this.#end());
|
|
52
|
+
}
|
|
53
|
+
request(request) {
|
|
54
|
+
if (this.#ended)
|
|
55
|
+
return Promise.reject(new Error("The harness host closed the connection"));
|
|
56
|
+
const reply = Promise.withResolvers();
|
|
57
|
+
this.#pending.push(reply);
|
|
58
|
+
this.#socket.write(`${JSON.stringify(request)}\n`);
|
|
59
|
+
// SAFETY: the host answers each request line in order with the value of `Replies` for its type, and `connect`
|
|
60
|
+
// refuses a host of another protocol version.
|
|
61
|
+
return reply.promise;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Sends the `watch` request of `handle`. Its `lines` are the events of the watch, and end when the connection closes.
|
|
65
|
+
* Other requests can follow on this client. Call it once.
|
|
66
|
+
*/
|
|
67
|
+
async watch(handle) {
|
|
68
|
+
const lines = new Readable({ objectMode: true, read() { } });
|
|
69
|
+
this.#lines = lines;
|
|
70
|
+
const reply = await this.request({ type: "watch", handle });
|
|
71
|
+
return { ...reply, lines };
|
|
72
|
+
}
|
|
73
|
+
close() {
|
|
74
|
+
this.#socket.end();
|
|
75
|
+
}
|
|
76
|
+
/** Rejects the pending requests with `cause` and closes the connection. The host then stops them. */
|
|
77
|
+
abort(cause) {
|
|
78
|
+
this.#end(cause);
|
|
79
|
+
this.#socket.destroy();
|
|
80
|
+
}
|
|
81
|
+
#end(cause = new Error("The harness host closed the connection")) {
|
|
82
|
+
if (!this.#ended)
|
|
83
|
+
this.#lines?.push(null);
|
|
84
|
+
this.#ended = true;
|
|
85
|
+
for (const waiter of this.#pending.splice(0))
|
|
86
|
+
waiter.reject(cause);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A connection to the host of the project of `HARNESS_PROJECT` or the cwd (see `resolveProject`), after its `hello`.
|
|
91
|
+
* When no host listens and `start` is true, it starts one, detached, with its output in `host.log`, and tries to
|
|
92
|
+
* connect for 10 s, or until `signal` aborts. When no host listens and `start` is false, it is undefined.
|
|
93
|
+
*/
|
|
94
|
+
export async function connect(options) {
|
|
95
|
+
const project = resolveProject(process.env, process.cwd());
|
|
96
|
+
const paths = statePaths(project);
|
|
97
|
+
let socket = await tryConnect(paths.socket);
|
|
98
|
+
if (socket === undefined) {
|
|
99
|
+
if (!options.start)
|
|
100
|
+
return undefined;
|
|
101
|
+
startHost(project, paths.dir, paths.log);
|
|
102
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
103
|
+
while (socket === undefined) {
|
|
104
|
+
options.signal?.throwIfAborted();
|
|
105
|
+
if (Date.now() > deadline)
|
|
106
|
+
throw new Error(`Could not start the harness host; see ${paths.log}`);
|
|
107
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 100));
|
|
108
|
+
socket = await tryConnect(paths.socket);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const client = new Client(socket);
|
|
112
|
+
try {
|
|
113
|
+
const hello = await client.request({ type: "hello", version: PROTOCOL_VERSION });
|
|
114
|
+
// A stopped host restarts from the installed code, which is the newer side here, so only the client can catch up.
|
|
115
|
+
if (hello.version > PROTOCOL_VERSION) {
|
|
116
|
+
throw new Error(`This client uses harness protocol ${PROTOCOL_VERSION} and the host (pid ${hello.pid}) uses ${hello.version}. Reload Pi with /reload, or use the harness CLI of the host's install`);
|
|
117
|
+
}
|
|
118
|
+
if (hello.version !== PROTOCOL_VERSION) {
|
|
119
|
+
throw new Error(`The harness host (pid ${hello.pid}) uses protocol ${hello.version}, not ${PROTOCOL_VERSION}. The host is older: stop it with kill ${hello.pid} and run the command again`);
|
|
120
|
+
}
|
|
121
|
+
client.pid = hello.pid;
|
|
122
|
+
client.project = project;
|
|
123
|
+
return client;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
client.close();
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export async function request(request, options) {
|
|
131
|
+
const { signal } = options;
|
|
132
|
+
const client = await connect(options);
|
|
133
|
+
if (client === undefined)
|
|
134
|
+
return undefined;
|
|
135
|
+
const abort = () => client.abort(signal?.reason);
|
|
136
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
137
|
+
try {
|
|
138
|
+
signal?.throwIfAborted();
|
|
139
|
+
return await client.request(request);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
signal?.removeEventListener("abort", abort);
|
|
143
|
+
client.close();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** The connected socket, or undefined when nothing listens at `path`. */
|
|
147
|
+
function tryConnect(path) {
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
const socket = createConnection(path);
|
|
150
|
+
socket.once("connect", () => {
|
|
151
|
+
socket.removeAllListeners("error");
|
|
152
|
+
resolve(socket);
|
|
153
|
+
});
|
|
154
|
+
socket.once("error", (error) => {
|
|
155
|
+
if (error.code === "ENOENT" || error.code === "ECONNREFUSED")
|
|
156
|
+
resolve(undefined);
|
|
157
|
+
else
|
|
158
|
+
reject(error);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
function startHost(project, dir, log) {
|
|
163
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
164
|
+
const output = openSync(log, "a", 0o600);
|
|
165
|
+
try {
|
|
166
|
+
const cli = cliPath();
|
|
167
|
+
spawn(process.execPath, [cli, "--project", project, "host"], {
|
|
168
|
+
cwd: dir,
|
|
169
|
+
detached: true,
|
|
170
|
+
stdio: ["ignore", output, output],
|
|
171
|
+
}).unref();
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
closeSync(output);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ToolRegistration } from "@earendil-works/pi-durable";
|
|
2
|
+
/** The tools and sections that children get; a conversation selects it by default. */
|
|
3
|
+
export declare const ChildContext: import("@earendil-works/pi-durable").Extension<ToolRegistration | ToolRegistration<import("typebox").TObject<{
|
|
4
|
+
path: import("typebox").TString;
|
|
5
|
+
offset: import("typebox").TOptional<import("typebox").TNumber>;
|
|
6
|
+
limit: import("typebox").TOptional<import("typebox").TNumber>;
|
|
7
|
+
}>, import("@earendil-works/pi-durable/tools").ReadToolDetails> | ToolRegistration<import("typebox").TObject<{
|
|
8
|
+
path: import("typebox").TString;
|
|
9
|
+
edits: import("typebox").TArray<import("typebox").TObject<{
|
|
10
|
+
oldText: import("typebox").TString;
|
|
11
|
+
newText: import("typebox").TString;
|
|
12
|
+
}>>;
|
|
13
|
+
}>, import("@earendil-works/pi-durable/tools").EditToolDetails>>;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a child sees of its working directory, as a Pi session there sees it: the coding tools, and the system prompt
|
|
3
|
+
* sections with the project instructions, the skills and the working directory.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, statSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createFindTool, createGrepTool, createLsTool, DefaultPackageManager, formatSkillsForPrompt, getAgentDir, loadProjectContextFiles, loadSkills, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { defineExtension, defineTool, section, } from "@earendil-works/pi-durable";
|
|
9
|
+
import { createBashTool, createEditTool, createReadTool, createWriteTool } from "@earendil-works/pi-durable/tools";
|
|
10
|
+
import { savedTrust } from "./trust.js";
|
|
11
|
+
/** A Pi coding agent tool as a durable tool that runs in the calling conversation's directory. */
|
|
12
|
+
function piTool(create) {
|
|
13
|
+
const { name, description, parameters } = create(".");
|
|
14
|
+
return defineTool({
|
|
15
|
+
name,
|
|
16
|
+
description,
|
|
17
|
+
parameters,
|
|
18
|
+
replay: "safe",
|
|
19
|
+
execute: async (args, api, context) => {
|
|
20
|
+
const { cwd } = await api.agent(context);
|
|
21
|
+
if (cwd === undefined)
|
|
22
|
+
throw new Error(`${name} needs a working directory`);
|
|
23
|
+
const result = await create(cwd).execute(api.callId, args, context.abortSignal);
|
|
24
|
+
return { output: result.content };
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const resources = new Map();
|
|
29
|
+
/**
|
|
30
|
+
* The project instructions and skills of `cwd`, as Pi loads them at startup. Project skills load only for a project
|
|
31
|
+
* that Pi trusts now. They are loaded once per directory and trust. Missing packages are skipped, not installed. A
|
|
32
|
+
* load that fails gives no resources, with a warning, as Pi skips a resource it cannot load, and the next request
|
|
33
|
+
* tries again.
|
|
34
|
+
*/
|
|
35
|
+
async function resourcesOf(cwd) {
|
|
36
|
+
try {
|
|
37
|
+
const trusted = savedTrust(cwd);
|
|
38
|
+
const key = `${trusted}:${cwd}`;
|
|
39
|
+
let found = resources.get(key);
|
|
40
|
+
if (found === undefined) {
|
|
41
|
+
found = loadResources(cwd, trusted);
|
|
42
|
+
resources.set(key, found);
|
|
43
|
+
found.catch(() => resources.delete(key));
|
|
44
|
+
}
|
|
45
|
+
return await found;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
process.stderr.write(`warning: ${cwd}: ${error instanceof Error ? error.message : String(error)}; no project instructions or skills\n`);
|
|
49
|
+
return { context: [], skills: [] };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function loadResources(cwd, trusted) {
|
|
53
|
+
const agentDir = getAgentDir();
|
|
54
|
+
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: trusted });
|
|
55
|
+
const resolved = await new DefaultPackageManager({ cwd, agentDir, settingsManager }).resolve(async () => "skip");
|
|
56
|
+
const skillPaths = resolved.skills.filter(({ enabled }) => enabled).map(skillPath);
|
|
57
|
+
return {
|
|
58
|
+
context: loadProjectContextFiles({ cwd, agentDir }),
|
|
59
|
+
skills: loadSkills({ cwd, agentDir, skillPaths, includeDefaults: false }).skills,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** The path that Pi loads a resolved skill from: the `SKILL.md` of a discovered or packaged skill directory. */
|
|
63
|
+
function skillPath(resource) {
|
|
64
|
+
if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package")
|
|
65
|
+
return resource.path;
|
|
66
|
+
const file = join(resource.path, "SKILL.md");
|
|
67
|
+
try {
|
|
68
|
+
return statSync(resource.path).isDirectory() && existsSync(file) ? file : resource.path;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return resource.path;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const cwdOf = (input) => input.env?.cwd ?? input.agent.cwd;
|
|
75
|
+
/** The resources of the conversation's directory. */
|
|
76
|
+
async function inputResources(input) {
|
|
77
|
+
const cwd = cwdOf(input);
|
|
78
|
+
return cwd === undefined ? undefined : resourcesOf(cwd);
|
|
79
|
+
}
|
|
80
|
+
/** The tools and sections that children get; a conversation selects it by default. */
|
|
81
|
+
export const ChildContext = defineExtension({
|
|
82
|
+
name: "pi-subagent-context",
|
|
83
|
+
tools: [
|
|
84
|
+
createReadTool(),
|
|
85
|
+
piTool(createGrepTool),
|
|
86
|
+
piTool(createFindTool),
|
|
87
|
+
piTool(createLsTool),
|
|
88
|
+
createWriteTool(),
|
|
89
|
+
createEditTool(),
|
|
90
|
+
createBashTool(),
|
|
91
|
+
],
|
|
92
|
+
sections: [
|
|
93
|
+
section("project_context", async (input) => {
|
|
94
|
+
const files = (await inputResources(input))?.context ?? [];
|
|
95
|
+
if (files.length === 0)
|
|
96
|
+
return undefined;
|
|
97
|
+
return [
|
|
98
|
+
"Project-specific instructions and guidelines:",
|
|
99
|
+
...files.map(({ path, content }) => `<project_instructions path="${path}">\n${content}\n</project_instructions>`),
|
|
100
|
+
].join("\n\n");
|
|
101
|
+
}),
|
|
102
|
+
section("skills", async (input) => {
|
|
103
|
+
// As in Pi, skills need a tool that reads their files, and the hint names the first one offered.
|
|
104
|
+
const offered = input.agent.tools.map(({ name }) => name);
|
|
105
|
+
const callable = input.agent.callable.map(({ name }) => name);
|
|
106
|
+
const reader = ["read", "bash"].find((name) => offered.includes(name)) ??
|
|
107
|
+
(callable.includes("read") || callable.includes("bash") ? "indirect" : undefined);
|
|
108
|
+
if (reader === undefined)
|
|
109
|
+
return undefined;
|
|
110
|
+
const skills = (await inputResources(input))?.skills ?? [];
|
|
111
|
+
return formatSkillsForPrompt(skills, reader).trim() || undefined;
|
|
112
|
+
}),
|
|
113
|
+
section("cwd", (input) => cwdOf(input)?.replace(/\\/g, "/")),
|
|
114
|
+
],
|
|
115
|
+
});
|