@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/setup.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers the Pi extension of a globally installed package in Pi. The `postinstall` script and the CLI of the package
|
|
3
|
+
* run it, since npm 12 does not run install scripts by default. workflows uses it too, as `@vzlentin/harness/setup`.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { basename, delimiter, dirname, join, sep } from "node:path";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import { Value } from "typebox/value";
|
|
12
|
+
const PackageJson = Type.Object({
|
|
13
|
+
name: Type.String(),
|
|
14
|
+
version: Type.String(),
|
|
15
|
+
retiredPiPackages: Type.Optional(Type.Array(Type.String())),
|
|
16
|
+
});
|
|
17
|
+
const PiSettings = Type.Object({
|
|
18
|
+
packages: Type.Optional(Type.Array(Type.Union([Type.String(), Type.Object({ source: Type.String() })]))),
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* Registers `<packageDir>/pi` in Pi when `packageDir` is a top-level global npm install. First it runs `pi remove` for
|
|
22
|
+
* each Pi package of the `retiredPiPackages` field of the package's `package.json` that Pi's settings list. It writes
|
|
23
|
+
* its messages to stderr, and it does not throw for a Pi failure: it writes a warning with the command to run.
|
|
24
|
+
* It writes the stamp that `setupOnce` reads unless a `pi` command failed, so the next run tries again. Without `pi`
|
|
25
|
+
* on PATH it writes the stamp, so a user without Pi gets the hint once per version.
|
|
26
|
+
*/
|
|
27
|
+
export function setup(packageDir) {
|
|
28
|
+
const installed = readInstalled(packageDir);
|
|
29
|
+
if (installed === undefined || !isTopLevelGlobal(installed))
|
|
30
|
+
return;
|
|
31
|
+
if (registerExtension(installed))
|
|
32
|
+
writeStamp(installed);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* `setup` once per version and path of the package: it does nothing when the stamp of `setup` exists. It never
|
|
36
|
+
* throws; an error is a warning.
|
|
37
|
+
*/
|
|
38
|
+
export function setupOnce(packageDir) {
|
|
39
|
+
try {
|
|
40
|
+
const installed = readInstalled(packageDir);
|
|
41
|
+
if (installed === undefined || !isTopLevelGlobal(installed) || existsSync(stampPath(installed)))
|
|
42
|
+
return;
|
|
43
|
+
if (registerExtension(installed))
|
|
44
|
+
writeStamp(installed);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
process.stderr.write(`harness: setup failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* True when `dir` is `<global root>/<name>`: `<root>` is a `node_modules` folder in `lib` (in the prefix on Windows),
|
|
52
|
+
* and has no other `node_modules` above it. A copy nested in another package is not top-level.
|
|
53
|
+
*/
|
|
54
|
+
function isTopLevelGlobal({ dir, json }) {
|
|
55
|
+
const name = json.name.split("/").join(sep);
|
|
56
|
+
if (!dir.endsWith(`${sep}${name}`))
|
|
57
|
+
return false;
|
|
58
|
+
const root = dir.slice(0, -name.length - 1);
|
|
59
|
+
const segments = root.split(sep);
|
|
60
|
+
if (segments.at(-1) !== "node_modules" || segments.indexOf("node_modules") !== segments.length - 1)
|
|
61
|
+
return false;
|
|
62
|
+
return process.platform === "win32" || segments.at(-2) === "lib";
|
|
63
|
+
}
|
|
64
|
+
function readInstalled(packageDir) {
|
|
65
|
+
const dir = realpathSync(packageDir);
|
|
66
|
+
const json = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
67
|
+
return Value.Check(PackageJson, json) ? { dir, json } : undefined;
|
|
68
|
+
}
|
|
69
|
+
/** The stamp of `setup`: `$XDG_STATE_HOME/harness/.setup/<hash of path and version>`. */
|
|
70
|
+
function stampPath({ dir, json }) {
|
|
71
|
+
const hash = createHash("sha256").update(`${dir}\n${json.version}`).digest("hex");
|
|
72
|
+
return join(process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "harness", ".setup", hash);
|
|
73
|
+
}
|
|
74
|
+
function writeStamp(installed) {
|
|
75
|
+
const path = stampPath(installed);
|
|
76
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
77
|
+
writeFileSync(path, `${installed.dir}\n${installed.json.version}\n`);
|
|
78
|
+
}
|
|
79
|
+
/** False when a `pi` command failed. */
|
|
80
|
+
function registerExtension({ dir, json }) {
|
|
81
|
+
const label = basename(json.name);
|
|
82
|
+
const extension = join(dir, "pi");
|
|
83
|
+
const later = `To add the Pi extension of ${label}, run: pi install ${extension}`;
|
|
84
|
+
const listed = piPackages();
|
|
85
|
+
for (const retired of json.retiredPiPackages ?? []) {
|
|
86
|
+
for (const source of listed.filter((item) => item === retired || item.startsWith(`${retired}@`))) {
|
|
87
|
+
process.stderr.write(`${label}: removing ${source} from Pi, which ${json.name} replaces\n`);
|
|
88
|
+
if (!runPi(label, ["remove", source], `To remove it, run: pi remove ${source}`))
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return runPi(label, ["install", extension], later);
|
|
93
|
+
}
|
|
94
|
+
/** Runs `pi`, with a warning when it is missing or fails. False when it failed; true when it worked or is missing. */
|
|
95
|
+
function runPi(label, args, later) {
|
|
96
|
+
const result = spawnSync("pi", args, {
|
|
97
|
+
env: { ...process.env, PATH: userPath() },
|
|
98
|
+
stdio: ["ignore", 2, 2],
|
|
99
|
+
timeout: 120_000,
|
|
100
|
+
});
|
|
101
|
+
if (result.error !== undefined && "code" in result.error && result.error.code === "ENOENT") {
|
|
102
|
+
process.stderr.write(`${label}: pi is not on PATH. ${later}\n`);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
if (result.error !== undefined || result.status !== 0) {
|
|
106
|
+
const reason = result.error?.message ?? `exit ${result.status ?? result.signal}`;
|
|
107
|
+
process.stderr.write(`${label}: pi ${args[0]} failed (${reason}). ${later}\n`);
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
/** The `PATH` without the `node_modules/.bin` folders that npm adds: pi-coding-agent there has its own `pi`. */
|
|
113
|
+
function userPath() {
|
|
114
|
+
return (process.env.PATH ?? "")
|
|
115
|
+
.split(delimiter)
|
|
116
|
+
.filter((entry) => !entry.endsWith(join("node_modules", ".bin")))
|
|
117
|
+
.join(delimiter);
|
|
118
|
+
}
|
|
119
|
+
/** The sources of the packages in Pi's global settings, or none when the file is missing or not valid. */
|
|
120
|
+
function piPackages() {
|
|
121
|
+
const env = process.env.PI_CODING_AGENT_DIR;
|
|
122
|
+
const agentDir = env ? env.replace(/^~(?=$|\/)/, homedir()) : join(homedir(), ".pi", "agent");
|
|
123
|
+
let settings;
|
|
124
|
+
try {
|
|
125
|
+
settings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf8"));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
if (!Value.Check(PiSettings, settings))
|
|
131
|
+
return [];
|
|
132
|
+
return (settings.packages ?? []).map((item) => (Value.Check(Type.String(), item) ? item : item.source));
|
|
133
|
+
}
|
package/dist/tasks.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AnyTask, Conversation, Extension, Harness } from "@earendil-works/pi-durable";
|
|
2
|
+
import type { Request, TaskInfo } from "./protocol.ts";
|
|
3
|
+
/** What the task requests use of the host. */
|
|
4
|
+
export type TaskHost = {
|
|
5
|
+
readonly harness: Harness;
|
|
6
|
+
/** The root conversation, which owns the started tasks. */
|
|
7
|
+
readonly root: Conversation;
|
|
8
|
+
/** The tasks that `start` can create, from `startableTasks`. */
|
|
9
|
+
readonly startable: Extension;
|
|
10
|
+
/** Aborted when the connection of the request closes. */
|
|
11
|
+
readonly signal: AbortSignal;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The extension of the tasks of the entries, which is the list of the kinds that `start` can create. The
|
|
15
|
+
* registry refuses a task name that two entries give.
|
|
16
|
+
*/
|
|
17
|
+
export declare function startableTasks(tasks: readonly AnyTask[]): Extension;
|
|
18
|
+
/**
|
|
19
|
+
* Starts the task of `request.key` once. It is a background task of the root conversation, so an abort of the project
|
|
20
|
+
* agent's turn does not reach it. The key is written in the commit that creates the task, so a start after a crash
|
|
21
|
+
* finds it.
|
|
22
|
+
*/
|
|
23
|
+
export declare function startTask(host: TaskHost, request: Extract<Request, {
|
|
24
|
+
type: "start";
|
|
25
|
+
}>): Promise<TaskInfo>;
|
|
26
|
+
/** The info of the task that `start` created for `key`. */
|
|
27
|
+
export declare function taskInfo(harness: Harness, key: string): Promise<TaskInfo>;
|
|
28
|
+
/** Waits until the task of `key` is terminal, or the connection closes, and gives its info with the outcome. */
|
|
29
|
+
export declare function waitTask(host: TaskHost, key: string): Promise<TaskInfo>;
|
|
30
|
+
/** Aborts the task of `key`. The abort reaches the conversations that the task owns, and their turns. */
|
|
31
|
+
export declare function abortTask(harness: Harness, key: string): Promise<TaskInfo>;
|
package/dist/tasks.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context";
|
|
2
|
+
import * as durable from "@earendil-works/pi-durable";
|
|
3
|
+
const context = BACKGROUND_CONTEXT;
|
|
4
|
+
/** The tasks that `start` created, by client key. A key is never removed. */
|
|
5
|
+
const StartedTasks = durable.defineDoc({
|
|
6
|
+
kind: "harness.tasks",
|
|
7
|
+
version: 1,
|
|
8
|
+
scope: "session",
|
|
9
|
+
initial: () => ({ tasks: {} }),
|
|
10
|
+
});
|
|
11
|
+
/**
|
|
12
|
+
* The extension of the tasks of the entries, which is the list of the kinds that `start` can create. The
|
|
13
|
+
* registry refuses a task name that two entries give.
|
|
14
|
+
*/
|
|
15
|
+
export function startableTasks(tasks) {
|
|
16
|
+
return durable.defineExtension({ name: "harness-entry-tasks", tasks });
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Starts the task of `request.key` once. It is a background task of the root conversation, so an abort of the project
|
|
20
|
+
* agent's turn does not reach it. The key is written in the commit that creates the task, so a start after a crash
|
|
21
|
+
* finds it.
|
|
22
|
+
*/
|
|
23
|
+
export async function startTask(host, request) {
|
|
24
|
+
await host.root.commit(async (tx) => {
|
|
25
|
+
const state = await tx.doc(StartedTasks);
|
|
26
|
+
if (Object.hasOwn(state.tasks, request.key))
|
|
27
|
+
return;
|
|
28
|
+
const task = startableTask(host.startable, request.kind);
|
|
29
|
+
if (request.version !== undefined && task.definition.version !== request.version) {
|
|
30
|
+
throw new Error(`This host has version ${task.definition.version} of ${request.kind}, not ${request.version}: stop the host so it starts again with the installed version`);
|
|
31
|
+
}
|
|
32
|
+
state.tasks[request.key] = await tx.createTask(task, request.input, {
|
|
33
|
+
ownership: { kind: "conversation" },
|
|
34
|
+
background: true,
|
|
35
|
+
});
|
|
36
|
+
}, context);
|
|
37
|
+
return taskInfo(host.harness, request.key);
|
|
38
|
+
}
|
|
39
|
+
/** The info of the task that `start` created for `key`. */
|
|
40
|
+
export async function taskInfo(harness, key) {
|
|
41
|
+
const id = await taskId(harness, key);
|
|
42
|
+
const record = await harness.getTask(id, context);
|
|
43
|
+
if (record === undefined)
|
|
44
|
+
throw new Error(`Missing task ${id}`);
|
|
45
|
+
const info = { key, id, kind: record.kind, version: record.version, status: record.state.status };
|
|
46
|
+
return record.state.status === "terminal" ? { ...info, outcome: record.state.outcome } : info;
|
|
47
|
+
}
|
|
48
|
+
/** Waits until the task of `key` is terminal, or the connection closes, and gives its info with the outcome. */
|
|
49
|
+
export async function waitTask(host, key) {
|
|
50
|
+
await host.harness.waitForTask(await taskId(host.harness, key), withAbortSignal(host.signal, context));
|
|
51
|
+
return taskInfo(host.harness, key);
|
|
52
|
+
}
|
|
53
|
+
/** Aborts the task of `key`. The abort reaches the conversations that the task owns, and their turns. */
|
|
54
|
+
export async function abortTask(harness, key) {
|
|
55
|
+
await harness.abortTask(await taskId(harness, key), context);
|
|
56
|
+
return taskInfo(harness, key);
|
|
57
|
+
}
|
|
58
|
+
/** The ID of the task that `start` created for `key`. */
|
|
59
|
+
async function taskId(harness, key) {
|
|
60
|
+
const state = await harness.snapshot(StartedTasks, context);
|
|
61
|
+
const id = state != null && Object.hasOwn(state.tasks, key) ? state.tasks[key] : undefined;
|
|
62
|
+
if (id === undefined)
|
|
63
|
+
throw new Error(`Unknown task: ${key}`);
|
|
64
|
+
return id;
|
|
65
|
+
}
|
|
66
|
+
/** The task of `kind` in `startable`. The entries give erased tasks, so this is where they get the type of `createTask`. */
|
|
67
|
+
function startableTask(startable, kind) {
|
|
68
|
+
const task = startable.tasks?.find((found) => found.definition.name === kind);
|
|
69
|
+
if (task === undefined)
|
|
70
|
+
throw new Error(`Unknown task kind: ${kind}`);
|
|
71
|
+
// SAFETY: `AnyTask` is a `Task` with its types erased. The store keeps the input, the phase state and the result of
|
|
72
|
+
// every task as JSON.
|
|
73
|
+
return task;
|
|
74
|
+
}
|
package/dist/theme.d.ts
ADDED
package/dist/theme.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
// Pi gives its theme object only to extensions. Pi's cards color with the theme that `initTheme()` sets in this module,
|
|
10
|
+
// so the viewer loads the same module by its path to color its own lines the same way. The path is not in the `exports`
|
|
11
|
+
// of pi-coding-agent, so it holds only for the pinned version 1.3.0.
|
|
12
|
+
// SAFETY: this module of pi-coding-agent 1.3.0, the version that package.json pins, exports the `theme` of Pi's cards.
|
|
13
|
+
const piTheme = (await import(__rewriteRelativeImportExtension(new URL("./modes/interactive/theme/theme.js", import.meta.resolve("@earendil-works/pi-coding-agent")).href)));
|
|
14
|
+
export function color(name, text) {
|
|
15
|
+
return piTheme.theme.fg(name, text);
|
|
16
|
+
}
|
|
17
|
+
export function bold(text) {
|
|
18
|
+
return piTheme.theme.bold(text);
|
|
19
|
+
}
|
package/dist/tool.d.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { Context } from "@earendil-works/chord/context";
|
|
2
|
+
import type * as Ai from "@earendil-works/pi-ai";
|
|
3
|
+
import type { Static } from "@earendil-works/pi-ai";
|
|
4
|
+
import type * as Durable from "@earendil-works/pi-durable";
|
|
5
|
+
import type { Extension, ToolExecutionApi } from "@earendil-works/pi-durable";
|
|
6
|
+
import { type Replies, type Request } from "./protocol.ts";
|
|
7
|
+
export declare const TOOL_DESCRIPTION = "Spawn and manage subagents: separate agents with their own context, for second opinions, independent investigations and delegated work.\n- spawn: start a child with `prompts` and `files`. It uses your provider, model and thinking level unless you give others. Give it a short `name`. It prints `Spawned <name> (<handle>)`. The handle is the short hex ID in parentheses, not the name.\n- status, wait, send, rename, stop: act on the child `handle`. `wait` waits until the child answers its last message (up to `timeout` seconds, default 1800) and gives the answer, after the earlier answers of the spawn and the sends that no `wait` gave. `stop` also stops the children the child spawned. `send` steers a busy child, or queues `message` with `followUp`.\n- list: your children.\nCalls in one message run one after another, in message order, so a `wait` after a `send` gives the answer to that message.\nChildren persist after a turn. Stop each child when you no longer need it.";
|
|
8
|
+
/** The parameters and the output schema of the tool, built with the `Type` of the caller's pi-ai. */
|
|
9
|
+
export declare function toolSchemas(Type: typeof Ai.Type): {
|
|
10
|
+
parameters: Ai.Type.TObject<{
|
|
11
|
+
action: Ai.Type.TUnion<[Ai.Type.TLiteral<"spawn">, Ai.Type.TLiteral<"send">, Ai.Type.TLiteral<"wait">, Ai.Type.TLiteral<"status">, Ai.Type.TLiteral<"rename">, Ai.Type.TLiteral<"list">, Ai.Type.TLiteral<"stop">]>;
|
|
12
|
+
name: Ai.Type.TOptional<Ai.Type.TString>;
|
|
13
|
+
provider: Ai.Type.TOptional<Ai.Type.TString>;
|
|
14
|
+
model: Ai.Type.TOptional<Ai.Type.TString>;
|
|
15
|
+
thinking: Ai.Type.TOptional<Ai.Type.TString>;
|
|
16
|
+
cwd: Ai.Type.TOptional<Ai.Type.TString>;
|
|
17
|
+
tools: Ai.Type.TOptional<Ai.Type.TArray<Ai.Type.TString>>;
|
|
18
|
+
prompts: Ai.Type.TOptional<Ai.Type.TArray<Ai.Type.TString>>;
|
|
19
|
+
files: Ai.Type.TOptional<Ai.Type.TArray<Ai.Type.TString>>;
|
|
20
|
+
handle: Ai.Type.TOptional<Ai.Type.TString>;
|
|
21
|
+
message: Ai.Type.TOptional<Ai.Type.TString>;
|
|
22
|
+
followUp: Ai.Type.TOptional<Ai.Type.TBoolean>;
|
|
23
|
+
timeout: Ai.Type.TOptional<Ai.Type.TInteger>;
|
|
24
|
+
key: Ai.Type.TOptional<Ai.Type.TString>;
|
|
25
|
+
}>;
|
|
26
|
+
output: Ai.Type.TUnion<[Ai.Type.TObject<{
|
|
27
|
+
action: Ai.Type.TLiteral<"spawn">;
|
|
28
|
+
value: Ai.Type.TObject<{
|
|
29
|
+
handle: Ai.Type.TString;
|
|
30
|
+
name: Ai.Type.TOptional<Ai.Type.TString>;
|
|
31
|
+
state: Ai.Type.TUnion<[Ai.Type.TLiteral<"busy">, Ai.Type.TLiteral<"idle">, Ai.Type.TLiteral<"error">]>;
|
|
32
|
+
provider: Ai.Type.TString;
|
|
33
|
+
model: Ai.Type.TString;
|
|
34
|
+
thinking: Ai.Type.TString;
|
|
35
|
+
cwd: Ai.Type.TString;
|
|
36
|
+
}>;
|
|
37
|
+
}>, Ai.Type.TObject<{
|
|
38
|
+
action: Ai.Type.TLiteral<"send">;
|
|
39
|
+
value: Ai.Type.TObject<{
|
|
40
|
+
verb: Ai.Type.TString;
|
|
41
|
+
child: Ai.Type.TObject<{
|
|
42
|
+
handle: Ai.Type.TString;
|
|
43
|
+
name: Ai.Type.TOptional<Ai.Type.TString>;
|
|
44
|
+
state: Ai.Type.TUnion<[Ai.Type.TLiteral<"busy">, Ai.Type.TLiteral<"idle">, Ai.Type.TLiteral<"error">]>;
|
|
45
|
+
provider: Ai.Type.TString;
|
|
46
|
+
model: Ai.Type.TString;
|
|
47
|
+
thinking: Ai.Type.TString;
|
|
48
|
+
cwd: Ai.Type.TString;
|
|
49
|
+
}>;
|
|
50
|
+
}>;
|
|
51
|
+
}>, Ai.Type.TObject<{
|
|
52
|
+
action: Ai.Type.TLiteral<"wait">;
|
|
53
|
+
value: Ai.Type.TObject<{
|
|
54
|
+
answer: Ai.Type.TString;
|
|
55
|
+
}>;
|
|
56
|
+
}>, Ai.Type.TObject<{
|
|
57
|
+
action: Ai.Type.TLiteral<"status">;
|
|
58
|
+
value: Ai.Type.TObject<{
|
|
59
|
+
handle: Ai.Type.TString;
|
|
60
|
+
name: Ai.Type.TOptional<Ai.Type.TString>;
|
|
61
|
+
state: Ai.Type.TUnion<[Ai.Type.TLiteral<"busy">, Ai.Type.TLiteral<"idle">, Ai.Type.TLiteral<"error">]>;
|
|
62
|
+
provider: Ai.Type.TString;
|
|
63
|
+
model: Ai.Type.TString;
|
|
64
|
+
thinking: Ai.Type.TString;
|
|
65
|
+
cwd: Ai.Type.TString;
|
|
66
|
+
}>;
|
|
67
|
+
}>, Ai.Type.TObject<{
|
|
68
|
+
action: Ai.Type.TLiteral<"rename">;
|
|
69
|
+
value: Ai.Type.TObject<{
|
|
70
|
+
handle: Ai.Type.TString;
|
|
71
|
+
name: Ai.Type.TOptional<Ai.Type.TString>;
|
|
72
|
+
state: Ai.Type.TUnion<[Ai.Type.TLiteral<"busy">, Ai.Type.TLiteral<"idle">, Ai.Type.TLiteral<"error">]>;
|
|
73
|
+
provider: Ai.Type.TString;
|
|
74
|
+
model: Ai.Type.TString;
|
|
75
|
+
thinking: Ai.Type.TString;
|
|
76
|
+
cwd: Ai.Type.TString;
|
|
77
|
+
}>;
|
|
78
|
+
}>, Ai.Type.TObject<{
|
|
79
|
+
action: Ai.Type.TLiteral<"list">;
|
|
80
|
+
value: Ai.Type.TArray<Ai.Type.TObject<{
|
|
81
|
+
handle: Ai.Type.TString;
|
|
82
|
+
name: Ai.Type.TOptional<Ai.Type.TString>;
|
|
83
|
+
state: Ai.Type.TUnion<[Ai.Type.TLiteral<"busy">, Ai.Type.TLiteral<"idle">, Ai.Type.TLiteral<"error">]>;
|
|
84
|
+
provider: Ai.Type.TString;
|
|
85
|
+
model: Ai.Type.TString;
|
|
86
|
+
thinking: Ai.Type.TString;
|
|
87
|
+
cwd: Ai.Type.TString;
|
|
88
|
+
}>>;
|
|
89
|
+
}>, Ai.Type.TObject<{
|
|
90
|
+
action: Ai.Type.TLiteral<"stop">;
|
|
91
|
+
value: Ai.Type.TObject<{
|
|
92
|
+
handle: Ai.Type.TString;
|
|
93
|
+
name: Ai.Type.TOptional<Ai.Type.TString>;
|
|
94
|
+
}>;
|
|
95
|
+
}>]>;
|
|
96
|
+
};
|
|
97
|
+
type Schemas = ReturnType<typeof toolSchemas>;
|
|
98
|
+
export type ToolArgs = Static<Schemas["parameters"]>;
|
|
99
|
+
/** The result of a call: the text for the model, the same as the CLI output, and the host's reply for programs. */
|
|
100
|
+
export type ToolResult = {
|
|
101
|
+
readonly text: string;
|
|
102
|
+
readonly data: Static<Schemas["output"]>;
|
|
103
|
+
};
|
|
104
|
+
/** Sends one request to the host and gives its reply. */
|
|
105
|
+
export type Send = <T extends Request["type"]>(request: Extract<Request, {
|
|
106
|
+
type: T;
|
|
107
|
+
}>, signal: AbortSignal) => Promise<Replies[T]>;
|
|
108
|
+
/** Sends through the host's socket, and starts the host when none runs. */
|
|
109
|
+
export declare const sendToHost: Send;
|
|
110
|
+
/** What a tool call gets from its adapter. */
|
|
111
|
+
export type ToolScope = {
|
|
112
|
+
/** The caller that owns the children. */
|
|
113
|
+
readonly caller: string;
|
|
114
|
+
/** The identity of the call. Without `key`, it is the key of a spawn or send. */
|
|
115
|
+
readonly callKey: string;
|
|
116
|
+
readonly provider: string | undefined;
|
|
117
|
+
readonly model: string | undefined;
|
|
118
|
+
readonly thinking: string;
|
|
119
|
+
/** Relative `cwd` and `files` paths resolve against it. A caller without one cannot spawn. */
|
|
120
|
+
readonly cwd: string | undefined;
|
|
121
|
+
readonly send: Send;
|
|
122
|
+
readonly signal: AbortSignal | undefined;
|
|
123
|
+
};
|
|
124
|
+
/** Runs one call of the tool. */
|
|
125
|
+
export declare function runTool(args: ToolArgs, scope: ToolScope): Promise<ToolResult>;
|
|
126
|
+
/** How a host reaches the harness host. */
|
|
127
|
+
export type DurableScope = {
|
|
128
|
+
/** The caller that owns the children of the calling conversation. */
|
|
129
|
+
caller(api: ToolExecutionApi, context: Context): Promise<string>;
|
|
130
|
+
readonly send: Send;
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* The pi-durable extension with the `subagent` tool. A call that runs again after a restart has the same task, so its
|
|
134
|
+
* spawn or send has the same request ID and does not repeat.
|
|
135
|
+
*/
|
|
136
|
+
export declare function subagentTool(durable: typeof Durable, ai: typeof Ai, scope: DurableScope): Extension;
|
|
137
|
+
export {};
|
package/dist/tool.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { request } from "./client.js";
|
|
4
|
+
import { childLine, isValidRunName, runDisplayName } from "./protocol.js";
|
|
5
|
+
const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
6
|
+
/** The signal of a call that its adapter cannot abort. */
|
|
7
|
+
const NO_ABORT = new AbortController().signal;
|
|
8
|
+
export const TOOL_DESCRIPTION = `Spawn and manage subagents: separate agents with their own context, for second opinions, independent investigations and delegated work.
|
|
9
|
+
- spawn: start a child with \`prompts\` and \`files\`. It uses your provider, model and thinking level unless you give others. Give it a short \`name\`. It prints \`Spawned <name> (<handle>)\`. The handle is the short hex ID in parentheses, not the name.
|
|
10
|
+
- status, wait, send, rename, stop: act on the child \`handle\`. \`wait\` waits until the child answers its last message (up to \`timeout\` seconds, default 1800) and gives the answer, after the earlier answers of the spawn and the sends that no \`wait\` gave. \`stop\` also stops the children the child spawned. \`send\` steers a busy child, or queues \`message\` with \`followUp\`.
|
|
11
|
+
- list: your children.
|
|
12
|
+
Calls in one message run one after another, in message order, so a \`wait\` after a \`send\` gives the answer to that message.
|
|
13
|
+
Children persist after a turn. Stop each child when you no longer need it.`;
|
|
14
|
+
/** The parameters and the output schema of the tool, built with the `Type` of the caller's pi-ai. */
|
|
15
|
+
export function toolSchemas(Type) {
|
|
16
|
+
const text = (description) => Type.Optional(Type.String({ description }));
|
|
17
|
+
const texts = (description) => Type.Optional(Type.Array(Type.String(), { description }));
|
|
18
|
+
const parameters = Type.Object({
|
|
19
|
+
action: Type.Union([
|
|
20
|
+
Type.Literal("spawn"),
|
|
21
|
+
Type.Literal("send"),
|
|
22
|
+
Type.Literal("wait"),
|
|
23
|
+
Type.Literal("status"),
|
|
24
|
+
Type.Literal("rename"),
|
|
25
|
+
Type.Literal("list"),
|
|
26
|
+
Type.Literal("stop"),
|
|
27
|
+
], { description: "What to do" }),
|
|
28
|
+
name: text("spawn, rename: the child's name, 1 to 64 characters"),
|
|
29
|
+
provider: text("spawn: the provider; default yours"),
|
|
30
|
+
model: text("spawn: the model; default yours"),
|
|
31
|
+
thinking: text("spawn: off, minimal, low, medium, high, xhigh or max; default yours"),
|
|
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"),
|
|
34
|
+
prompts: texts("spawn: the task, in parts"),
|
|
35
|
+
files: texts("spawn: files whose text goes before the task"),
|
|
36
|
+
handle: text("send, wait, status, rename, stop: the hex ID that spawn prints in parentheses, not the name"),
|
|
37
|
+
message: text("send: the message"),
|
|
38
|
+
followUp: Type.Optional(Type.Boolean({ description: "send: queue after the current answer, not a steer" })),
|
|
39
|
+
timeout: Type.Optional(Type.Integer({ minimum: 1, description: "wait: seconds; default 1800" })),
|
|
40
|
+
key: text("spawn, send: a key that makes a repeated call do nothing; default the call"),
|
|
41
|
+
});
|
|
42
|
+
const child = Type.Object({
|
|
43
|
+
handle: Type.String(),
|
|
44
|
+
name: Type.Optional(Type.String()),
|
|
45
|
+
state: Type.Union([Type.Literal("busy"), Type.Literal("idle"), Type.Literal("error")]),
|
|
46
|
+
provider: Type.String(),
|
|
47
|
+
model: Type.String(),
|
|
48
|
+
thinking: Type.String(),
|
|
49
|
+
cwd: Type.String(),
|
|
50
|
+
});
|
|
51
|
+
const result = (action, value) => Type.Object({ action: Type.Literal(action), value });
|
|
52
|
+
const output = Type.Union([
|
|
53
|
+
result("spawn", child),
|
|
54
|
+
result("send", Type.Object({ verb: Type.String(), child })),
|
|
55
|
+
result("wait", Type.Object({ answer: Type.String() })),
|
|
56
|
+
result("status", child),
|
|
57
|
+
result("rename", child),
|
|
58
|
+
result("list", Type.Array(child)),
|
|
59
|
+
result("stop", Type.Object({ handle: Type.String(), name: Type.Optional(Type.String()) })),
|
|
60
|
+
]);
|
|
61
|
+
return { parameters, output };
|
|
62
|
+
}
|
|
63
|
+
/** Sends through the host's socket, and starts the host when none runs. */
|
|
64
|
+
export const sendToHost = (message, signal) => request(message, { start: true, signal });
|
|
65
|
+
/** Runs one call of the tool. */
|
|
66
|
+
export async function runTool(args, scope) {
|
|
67
|
+
const { action } = args;
|
|
68
|
+
const { send, caller } = scope;
|
|
69
|
+
const signal = scope.signal ?? NO_ABORT;
|
|
70
|
+
const requestId = `${caller}:${args.key ?? scope.callKey}`;
|
|
71
|
+
const handle = () => checkHandle(required(args.handle, "handle", action));
|
|
72
|
+
switch (action) {
|
|
73
|
+
case "spawn": {
|
|
74
|
+
const child = await send(spawnRequest(args, scope, requestId), signal);
|
|
75
|
+
return { text: `Spawned ${runDisplayName(child)}\nState: busy\n`, data: { action, value: child } };
|
|
76
|
+
}
|
|
77
|
+
case "send": {
|
|
78
|
+
const message = required(args.message?.trim(), "message", action);
|
|
79
|
+
const followUp = args.followUp === true;
|
|
80
|
+
const sent = await send({ type: "send", requestId, caller, handle: handle(), message, followUp }, signal);
|
|
81
|
+
return { text: `${sent.verb} ${runDisplayName(sent.child)}\n`, data: { action, value: sent } };
|
|
82
|
+
}
|
|
83
|
+
case "wait": {
|
|
84
|
+
const target = handle();
|
|
85
|
+
const waited = await send({ type: "wait", handle: target, timeout: args.timeout ?? 1800 }, signal);
|
|
86
|
+
return { text: `${target} finished\n\n${waited.answer}\n`, data: { action, value: waited } };
|
|
87
|
+
}
|
|
88
|
+
case "status": {
|
|
89
|
+
const child = await send({ type: "status", handle: handle() }, signal);
|
|
90
|
+
const text = `${runDisplayName(child)}: ${child.state} (${child.provider}/${child.model}, ${child.thinking})\n`;
|
|
91
|
+
return { text, data: { action, value: child } };
|
|
92
|
+
}
|
|
93
|
+
case "rename": {
|
|
94
|
+
const name = normalizeRunName(required(args.name, "name", action));
|
|
95
|
+
const child = await send({ type: "rename", caller, handle: handle(), name }, signal);
|
|
96
|
+
return { text: `Renamed ${runDisplayName(child)}\n`, data: { action, value: child } };
|
|
97
|
+
}
|
|
98
|
+
case "list": {
|
|
99
|
+
const children = await send({ type: "list", caller }, signal);
|
|
100
|
+
const text = children.length === 0 ? "No subagents\n" : children.map((child) => `${childLine(child)}\n`).join("");
|
|
101
|
+
return { text, data: { action, value: [...children] } };
|
|
102
|
+
}
|
|
103
|
+
case "stop": {
|
|
104
|
+
const stopped = await send({ type: "stop", caller, handle: handle() }, signal);
|
|
105
|
+
return { text: `Stopped ${runDisplayName(stopped)}\n`, data: { action, value: stopped } };
|
|
106
|
+
}
|
|
107
|
+
default:
|
|
108
|
+
throw new Error(`Unknown action: ${String(action)}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** The spawn request of `args`, with the defaults of `scope`. */
|
|
112
|
+
function spawnRequest(args, scope, requestId) {
|
|
113
|
+
const provider = args.provider ?? scope.provider;
|
|
114
|
+
const model = args.model ?? scope.model;
|
|
115
|
+
const thinking = args.thinking ?? scope.thinking;
|
|
116
|
+
if (!provider)
|
|
117
|
+
throw new Error("No provider specified and the caller has no model");
|
|
118
|
+
if (!model)
|
|
119
|
+
throw new Error("No model specified and the caller has no model");
|
|
120
|
+
if (!THINKING_LEVELS.has(thinking))
|
|
121
|
+
throw new Error(`Invalid thinking level: ${thinking}`);
|
|
122
|
+
const base = scope.cwd;
|
|
123
|
+
if (base === undefined)
|
|
124
|
+
throw new Error("spawn requires a caller with a working directory");
|
|
125
|
+
const name = args.name === undefined ? undefined : normalizeRunName(args.name);
|
|
126
|
+
const cwd = resolve(base, args.cwd ?? ".");
|
|
127
|
+
if (!existsSync(cwd))
|
|
128
|
+
throw new Error(`Working directory not found: ${cwd}`);
|
|
129
|
+
const tools = args.tools?.map((tool) => tool.trim()).filter((tool) => tool);
|
|
130
|
+
if (tools !== undefined && tools.length === 0)
|
|
131
|
+
throw new Error("tools requires at least one tool name");
|
|
132
|
+
const files = (args.files ?? []).map((file) => resolve(base, file));
|
|
133
|
+
for (const file of files)
|
|
134
|
+
if (!existsSync(file))
|
|
135
|
+
throw new Error(`File not found: ${file}`);
|
|
136
|
+
const prompts = args.prompts ?? [];
|
|
137
|
+
if (prompts.length === 0 && files.length === 0)
|
|
138
|
+
throw new Error("spawn requires at least one prompt or file");
|
|
139
|
+
// The first message: the text of each file, as Pi gives a file argument to the model, then `Task:` and the prompts.
|
|
140
|
+
let prompt = files.map(fileText).join("");
|
|
141
|
+
if (prompts.length > 0)
|
|
142
|
+
prompt += `Task:\n${prompts.join("\n\n")}`;
|
|
143
|
+
let request = {
|
|
144
|
+
type: "spawn",
|
|
145
|
+
requestId,
|
|
146
|
+
caller: scope.caller,
|
|
147
|
+
provider,
|
|
148
|
+
model,
|
|
149
|
+
callerThinking: scope.thinking,
|
|
150
|
+
cwd,
|
|
151
|
+
prompt,
|
|
152
|
+
};
|
|
153
|
+
if (name !== undefined)
|
|
154
|
+
request = { ...request, name };
|
|
155
|
+
if (args.thinking !== undefined)
|
|
156
|
+
request = { ...request, thinking: args.thinking };
|
|
157
|
+
if (tools !== undefined)
|
|
158
|
+
request = { ...request, tools };
|
|
159
|
+
return request;
|
|
160
|
+
}
|
|
161
|
+
/** The text of `file` in a `<file>` block. An empty file gives nothing. */
|
|
162
|
+
function fileText(file) {
|
|
163
|
+
if (statSync(file).size === 0)
|
|
164
|
+
return "";
|
|
165
|
+
return `<file name="${file}">\n${readFileSync(file, "utf8").replace(/^\uFEFF/, "")}\n</file>\n`;
|
|
166
|
+
}
|
|
167
|
+
function required(value, argument, action) {
|
|
168
|
+
if (value === undefined || value === "")
|
|
169
|
+
throw new Error(`${action} requires ${argument}`);
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
/** `value` without surrounding spaces. It must be a valid run name. */
|
|
173
|
+
function normalizeRunName(value) {
|
|
174
|
+
const name = value.trim();
|
|
175
|
+
if (!isValidRunName(name))
|
|
176
|
+
throw new Error("Subagent name must be a single line of 1 to 64 characters");
|
|
177
|
+
return name;
|
|
178
|
+
}
|
|
179
|
+
function checkHandle(handle) {
|
|
180
|
+
if (!/^[a-z0-9]+$/.test(handle))
|
|
181
|
+
throw new Error(`Invalid subagent handle: ${handle}`);
|
|
182
|
+
return handle;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The pi-durable extension with the `subagent` tool. A call that runs again after a restart has the same task, so its
|
|
186
|
+
* spawn or send has the same request ID and does not repeat.
|
|
187
|
+
*/
|
|
188
|
+
export function subagentTool(durable, ai, scope) {
|
|
189
|
+
const { parameters, output } = toolSchemas(ai.Type);
|
|
190
|
+
const tool = durable.defineTool({
|
|
191
|
+
name: "subagent",
|
|
192
|
+
description: TOOL_DESCRIPTION,
|
|
193
|
+
parameters,
|
|
194
|
+
structuredOutputSchema: output,
|
|
195
|
+
replay: "safe",
|
|
196
|
+
executionMode: "sequential",
|
|
197
|
+
async execute(args, api, context) {
|
|
198
|
+
const agent = await api.agent(context);
|
|
199
|
+
const { text, data } = await runTool(args, {
|
|
200
|
+
caller: await scope.caller(api, context),
|
|
201
|
+
callKey: String(api.taskId),
|
|
202
|
+
provider: agent.model?.provider,
|
|
203
|
+
model: agent.model?.modelId,
|
|
204
|
+
thinking: agent.thinkingLevel,
|
|
205
|
+
cwd: agent.cwd,
|
|
206
|
+
send: scope.send,
|
|
207
|
+
signal: context.abortSignal,
|
|
208
|
+
});
|
|
209
|
+
return { output: [{ type: "text", text }], structuredOutput: data };
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
return durable.defineExtension({ name: "pi-subagent", tools: [tool] });
|
|
213
|
+
}
|
package/dist/trust.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether Pi trusts the project in `cwd` with no one to ask: a directory without project resources is trusted, then a
|
|
3
|
+
* decision saved with `/trust` counts, then the `defaultProjectTrust` setting, where `ask` means not trusted.
|
|
4
|
+
*/
|
|
5
|
+
export declare function savedTrust(cwd: string): boolean;
|
package/dist/trust.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Pi's project trust, apart from the rest of the context, so that a Pi extension can read it without pi-durable. */
|
|
2
|
+
import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
/**
|
|
4
|
+
* Whether Pi trusts the project in `cwd` with no one to ask: a directory without project resources is trusted, then a
|
|
5
|
+
* decision saved with `/trust` counts, then the `defaultProjectTrust` setting, where `ask` means not trusted.
|
|
6
|
+
*/
|
|
7
|
+
export function savedTrust(cwd) {
|
|
8
|
+
if (!hasTrustRequiringProjectResources(cwd))
|
|
9
|
+
return true;
|
|
10
|
+
const agentDir = getAgentDir();
|
|
11
|
+
const decision = new ProjectTrustStore(agentDir).get(cwd);
|
|
12
|
+
if (decision !== null)
|
|
13
|
+
return decision;
|
|
14
|
+
return SettingsManager.create(cwd, agentDir, { projectTrusted: false }).getDefaultProjectTrust() === "always";
|
|
15
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the viewer reads from the documents of a durable conversation view: its live state, its inbox and the texts of
|
|
3
|
+
* its user messages.
|
|
4
|
+
*/
|
|
5
|
+
import type { JsonRepresentation } from "@earendil-works/chord";
|
|
6
|
+
import type { UserMessage } from "@earendil-works/pi-ai";
|
|
7
|
+
import { type ConversationDocToken, type ConversationView, type InboxState, type JsonObject, type LiveState } from "@earendil-works/pi-durable";
|
|
8
|
+
/** The built-in document of `token` in `view`, if the conversation has it. */
|
|
9
|
+
export declare function docOf<T extends JsonObject>(view: ConversationView, token: ConversationDocToken<T>): T | undefined;
|
|
10
|
+
export declare function liveOf(view: ConversationView): LiveState;
|
|
11
|
+
export declare function inboxOf(view: ConversationView): InboxState;
|
|
12
|
+
export declare function userText(content: JsonRepresentation<UserMessage["content"]>): string;
|
|
13
|
+
/** The queued steers and follow-ups, in inbox order. */
|
|
14
|
+
export declare function queued(inbox: InboxState): {
|
|
15
|
+
mode: "steer" | "followUp";
|
|
16
|
+
text: string;
|
|
17
|
+
}[];
|
|
18
|
+
/** What the conversation does now, as Pi's working indicator says it; empty when it does nothing. */
|
|
19
|
+
export declare function statusText(live: LiveState): string;
|