@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
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type * as Ai from "@earendil-works/pi-ai";
|
|
2
|
+
import type * as Durable from "@earendil-works/pi-durable";
|
|
3
|
+
/**
|
|
4
|
+
* The entry for a pi-durable host. The caller is `<store>:<conversation ID>`, where the store is a random ID that the store keeps,
|
|
5
|
+
* so conversations of two stores do not share children.
|
|
6
|
+
*/
|
|
7
|
+
export default function subagentEntry({ durable, ai }: {
|
|
8
|
+
readonly durable: typeof Durable;
|
|
9
|
+
readonly ai: typeof Ai;
|
|
10
|
+
}): {
|
|
11
|
+
extensions: Durable.Extension<Durable.ToolRegistration<Ai.TSchema, import("@earendil-works/chord").JsonValue>>[];
|
|
12
|
+
close: () => Promise<void>;
|
|
13
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `subagent` tool for hosts that run agents on pi-durable. The host passes its pi-durable and pi-ai modules, so
|
|
3
|
+
* this package loads no second copy of either.
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { sendToHost, subagentTool } from "./tool.js";
|
|
7
|
+
/**
|
|
8
|
+
* The entry for a pi-durable host. The caller is `<store>:<conversation ID>`, where the store is a random ID that the store keeps,
|
|
9
|
+
* so conversations of two stores do not share children.
|
|
10
|
+
*/
|
|
11
|
+
export default function subagentEntry({ durable, ai }) {
|
|
12
|
+
const Store = durable.defineDoc({
|
|
13
|
+
kind: "pi-subagent.store",
|
|
14
|
+
version: 1,
|
|
15
|
+
scope: "session",
|
|
16
|
+
initial: () => ({ id: "" }),
|
|
17
|
+
});
|
|
18
|
+
// The store ID never changes, so the first call reads it and the next calls reuse it.
|
|
19
|
+
let storeId;
|
|
20
|
+
const extension = subagentTool(durable, ai, {
|
|
21
|
+
caller: async (api, context) => {
|
|
22
|
+
storeId ??= await api.commit(async (tx) => {
|
|
23
|
+
const store = await tx.doc(Store);
|
|
24
|
+
if (store.id === "")
|
|
25
|
+
store.id = randomUUID();
|
|
26
|
+
return store.id;
|
|
27
|
+
}, context);
|
|
28
|
+
return `${storeId}:${api.conversationId}`;
|
|
29
|
+
},
|
|
30
|
+
send: sendToHost,
|
|
31
|
+
});
|
|
32
|
+
return { extensions: [extension], close: async () => { } };
|
|
33
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The file of the entries: `$XDG_CONFIG_HOME/harness/entries.json`, default `~/.config/harness/entries.json`. */
|
|
2
|
+
export declare function entriesPath(): string;
|
|
3
|
+
/** The entries, name to absolute module path. It is empty when the file does not exist. */
|
|
4
|
+
export declare function readEntries(): Record<string, string>;
|
|
5
|
+
/**
|
|
6
|
+
* Sets the module of the entry `name` to the absolute path `module`. It is true when it changed the file: a host that
|
|
7
|
+
* runs already loads the new module only after it starts again.
|
|
8
|
+
*/
|
|
9
|
+
export declare function setEntry(name: string, module: string): boolean;
|
package/dist/entries.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The entries that the host loads, by name. A package such as workflows sets its entry before it starts a task, so the
|
|
3
|
+
* host does not depend on Pi's package settings.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import { Value } from "typebox/value";
|
|
10
|
+
const Entries = Type.Record(Type.String(), Type.String());
|
|
11
|
+
/** The file of the entries: `$XDG_CONFIG_HOME/harness/entries.json`, default `~/.config/harness/entries.json`. */
|
|
12
|
+
export function entriesPath() {
|
|
13
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "harness", "entries.json");
|
|
14
|
+
}
|
|
15
|
+
/** The entries, name to absolute module path. It is empty when the file does not exist. */
|
|
16
|
+
export function readEntries() {
|
|
17
|
+
const path = entriesPath();
|
|
18
|
+
let text;
|
|
19
|
+
try {
|
|
20
|
+
text = readFileSync(path, "utf8");
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
24
|
+
return {};
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
const entries = JSON.parse(text);
|
|
28
|
+
if (!Value.Check(Entries, entries) || !Object.values(entries).every((module) => isAbsolute(module))) {
|
|
29
|
+
throw new Error(`${path} is not an object of entry names to absolute module paths`);
|
|
30
|
+
}
|
|
31
|
+
return entries;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Sets the module of the entry `name` to the absolute path `module`. It is true when it changed the file: a host that
|
|
35
|
+
* runs already loads the new module only after it starts again.
|
|
36
|
+
*/
|
|
37
|
+
export function setEntry(name, module) {
|
|
38
|
+
if (!isAbsolute(module))
|
|
39
|
+
throw new Error(`An entry module must be an absolute path: ${module}`);
|
|
40
|
+
const entries = readEntries();
|
|
41
|
+
if (entries[name] === module)
|
|
42
|
+
return false;
|
|
43
|
+
const path = entriesPath();
|
|
44
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
45
|
+
// A temporary file, then a rename, so a stop in between does not truncate the entries.
|
|
46
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
47
|
+
writeFileSync(temporary, `${JSON.stringify({ ...entries, [name]: module }, null, "\t")}\n`);
|
|
48
|
+
renameSync(temporary, path);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
package/dist/herdr.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type ChildInfo } from "./protocol.ts";
|
|
2
|
+
export type ViewerTarget = {
|
|
3
|
+
title(child: ChildInfo): string;
|
|
4
|
+
project: string[];
|
|
5
|
+
resume: string[];
|
|
6
|
+
};
|
|
7
|
+
export type AgentReporter = {
|
|
8
|
+
report(busy: boolean): void;
|
|
9
|
+
release(): Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* How the viewer of `handle` on `project` shows and resumes it: the title of its child, the `--project` words, and the
|
|
13
|
+
* words after them that open it again. The project agent is `harness <project>`, with no `--project`; a child is
|
|
14
|
+
* `harness --project <project> attach <handle>`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function viewerTarget(handle: string, project: string): ViewerTarget;
|
|
17
|
+
/**
|
|
18
|
+
* The words of the command that runs the viewer of `handle`, with this Node and this package's CLI, so it does not
|
|
19
|
+
* need the CLI on PATH. Herdr wants a plain command name as the first word of a resume command, so it starts with
|
|
20
|
+
* `env`.
|
|
21
|
+
*/
|
|
22
|
+
export declare function viewerCommand(handle: string, project: string): string[];
|
|
23
|
+
/**
|
|
24
|
+
* Runs `herdr` with `args` and gives its output. When `herdr` fails, it rejects with the stderr of `herdr`, or with the
|
|
25
|
+
* error of `execFile` when stderr is empty.
|
|
26
|
+
*/
|
|
27
|
+
export declare function herdr(args: readonly string[], env: NodeJS.ProcessEnv): Promise<string>;
|
|
28
|
+
/**
|
|
29
|
+
* Opens a Herdr tab that runs the viewer of `child`, on the project of `env` and this process's cwd. It rejects when a
|
|
30
|
+
* `herdr` call fails or gives no pane ID.
|
|
31
|
+
*/
|
|
32
|
+
export declare function openViewerTab(child: ChildInfo, env: NodeJS.ProcessEnv): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Reports the state of the agent `label` to the Herdr pane of this process, with the viewer of `handle` on `project` as
|
|
35
|
+
* the resume command. Both methods do nothing outside Herdr.
|
|
36
|
+
* The calls run one after another, so `--seq` reaches Herdr in order.
|
|
37
|
+
*/
|
|
38
|
+
export declare function agentReporter(env: NodeJS.ProcessEnv, label: string, handle: string, project: string): AgentReporter;
|
package/dist/herdr.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { Value } from "typebox/value";
|
|
4
|
+
import { resolveProject } from "./projects.js";
|
|
5
|
+
import { AGENT_HANDLE, childLabel, cliPath, runDisplayName } from "./protocol.js";
|
|
6
|
+
const HERDR_SOURCE = "custom:harness";
|
|
7
|
+
/** The part of the output of `herdr tab create` that has the pane of the tab. */
|
|
8
|
+
const TabCreated = Type.Object({ result: Type.Object({ root_pane: Type.Object({ pane_id: Type.String() }) }) });
|
|
9
|
+
/**
|
|
10
|
+
* How the viewer of `handle` on `project` shows and resumes it: the title of its child, the `--project` words, and the
|
|
11
|
+
* words after them that open it again. The project agent is `harness <project>`, with no `--project`; a child is
|
|
12
|
+
* `harness --project <project> attach <handle>`.
|
|
13
|
+
*/
|
|
14
|
+
export function viewerTarget(handle, project) {
|
|
15
|
+
// The agent's name already says what it is; a child's shows its handle too.
|
|
16
|
+
if (handle === AGENT_HANDLE)
|
|
17
|
+
return { title: childLabel, project: [], resume: [project] };
|
|
18
|
+
return { title: runDisplayName, project: ["--project", project], resume: ["attach", handle] };
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The words of the command that runs the viewer of `handle`, with this Node and this package's CLI, so it does not
|
|
22
|
+
* need the CLI on PATH. Herdr wants a plain command name as the first word of a resume command, so it starts with
|
|
23
|
+
* `env`.
|
|
24
|
+
*/
|
|
25
|
+
export function viewerCommand(handle, project) {
|
|
26
|
+
const cli = cliPath();
|
|
27
|
+
const target = viewerTarget(handle, project);
|
|
28
|
+
return ["env", process.execPath, cli, ...target.project, ...target.resume];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Runs `herdr` with `args` and gives its output. When `herdr` fails, it rejects with the stderr of `herdr`, or with the
|
|
32
|
+
* error of `execFile` when stderr is empty.
|
|
33
|
+
*/
|
|
34
|
+
export function herdr(args, env) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
execFile("herdr", args, { env }, (error, stdout, stderr) => {
|
|
37
|
+
if (error)
|
|
38
|
+
reject(new Error(stderr.trim() || error.message));
|
|
39
|
+
else
|
|
40
|
+
resolve(stdout);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Opens a Herdr tab that runs the viewer of `child`, on the project of `env` and this process's cwd. It rejects when a
|
|
46
|
+
* `herdr` call fails or gives no pane ID.
|
|
47
|
+
*/
|
|
48
|
+
export async function openViewerTab(child, env) {
|
|
49
|
+
const project = resolveProject(env, process.cwd());
|
|
50
|
+
// The tab must find the same host and projects as `statePaths()` in protocol.ts and `projectsPath()` in projects.ts.
|
|
51
|
+
const state = env.XDG_STATE_HOME;
|
|
52
|
+
const config = env.XDG_CONFIG_HOME;
|
|
53
|
+
const created = await herdr([
|
|
54
|
+
"tab",
|
|
55
|
+
"create",
|
|
56
|
+
"--cwd",
|
|
57
|
+
child.cwd,
|
|
58
|
+
"--label",
|
|
59
|
+
childLabel(child),
|
|
60
|
+
"--focus",
|
|
61
|
+
...(state ? ["--env", `XDG_STATE_HOME=${state}`] : []),
|
|
62
|
+
...(config ? ["--env", `XDG_CONFIG_HOME=${config}`] : []),
|
|
63
|
+
], env);
|
|
64
|
+
let output;
|
|
65
|
+
try {
|
|
66
|
+
output = JSON.parse(created);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
output = undefined;
|
|
70
|
+
}
|
|
71
|
+
if (!Value.Check(TabCreated, output))
|
|
72
|
+
throw new Error(`herdr tab create gave no pane ID: ${created.trim()}`);
|
|
73
|
+
const pane = output.result.root_pane.pane_id;
|
|
74
|
+
const viewer = viewerCommand(child.handle, project)
|
|
75
|
+
.map((word) => `'${word.replaceAll("'", "'\\''")}'`)
|
|
76
|
+
.join(" ");
|
|
77
|
+
await herdr(["pane", "run", pane, viewer], env);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Reports the state of the agent `label` to the Herdr pane of this process, with the viewer of `handle` on `project` as
|
|
81
|
+
* the resume command. Both methods do nothing outside Herdr.
|
|
82
|
+
* The calls run one after another, so `--seq` reaches Herdr in order.
|
|
83
|
+
*/
|
|
84
|
+
export function agentReporter(env, label, handle, project) {
|
|
85
|
+
const pane = env.HERDR_ENV ? env.HERDR_PANE_ID : undefined;
|
|
86
|
+
const resume = pane === undefined ? [] : ["--", ...viewerCommand(handle, project)];
|
|
87
|
+
let calls = Promise.resolve();
|
|
88
|
+
let seq = 0;
|
|
89
|
+
let reported;
|
|
90
|
+
const call = (command, options, resume) => {
|
|
91
|
+
if (pane === undefined)
|
|
92
|
+
return;
|
|
93
|
+
seq = Math.max(Date.now(), seq + 1);
|
|
94
|
+
const args = ["pane", command, "--source", HERDR_SOURCE, "--agent", label, ...options, "--seq", String(seq), pane];
|
|
95
|
+
// A failed report never stops the caller.
|
|
96
|
+
calls = calls.then(() => herdr([...args, ...resume], env)).catch(() => { });
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
report(busy) {
|
|
100
|
+
const state = busy ? "working" : "idle";
|
|
101
|
+
if (state === reported)
|
|
102
|
+
return;
|
|
103
|
+
reported = state;
|
|
104
|
+
call("report-agent", ["--state", state], resume);
|
|
105
|
+
},
|
|
106
|
+
async release() {
|
|
107
|
+
call("release-agent", [], []);
|
|
108
|
+
await calls;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
package/dist/host.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Models } from "@earendil-works/pi-ai/models";
|
|
2
|
+
export type Host = {
|
|
3
|
+
/** Resolves once the host has closed, by `close()`, the idle exit, or a store failure. */
|
|
4
|
+
readonly closed: Promise<void>;
|
|
5
|
+
close(): Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Runs the host of `project` in this process: the store in `stateDir`, the children's models from `models`, the
|
|
9
|
+
* project agent in `cwd`, and the socket. It is undefined when another host holds the lock.
|
|
10
|
+
*/
|
|
11
|
+
export declare function runHost(options: {
|
|
12
|
+
readonly stateDir: string;
|
|
13
|
+
readonly project: string;
|
|
14
|
+
readonly cwd: string;
|
|
15
|
+
readonly models: Models;
|
|
16
|
+
/** For tests: the idle time before the host exits. Default 5 minutes. */
|
|
17
|
+
readonly idleExitMs?: number;
|
|
18
|
+
}): Promise<Host | undefined>;
|