@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/picker.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { ModelInfo } from "./protocol.ts";
|
|
3
|
+
/**
|
|
4
|
+
* A filter over a list of models, fuzzy as Pi's model selector: typing narrows the list and keeps the selected model
|
|
5
|
+
* when it still matches, ↑↓ move, Enter picks, Esc closes.
|
|
6
|
+
*/
|
|
7
|
+
export declare class ModelPicker implements Component {
|
|
8
|
+
private readonly input;
|
|
9
|
+
private readonly items;
|
|
10
|
+
private readonly modelOf;
|
|
11
|
+
private readonly onSelect;
|
|
12
|
+
private readonly onCancel;
|
|
13
|
+
private readonly border;
|
|
14
|
+
private list;
|
|
15
|
+
focused: boolean;
|
|
16
|
+
constructor(models: readonly ModelInfo[], current: ModelInfo | undefined, border: (text: string) => string, onSelect: (model: ModelInfo) => void, onCancel: () => void);
|
|
17
|
+
/** A frame with full-width lines, so the transcript does not show through. */
|
|
18
|
+
render(width: number): string[];
|
|
19
|
+
handleInput(data: string): void;
|
|
20
|
+
invalidate(): void;
|
|
21
|
+
private listOf;
|
|
22
|
+
}
|
package/dist/picker.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { fuzzyFilter, Input, matchesKey, SelectList, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
|
|
3
|
+
/** The keys that the list of the model picker gets; the filter gets the others. */
|
|
4
|
+
const LIST_KEYS = ["up", "down", "enter", "escape", "pageUp", "pageDown"];
|
|
5
|
+
/**
|
|
6
|
+
* A filter over a list of models, fuzzy as Pi's model selector: typing narrows the list and keeps the selected model
|
|
7
|
+
* when it still matches, ↑↓ move, Enter picks, Esc closes.
|
|
8
|
+
*/
|
|
9
|
+
export class ModelPicker {
|
|
10
|
+
input = new Input();
|
|
11
|
+
items;
|
|
12
|
+
modelOf = new Map();
|
|
13
|
+
onSelect;
|
|
14
|
+
onCancel;
|
|
15
|
+
border;
|
|
16
|
+
list;
|
|
17
|
+
focused = true;
|
|
18
|
+
constructor(models, current, border, onSelect, onCancel) {
|
|
19
|
+
this.items = models.map((model) => {
|
|
20
|
+
const item = {
|
|
21
|
+
value: `${model.provider}/${model.id}`,
|
|
22
|
+
label: `${model.provider}/${model.id}`,
|
|
23
|
+
description: model.name,
|
|
24
|
+
};
|
|
25
|
+
this.modelOf.set(item, model);
|
|
26
|
+
return item;
|
|
27
|
+
});
|
|
28
|
+
this.border = border;
|
|
29
|
+
this.onSelect = onSelect;
|
|
30
|
+
this.onCancel = onCancel;
|
|
31
|
+
this.input.focused = true;
|
|
32
|
+
this.list = this.listOf(this.items);
|
|
33
|
+
this.list.setSelectedIndex(current === undefined ? 0 : models.indexOf(current));
|
|
34
|
+
}
|
|
35
|
+
/** A frame with full-width lines, so the transcript does not show through. */
|
|
36
|
+
render(width) {
|
|
37
|
+
const inner = Math.max(1, width - 2);
|
|
38
|
+
const body = [" Model", ...this.input.render(inner), ...this.list.render(inner)].map((line) => {
|
|
39
|
+
const cut = truncateToWidth(line, inner);
|
|
40
|
+
return `${this.border("│")}${cut}${" ".repeat(Math.max(0, inner - visibleWidth(cut)))}${this.border("│")}`;
|
|
41
|
+
});
|
|
42
|
+
return [this.border(`╭${"─".repeat(inner)}╮`), ...body, this.border(`╰${"─".repeat(inner)}╯`)];
|
|
43
|
+
}
|
|
44
|
+
handleInput(data) {
|
|
45
|
+
if (LIST_KEYS.some((key) => matchesKey(data, key))) {
|
|
46
|
+
this.list.handleInput(data);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
this.input.handleInput(data);
|
|
50
|
+
const selected = this.list.getSelectedItem();
|
|
51
|
+
const query = this.input.getValue();
|
|
52
|
+
const items = fuzzyFilter(this.items, query, (item) => `${item.label} ${item.description ?? ""}`);
|
|
53
|
+
this.list = this.listOf(items);
|
|
54
|
+
this.list.setSelectedIndex(selected === null ? 0 : items.indexOf(selected));
|
|
55
|
+
}
|
|
56
|
+
invalidate() {
|
|
57
|
+
this.input.invalidate();
|
|
58
|
+
this.list.invalidate();
|
|
59
|
+
}
|
|
60
|
+
listOf(items) {
|
|
61
|
+
const list = new SelectList(items, 10, getSelectListTheme());
|
|
62
|
+
list.onSelect = (item) => {
|
|
63
|
+
const model = this.modelOf.get(item);
|
|
64
|
+
if (model !== undefined)
|
|
65
|
+
this.onSelect(model);
|
|
66
|
+
};
|
|
67
|
+
list.onCancel = this.onCancel;
|
|
68
|
+
return list;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** The project that has no directory. A cwd outside every project goes to it. */
|
|
2
|
+
export declare const MAIN_PROJECT = "main";
|
|
3
|
+
/** The file of the projects: `$XDG_CONFIG_HOME/harness/projects.json`, default `~/.config/harness/projects.json`. */
|
|
4
|
+
export declare function projectsPath(): string;
|
|
5
|
+
/**
|
|
6
|
+
* The projects, name to real directory, in creation order. It is empty when the file does not exist. It throws when the
|
|
7
|
+
* file is not an object of valid names to absolute directories.
|
|
8
|
+
*/
|
|
9
|
+
export declare function readProjects(): Record<string, string>;
|
|
10
|
+
/**
|
|
11
|
+
* Adds the project `name` with the real path of `dir` to the projects, and gives that path. It throws for an invalid or
|
|
12
|
+
* existing name and for a directory that does not exist.
|
|
13
|
+
*/
|
|
14
|
+
export declare function addProject(name: string, dir: string): string;
|
|
15
|
+
/** `name` when it is `main` or a project. Otherwise it throws. */
|
|
16
|
+
export declare function checkProject(name: string, projects?: Readonly<Record<string, string>>): string;
|
|
17
|
+
/** The directory of the project `name`: the home directory for `main`. It throws for an unknown project. */
|
|
18
|
+
export declare function projectDir(name: string, projects?: Readonly<Record<string, string>>): string;
|
|
19
|
+
/**
|
|
20
|
+
* The project of a command: `HARNESS_PROJECT` in `env`, then the project with the longest directory that is `cwd` or
|
|
21
|
+
* contains it, then `main`. An unknown `HARNESS_PROJECT` is an error.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveProject(env: NodeJS.ProcessEnv, cwd: string, projects?: Readonly<Record<string, string>>): string;
|
package/dist/projects.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { Value } from "typebox/value";
|
|
6
|
+
/** The project that has no directory. A cwd outside every project goes to it. */
|
|
7
|
+
export const MAIN_PROJECT = "main";
|
|
8
|
+
const NAME = /^[a-z][a-z0-9-]{0,31}$/;
|
|
9
|
+
/** The file of the projects: `$XDG_CONFIG_HOME/harness/projects.json`, default `~/.config/harness/projects.json`. */
|
|
10
|
+
export function projectsPath() {
|
|
11
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "harness", "projects.json");
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The projects, name to real directory, in creation order. It is empty when the file does not exist. It throws when the
|
|
15
|
+
* file is not an object of valid names to absolute directories.
|
|
16
|
+
*/
|
|
17
|
+
export function readProjects() {
|
|
18
|
+
const path = projectsPath();
|
|
19
|
+
let text;
|
|
20
|
+
try {
|
|
21
|
+
text = readFileSync(path, "utf8");
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
25
|
+
return {};
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
const projects = JSON.parse(text);
|
|
29
|
+
if (!Value.Check(Type.Record(Type.String(), Type.Unknown()), projects)) {
|
|
30
|
+
throw new Error(`${path} is not an object of project names to directories`);
|
|
31
|
+
}
|
|
32
|
+
return Object.fromEntries(Object.entries(projects).map(([name, dir]) => {
|
|
33
|
+
if (!NAME.test(name) || name === MAIN_PROJECT || !Value.Check(Type.String(), dir) || !isAbsolute(dir)) {
|
|
34
|
+
throw new Error(`${path} has an invalid project: ${name}`);
|
|
35
|
+
}
|
|
36
|
+
return [name, dir];
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Adds the project `name` with the real path of `dir` to the projects, and gives that path. It throws for an invalid or
|
|
41
|
+
* existing name and for a directory that does not exist.
|
|
42
|
+
*/
|
|
43
|
+
export function addProject(name, dir) {
|
|
44
|
+
if (!NAME.test(name))
|
|
45
|
+
throw new Error(`A project name has 1 to 32 characters: a-z first, then a-z, 0-9 or -: ${name}`);
|
|
46
|
+
if (name === MAIN_PROJECT)
|
|
47
|
+
throw new Error(`A project cannot have the name ${name}`);
|
|
48
|
+
let real;
|
|
49
|
+
try {
|
|
50
|
+
real = realpathSync(resolve(dir));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new Error(`No such directory: ${dir}`);
|
|
54
|
+
}
|
|
55
|
+
if (!statSync(real).isDirectory())
|
|
56
|
+
throw new Error(`Not a directory: ${dir}`);
|
|
57
|
+
const projects = readProjects();
|
|
58
|
+
if (Object.hasOwn(projects, name))
|
|
59
|
+
throw new Error(`The project ${name} exists (${projects[name]})`);
|
|
60
|
+
const path = projectsPath();
|
|
61
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
62
|
+
// A temporary file, then a rename, so a stop in between does not truncate the projects.
|
|
63
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
64
|
+
writeFileSync(temporary, `${JSON.stringify({ ...projects, [name]: real }, null, "\t")}\n`);
|
|
65
|
+
renameSync(temporary, path);
|
|
66
|
+
return real;
|
|
67
|
+
}
|
|
68
|
+
/** `name` when it is `main` or a project. Otherwise it throws. */
|
|
69
|
+
export function checkProject(name, projects = readProjects()) {
|
|
70
|
+
if (name !== MAIN_PROJECT && !Object.hasOwn(projects, name))
|
|
71
|
+
throw new Error(`Unknown harness project: ${name}`);
|
|
72
|
+
return name;
|
|
73
|
+
}
|
|
74
|
+
/** The directory of the project `name`: the home directory for `main`. It throws for an unknown project. */
|
|
75
|
+
export function projectDir(name, projects = readProjects()) {
|
|
76
|
+
// `checkProject` passes only `main`, which `projects` never has, and the names in `projects`.
|
|
77
|
+
return projects[checkProject(name, projects)] ?? homedir();
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The project of a command: `HARNESS_PROJECT` in `env`, then the project with the longest directory that is `cwd` or
|
|
81
|
+
* contains it, then `main`. An unknown `HARNESS_PROJECT` is an error.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveProject(env, cwd, projects = readProjects()) {
|
|
84
|
+
if (env.HARNESS_PROJECT)
|
|
85
|
+
return checkProject(env.HARNESS_PROJECT, projects);
|
|
86
|
+
let found = MAIN_PROJECT;
|
|
87
|
+
let length = -1;
|
|
88
|
+
for (const [name, dir] of Object.entries(projects)) {
|
|
89
|
+
// The first project wins over a later one with the same directory.
|
|
90
|
+
if (dir.length > length && (cwd === dir || cwd.startsWith(dir.endsWith(sep) ? dir : dir + sep))) {
|
|
91
|
+
found = name;
|
|
92
|
+
length = dir.length;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return found;
|
|
96
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type { Socket } from "node:net";
|
|
2
|
+
import type { JsonValue } from "@earendil-works/chord";
|
|
3
|
+
import type { Op } from "@earendil-works/chord/delta";
|
|
4
|
+
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
5
|
+
import type { ConversationView, TaskId, TaskOutcome, TaskState } from "@earendil-works/pi-durable";
|
|
6
|
+
import { type Static, Type } from "typebox";
|
|
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 = 7;
|
|
9
|
+
/**
|
|
10
|
+
* The handle of the project agent, the host's root conversation, in `watch`, `input`, `abort` and `configure`. It is not
|
|
11
|
+
* hex, so no child has it.
|
|
12
|
+
*/
|
|
13
|
+
export declare const AGENT_HANDLE = "agent";
|
|
14
|
+
export type ChildState = "busy" | "idle" | "error";
|
|
15
|
+
/** One child as the host reports it. Provider, model and thinking level come from the child's agent. */
|
|
16
|
+
export type ChildInfo = {
|
|
17
|
+
readonly handle: string;
|
|
18
|
+
readonly name?: string;
|
|
19
|
+
readonly state: ChildState;
|
|
20
|
+
readonly provider: string;
|
|
21
|
+
readonly model: string;
|
|
22
|
+
readonly thinking: ModelThinkingLevel;
|
|
23
|
+
/** The child's working directory. */
|
|
24
|
+
readonly cwd: string;
|
|
25
|
+
};
|
|
26
|
+
/** A task that `start` created, by its client key. `outcome` is there only when the task is terminal. */
|
|
27
|
+
export type TaskInfo = {
|
|
28
|
+
readonly key: string;
|
|
29
|
+
readonly id: TaskId<JsonValue>;
|
|
30
|
+
/** The name of the task definition. */
|
|
31
|
+
readonly kind: string;
|
|
32
|
+
/**
|
|
33
|
+
* The version on the task's record: the version of the definition that last ran or migrated it, so a client can tell
|
|
34
|
+
* which version of an entry the host used for this task. A task that no loaded definition can run keeps its version.
|
|
35
|
+
*/
|
|
36
|
+
readonly version: number;
|
|
37
|
+
readonly status: TaskState<JsonValue, JsonValue>["status"];
|
|
38
|
+
readonly outcome?: TaskOutcome<JsonValue>;
|
|
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
|
+
/** Any JSON value. A request line is JSON, so `start` takes any input. */
|
|
50
|
+
export declare const Json: Type.TCyclic<{
|
|
51
|
+
Json: Type.TUnion<[Type.TNull, Type.TBoolean, Type.TNumber, Type.TString, Type.TArray<Type.TRef<"Json">>, Type.TRecord<"^.*$", Type.TRef<"Json">>]>;
|
|
52
|
+
}, "Json">;
|
|
53
|
+
/** One request line. The caller is `PI_SESSION_ID`, or `cli` without one. */
|
|
54
|
+
export type Request = {
|
|
55
|
+
readonly type: "hello";
|
|
56
|
+
readonly version: number;
|
|
57
|
+
} | {
|
|
58
|
+
readonly type: "spawn";
|
|
59
|
+
readonly requestId: string;
|
|
60
|
+
readonly caller: string;
|
|
61
|
+
readonly name?: string;
|
|
62
|
+
readonly provider: string;
|
|
63
|
+
readonly model: string;
|
|
64
|
+
/** The level the caller asks for; the host refuses it when the model does not have it. */
|
|
65
|
+
readonly thinking?: string;
|
|
66
|
+
/** The caller's own level, used without `thinking`, clamped to the nearest level that the model has. */
|
|
67
|
+
readonly callerThinking: string;
|
|
68
|
+
readonly cwd: string;
|
|
69
|
+
readonly tools?: readonly string[];
|
|
70
|
+
readonly prompt: string;
|
|
71
|
+
} | {
|
|
72
|
+
readonly type: "send";
|
|
73
|
+
readonly requestId: string;
|
|
74
|
+
readonly caller: string;
|
|
75
|
+
readonly handle: string;
|
|
76
|
+
readonly message: string;
|
|
77
|
+
readonly followUp: boolean;
|
|
78
|
+
} | {
|
|
79
|
+
readonly type: "wait";
|
|
80
|
+
readonly handle: string;
|
|
81
|
+
readonly timeout: number;
|
|
82
|
+
} | {
|
|
83
|
+
readonly type: "status";
|
|
84
|
+
readonly handle: string;
|
|
85
|
+
} | {
|
|
86
|
+
readonly type: "list";
|
|
87
|
+
readonly caller: string;
|
|
88
|
+
} | {
|
|
89
|
+
readonly type: "rename";
|
|
90
|
+
readonly caller: string;
|
|
91
|
+
readonly handle: string;
|
|
92
|
+
readonly name: string;
|
|
93
|
+
} | {
|
|
94
|
+
readonly type: "stop";
|
|
95
|
+
readonly caller: string;
|
|
96
|
+
readonly handle: string;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The viewer's stream of the child: the reply, then one `{ event: WatchLine }` line after each commit to the child's
|
|
100
|
+
* conversation and after each rename. Every other line on the connection stays a reply, in request order. A
|
|
101
|
+
* connection carries one watch.
|
|
102
|
+
*/
|
|
103
|
+
| {
|
|
104
|
+
readonly type: "watch";
|
|
105
|
+
readonly handle: string;
|
|
106
|
+
}
|
|
107
|
+
/** Stops the child's current turn. The viewer sends it. */
|
|
108
|
+
| {
|
|
109
|
+
readonly type: "abort";
|
|
110
|
+
readonly handle: string;
|
|
111
|
+
}
|
|
112
|
+
/** The models that have a login, for the viewer's model picker and context window. */
|
|
113
|
+
| {
|
|
114
|
+
readonly type: "models";
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The viewer's change of the child's model or thinking level. It has no caller. A new model keeps the thinking level
|
|
118
|
+
* when the model has it, and takes the nearest level that it has otherwise. The host refuses a level that the model
|
|
119
|
+
* does not have.
|
|
120
|
+
*/
|
|
121
|
+
| {
|
|
122
|
+
readonly type: "configure";
|
|
123
|
+
readonly handle: string;
|
|
124
|
+
readonly provider?: string;
|
|
125
|
+
readonly model?: string;
|
|
126
|
+
readonly thinking?: ModelThinkingLevel;
|
|
127
|
+
}
|
|
128
|
+
/** The viewer's message to the child. It has no caller. */
|
|
129
|
+
| {
|
|
130
|
+
readonly type: "input";
|
|
131
|
+
readonly requestId: string;
|
|
132
|
+
readonly handle: string;
|
|
133
|
+
readonly message: string;
|
|
134
|
+
readonly followUp: boolean;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Starts a background task of the kind `kind`, a task of an entry in `entries.json`, in the root conversation.
|
|
138
|
+
* A repeated `key` gives the first task, and its new kind, version and input are ignored. With `version`, a host
|
|
139
|
+
* whose definition of `kind` has another version refuses before it creates anything.
|
|
140
|
+
*/
|
|
141
|
+
| {
|
|
142
|
+
readonly type: "start";
|
|
143
|
+
readonly kind: string;
|
|
144
|
+
readonly input: JsonValue;
|
|
145
|
+
readonly key: string;
|
|
146
|
+
readonly version?: number;
|
|
147
|
+
} | {
|
|
148
|
+
readonly type: "task";
|
|
149
|
+
readonly key: string;
|
|
150
|
+
}
|
|
151
|
+
/** Waits until the task is terminal, and replies with its outcome. A client polls `task` for the status. */
|
|
152
|
+
| {
|
|
153
|
+
readonly type: "wait-task";
|
|
154
|
+
readonly key: string;
|
|
155
|
+
}
|
|
156
|
+
/** Aborts the task and the work that it owns. */
|
|
157
|
+
| {
|
|
158
|
+
readonly type: "abort-task";
|
|
159
|
+
readonly key: string;
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* The check of a request line on the host. It is looser than `Request`, so the host answers a spawn without `cwd` and an
|
|
163
|
+
* unknown thinking level with its own errors.
|
|
164
|
+
*/
|
|
165
|
+
export declare const RequestLine: Type.TUnion<[Type.TObject<{
|
|
166
|
+
type: Type.TLiteral<"hello">;
|
|
167
|
+
version: Type.TNumber;
|
|
168
|
+
}>, Type.TObject<{
|
|
169
|
+
type: Type.TLiteral<"spawn">;
|
|
170
|
+
requestId: Type.TString;
|
|
171
|
+
caller: Type.TString;
|
|
172
|
+
name: Type.TOptional<Type.TString>;
|
|
173
|
+
provider: Type.TString;
|
|
174
|
+
model: Type.TString;
|
|
175
|
+
thinking: Type.TOptional<Type.TString>;
|
|
176
|
+
callerThinking: Type.TString;
|
|
177
|
+
cwd: Type.TOptional<Type.TString>;
|
|
178
|
+
tools: Type.TOptional<Type.TImmutable<Type.TArray<Type.TString>>>;
|
|
179
|
+
prompt: Type.TString;
|
|
180
|
+
}>, Type.TObject<{
|
|
181
|
+
type: Type.TLiteral<"send">;
|
|
182
|
+
requestId: Type.TString;
|
|
183
|
+
caller: Type.TString;
|
|
184
|
+
handle: Type.TString;
|
|
185
|
+
message: Type.TString;
|
|
186
|
+
followUp: Type.TBoolean;
|
|
187
|
+
}>, Type.TObject<{
|
|
188
|
+
type: Type.TLiteral<"wait">;
|
|
189
|
+
handle: Type.TString;
|
|
190
|
+
timeout: Type.TNumber;
|
|
191
|
+
}>, Type.TObject<{
|
|
192
|
+
type: Type.TLiteral<"status">;
|
|
193
|
+
handle: Type.TString;
|
|
194
|
+
}>, Type.TObject<{
|
|
195
|
+
type: Type.TLiteral<"list">;
|
|
196
|
+
caller: Type.TString;
|
|
197
|
+
}>, Type.TObject<{
|
|
198
|
+
type: Type.TLiteral<"rename">;
|
|
199
|
+
caller: Type.TString;
|
|
200
|
+
handle: Type.TString;
|
|
201
|
+
name: Type.TString;
|
|
202
|
+
}>, Type.TObject<{
|
|
203
|
+
type: Type.TLiteral<"stop">;
|
|
204
|
+
caller: Type.TString;
|
|
205
|
+
handle: Type.TString;
|
|
206
|
+
}>, Type.TObject<{
|
|
207
|
+
type: Type.TLiteral<"watch">;
|
|
208
|
+
handle: Type.TString;
|
|
209
|
+
}>, Type.TObject<{
|
|
210
|
+
type: Type.TLiteral<"abort">;
|
|
211
|
+
handle: Type.TString;
|
|
212
|
+
}>, Type.TObject<{
|
|
213
|
+
type: Type.TLiteral<"models">;
|
|
214
|
+
}>, Type.TObject<{
|
|
215
|
+
type: Type.TLiteral<"configure">;
|
|
216
|
+
handle: Type.TString;
|
|
217
|
+
provider: Type.TOptional<Type.TString>;
|
|
218
|
+
model: Type.TOptional<Type.TString>;
|
|
219
|
+
thinking: Type.TOptional<Type.TString>;
|
|
220
|
+
}>, Type.TObject<{
|
|
221
|
+
type: Type.TLiteral<"input">;
|
|
222
|
+
requestId: Type.TString;
|
|
223
|
+
handle: Type.TString;
|
|
224
|
+
message: Type.TString;
|
|
225
|
+
followUp: Type.TBoolean;
|
|
226
|
+
}>, Type.TObject<{
|
|
227
|
+
type: Type.TLiteral<"start">;
|
|
228
|
+
kind: Type.TString;
|
|
229
|
+
input: Type.TCyclic<{
|
|
230
|
+
Json: Type.TUnion<[Type.TNull, Type.TBoolean, Type.TNumber, Type.TString, Type.TArray<Type.TRef<"Json">>, Type.TRecord<"^.*$", Type.TRef<"Json">>]>;
|
|
231
|
+
}, "Json">;
|
|
232
|
+
key: Type.TString;
|
|
233
|
+
version: Type.TOptional<Type.TNumber>;
|
|
234
|
+
}>, Type.TObject<{
|
|
235
|
+
type: Type.TLiteral<"task">;
|
|
236
|
+
key: Type.TString;
|
|
237
|
+
}>, Type.TObject<{
|
|
238
|
+
type: Type.TLiteral<"wait-task">;
|
|
239
|
+
key: Type.TString;
|
|
240
|
+
}>, Type.TObject<{
|
|
241
|
+
type: Type.TLiteral<"abort-task">;
|
|
242
|
+
key: Type.TString;
|
|
243
|
+
}>]>;
|
|
244
|
+
export type RequestLine = Static<typeof RequestLine>;
|
|
245
|
+
/** The value of the reply to each request type. */
|
|
246
|
+
export type Replies = {
|
|
247
|
+
readonly hello: {
|
|
248
|
+
readonly version: number;
|
|
249
|
+
readonly pid: number;
|
|
250
|
+
};
|
|
251
|
+
readonly spawn: ChildInfo;
|
|
252
|
+
readonly send: {
|
|
253
|
+
readonly verb: string;
|
|
254
|
+
readonly child: ChildInfo;
|
|
255
|
+
};
|
|
256
|
+
readonly wait: {
|
|
257
|
+
readonly answer: string;
|
|
258
|
+
};
|
|
259
|
+
readonly status: ChildInfo;
|
|
260
|
+
readonly list: readonly ChildInfo[];
|
|
261
|
+
readonly rename: ChildInfo;
|
|
262
|
+
readonly stop: {
|
|
263
|
+
readonly handle: string;
|
|
264
|
+
readonly name?: string;
|
|
265
|
+
};
|
|
266
|
+
readonly watch: {
|
|
267
|
+
readonly view: ConversationView;
|
|
268
|
+
readonly child: ChildInfo;
|
|
269
|
+
};
|
|
270
|
+
readonly abort: ChildInfo;
|
|
271
|
+
readonly input: Replies["send"];
|
|
272
|
+
readonly models: readonly ModelInfo[];
|
|
273
|
+
readonly configure: ChildInfo;
|
|
274
|
+
readonly start: TaskInfo;
|
|
275
|
+
readonly task: TaskInfo;
|
|
276
|
+
readonly "wait-task": TaskInfo;
|
|
277
|
+
readonly "abort-task": TaskInfo;
|
|
278
|
+
};
|
|
279
|
+
/**
|
|
280
|
+
* An event of a watch after its reply, sent as `{ event: WatchLine }`: the changes of one commit or a rename, or the end
|
|
281
|
+
* when the child was stopped.
|
|
282
|
+
*/
|
|
283
|
+
export type WatchLine = {
|
|
284
|
+
readonly ops: readonly Op[];
|
|
285
|
+
readonly child: ChildInfo;
|
|
286
|
+
readonly stopped?: undefined;
|
|
287
|
+
} | {
|
|
288
|
+
readonly stopped: true;
|
|
289
|
+
};
|
|
290
|
+
/** One reply line. */
|
|
291
|
+
export type Reply = {
|
|
292
|
+
readonly ok: true;
|
|
293
|
+
readonly value: unknown;
|
|
294
|
+
} | {
|
|
295
|
+
readonly ok: false;
|
|
296
|
+
readonly error: string;
|
|
297
|
+
};
|
|
298
|
+
/** The files of the host of `project`: `$XDG_STATE_HOME/harness/<project>/`, default `~/.local/state/harness/<project>/`. */
|
|
299
|
+
/**
|
|
300
|
+
* The CLI of this install, which the host start and the Herdr tabs run with this Node: `cli.js` next to the built
|
|
301
|
+
* modules, or `cli.ts` in a checkout, which Node runs with type stripping.
|
|
302
|
+
*/
|
|
303
|
+
export declare function cliPath(): string;
|
|
304
|
+
export type StatePaths = {
|
|
305
|
+
dir: string;
|
|
306
|
+
socket: string;
|
|
307
|
+
log: string;
|
|
308
|
+
};
|
|
309
|
+
export declare function statePaths(project: string): StatePaths;
|
|
310
|
+
export declare function isValidRunName(value: string): boolean;
|
|
311
|
+
/** The label of a child: its name, or its handle when it has no name. */
|
|
312
|
+
export declare function childLabel(child: {
|
|
313
|
+
readonly handle: string;
|
|
314
|
+
readonly name?: string | undefined;
|
|
315
|
+
}): string;
|
|
316
|
+
export declare function runDisplayName(metadata: {
|
|
317
|
+
readonly handle: string;
|
|
318
|
+
readonly name?: string | undefined;
|
|
319
|
+
}): string;
|
|
320
|
+
/** The line of one child in a list. */
|
|
321
|
+
export declare function childLine(child: ChildInfo): string;
|
|
322
|
+
/** Calls `onLine` with each line that arrives on `socket`, without its newline. */
|
|
323
|
+
export declare function onLines(socket: Socket, onLine: (line: string) => void): void;
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Type } from "typebox";
|
|
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 = 7;
|
|
7
|
+
/**
|
|
8
|
+
* The handle of the project agent, the host's root conversation, in `watch`, `input`, `abort` and `configure`. It is not
|
|
9
|
+
* hex, so no child has it.
|
|
10
|
+
*/
|
|
11
|
+
export const AGENT_HANDLE = "agent";
|
|
12
|
+
/** Any JSON value. A request line is JSON, so `start` takes any input. */
|
|
13
|
+
export const Json = Type.Cyclic({
|
|
14
|
+
Json: Type.Union([
|
|
15
|
+
Type.Null(),
|
|
16
|
+
Type.Boolean(),
|
|
17
|
+
Type.Number(),
|
|
18
|
+
Type.String(),
|
|
19
|
+
Type.Array(Type.Ref("Json")),
|
|
20
|
+
Type.Record(Type.String(), Type.Ref("Json")),
|
|
21
|
+
]),
|
|
22
|
+
}, "Json");
|
|
23
|
+
/**
|
|
24
|
+
* The check of a request line on the host. It is looser than `Request`, so the host answers a spawn without `cwd` and an
|
|
25
|
+
* unknown thinking level with its own errors.
|
|
26
|
+
*/
|
|
27
|
+
export const RequestLine = Type.Union([
|
|
28
|
+
Type.Object({ type: Type.Literal("hello"), version: Type.Number() }),
|
|
29
|
+
Type.Object({
|
|
30
|
+
type: Type.Literal("spawn"),
|
|
31
|
+
requestId: Type.String(),
|
|
32
|
+
caller: Type.String(),
|
|
33
|
+
name: Type.Optional(Type.String()),
|
|
34
|
+
provider: Type.String(),
|
|
35
|
+
model: Type.String(),
|
|
36
|
+
thinking: Type.Optional(Type.String()),
|
|
37
|
+
callerThinking: Type.String(),
|
|
38
|
+
cwd: Type.Optional(Type.String()),
|
|
39
|
+
tools: Type.Optional(Type.Immutable(Type.Array(Type.String()))),
|
|
40
|
+
prompt: Type.String(),
|
|
41
|
+
}),
|
|
42
|
+
Type.Object({
|
|
43
|
+
type: Type.Literal("send"),
|
|
44
|
+
requestId: Type.String(),
|
|
45
|
+
caller: Type.String(),
|
|
46
|
+
handle: Type.String(),
|
|
47
|
+
message: Type.String(),
|
|
48
|
+
followUp: Type.Boolean(),
|
|
49
|
+
}),
|
|
50
|
+
Type.Object({ type: Type.Literal("wait"), handle: Type.String(), timeout: Type.Number() }),
|
|
51
|
+
Type.Object({ type: Type.Literal("status"), handle: Type.String() }),
|
|
52
|
+
Type.Object({ type: Type.Literal("list"), caller: Type.String() }),
|
|
53
|
+
Type.Object({ type: Type.Literal("rename"), caller: Type.String(), handle: Type.String(), name: Type.String() }),
|
|
54
|
+
Type.Object({ type: Type.Literal("stop"), caller: Type.String(), handle: Type.String() }),
|
|
55
|
+
Type.Object({ type: Type.Literal("watch"), handle: Type.String() }),
|
|
56
|
+
Type.Object({ type: Type.Literal("abort"), handle: Type.String() }),
|
|
57
|
+
Type.Object({ type: Type.Literal("models") }),
|
|
58
|
+
Type.Object({
|
|
59
|
+
type: Type.Literal("configure"),
|
|
60
|
+
handle: Type.String(),
|
|
61
|
+
provider: Type.Optional(Type.String()),
|
|
62
|
+
model: Type.Optional(Type.String()),
|
|
63
|
+
thinking: Type.Optional(Type.String()),
|
|
64
|
+
}),
|
|
65
|
+
Type.Object({
|
|
66
|
+
type: Type.Literal("input"),
|
|
67
|
+
requestId: Type.String(),
|
|
68
|
+
handle: Type.String(),
|
|
69
|
+
message: Type.String(),
|
|
70
|
+
followUp: Type.Boolean(),
|
|
71
|
+
}),
|
|
72
|
+
Type.Object({
|
|
73
|
+
type: Type.Literal("start"),
|
|
74
|
+
kind: Type.String(),
|
|
75
|
+
input: Json,
|
|
76
|
+
key: Type.String(),
|
|
77
|
+
version: Type.Optional(Type.Number()),
|
|
78
|
+
}),
|
|
79
|
+
Type.Object({ type: Type.Literal("task"), key: Type.String() }),
|
|
80
|
+
Type.Object({ type: Type.Literal("wait-task"), key: Type.String() }),
|
|
81
|
+
Type.Object({ type: Type.Literal("abort-task"), key: Type.String() }),
|
|
82
|
+
]);
|
|
83
|
+
/** The files of the host of `project`: `$XDG_STATE_HOME/harness/<project>/`, default `~/.local/state/harness/<project>/`. */
|
|
84
|
+
/**
|
|
85
|
+
* The CLI of this install, which the host start and the Herdr tabs run with this Node: `cli.js` next to the built
|
|
86
|
+
* modules, or `cli.ts` in a checkout, which Node runs with type stripping.
|
|
87
|
+
*/
|
|
88
|
+
export function cliPath() {
|
|
89
|
+
return fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./cli.ts" : "./cli.js", import.meta.url));
|
|
90
|
+
}
|
|
91
|
+
export function statePaths(project) {
|
|
92
|
+
const dir = join(process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "harness", project);
|
|
93
|
+
return { dir, socket: join(dir, "host.sock"), log: join(dir, "host.log") };
|
|
94
|
+
}
|
|
95
|
+
export function isValidRunName(value) {
|
|
96
|
+
return (value.length > 0 && value.length <= 64 && value.trim() === value && !Array.from(value).some(isControlCharacter));
|
|
97
|
+
}
|
|
98
|
+
/** U+0000 to U+001F and U+007F. */
|
|
99
|
+
function isControlCharacter(character) {
|
|
100
|
+
const code = character.charCodeAt(0);
|
|
101
|
+
return code <= 0x1f || code === 0x7f;
|
|
102
|
+
}
|
|
103
|
+
/** The label of a child: its name, or its handle when it has no name. */
|
|
104
|
+
export function childLabel(child) {
|
|
105
|
+
return child.name ?? child.handle;
|
|
106
|
+
}
|
|
107
|
+
export function runDisplayName(metadata) {
|
|
108
|
+
return metadata.name ? `${metadata.name} (${metadata.handle})` : metadata.handle;
|
|
109
|
+
}
|
|
110
|
+
/** The line of one child in a list. */
|
|
111
|
+
export function childLine(child) {
|
|
112
|
+
return `${runDisplayName(child)} ${child.state.padEnd(8)} ${child.provider}/${child.model} ${child.thinking}`;
|
|
113
|
+
}
|
|
114
|
+
/** Calls `onLine` with each line that arrives on `socket`, without its newline. */
|
|
115
|
+
export function onLines(socket, onLine) {
|
|
116
|
+
let buffer = "";
|
|
117
|
+
socket.setEncoding("utf8");
|
|
118
|
+
socket.on("data", (chunk) => {
|
|
119
|
+
buffer += chunk;
|
|
120
|
+
for (let end = buffer.indexOf("\n"); end !== -1; end = buffer.indexOf("\n")) {
|
|
121
|
+
const line = buffer.slice(0, end);
|
|
122
|
+
buffer = buffer.slice(end + 1);
|
|
123
|
+
onLine(line);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers `<packageDir>/pi` in Pi when `packageDir` is a top-level global npm install. First it runs `pi remove` for
|
|
3
|
+
* each Pi package of the `retiredPiPackages` field of the package's `package.json` that Pi's settings list. It writes
|
|
4
|
+
* its messages to stderr, and it does not throw for a Pi failure: it writes a warning with the command to run.
|
|
5
|
+
* It writes the stamp that `setupOnce` reads unless a `pi` command failed, so the next run tries again. Without `pi`
|
|
6
|
+
* on PATH it writes the stamp, so a user without Pi gets the hint once per version.
|
|
7
|
+
*/
|
|
8
|
+
export declare function setup(packageDir: string): void;
|
|
9
|
+
/**
|
|
10
|
+
* `setup` once per version and path of the package: it does nothing when the stamp of `setup` exists. It never
|
|
11
|
+
* throws; an error is a warning.
|
|
12
|
+
*/
|
|
13
|
+
export declare function setupOnce(packageDir: string): void;
|