@vzlentin/harness 0.0.0-stage → 0.1.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.
@@ -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;
@@ -0,0 +1,93 @@
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
+ /** The project that has no directory. A cwd outside every project goes to it. */
5
+ export const MAIN_PROJECT = "main";
6
+ const NAME = /^[a-z][a-z0-9-]{0,31}$/;
7
+ /** The file of the projects: `$XDG_CONFIG_HOME/harness/projects.json`, default `~/.config/harness/projects.json`. */
8
+ export function projectsPath() {
9
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "harness", "projects.json");
10
+ }
11
+ /**
12
+ * The projects, name to real directory, in creation order. It is empty when the file does not exist. It throws when the
13
+ * file is not an object of valid names to absolute directories.
14
+ */
15
+ export function readProjects() {
16
+ const path = projectsPath();
17
+ let text;
18
+ try {
19
+ text = readFileSync(path, "utf8");
20
+ }
21
+ catch (error) {
22
+ if (error.code === "ENOENT")
23
+ return {};
24
+ throw error;
25
+ }
26
+ const projects = JSON.parse(text);
27
+ if (typeof projects !== "object" || projects === null || Array.isArray(projects)) {
28
+ throw new Error(`${path} is not an object of project names to directories`);
29
+ }
30
+ for (const [name, dir] of Object.entries(projects)) {
31
+ if (!NAME.test(name) || name === MAIN_PROJECT || typeof dir !== "string" || !isAbsolute(dir)) {
32
+ throw new Error(`${path} has an invalid project: ${name}`);
33
+ }
34
+ }
35
+ return projects;
36
+ }
37
+ /**
38
+ * Adds the project `name` with the real path of `dir` to the projects, and gives that path. It throws for an invalid or
39
+ * existing name and for a directory that does not exist.
40
+ */
41
+ export function addProject(name, dir) {
42
+ if (!NAME.test(name))
43
+ throw new Error(`A project name has 1 to 32 characters: a-z first, then a-z, 0-9 or -: ${name}`);
44
+ if (name === MAIN_PROJECT)
45
+ throw new Error(`A project cannot have the name ${name}`);
46
+ let real;
47
+ try {
48
+ real = realpathSync(resolve(dir));
49
+ }
50
+ catch {
51
+ throw new Error(`No such directory: ${dir}`);
52
+ }
53
+ if (!statSync(real).isDirectory())
54
+ throw new Error(`Not a directory: ${dir}`);
55
+ const projects = readProjects();
56
+ if (Object.hasOwn(projects, name))
57
+ throw new Error(`The project ${name} exists (${projects[name]})`);
58
+ const path = projectsPath();
59
+ mkdirSync(dirname(path), { recursive: true });
60
+ // A temporary file, then a rename, so a stop in between does not truncate the projects.
61
+ const temporary = `${path}.${process.pid}.tmp`;
62
+ writeFileSync(temporary, `${JSON.stringify({ ...projects, [name]: real }, null, "\t")}\n`);
63
+ renameSync(temporary, path);
64
+ return real;
65
+ }
66
+ /** `name` when it is `main` or a project. Otherwise it throws. */
67
+ export function checkProject(name, projects = readProjects()) {
68
+ if (name !== MAIN_PROJECT && !Object.hasOwn(projects, name))
69
+ throw new Error(`Unknown harness project: ${name}`);
70
+ return name;
71
+ }
72
+ /** The directory of the project `name`: the home directory for `main`. It throws for an unknown project. */
73
+ export function projectDir(name, projects = readProjects()) {
74
+ return checkProject(name, projects) === MAIN_PROJECT ? homedir() : projects[name];
75
+ }
76
+ /**
77
+ * The project of a command: `HARNESS_PROJECT` in `env`, then the project with the longest directory that is `cwd` or
78
+ * contains it, then `main`. An unknown `HARNESS_PROJECT` is an error.
79
+ */
80
+ export function resolveProject(env, cwd, projects = readProjects()) {
81
+ if (env.HARNESS_PROJECT)
82
+ return checkProject(env.HARNESS_PROJECT, projects);
83
+ let found = MAIN_PROJECT;
84
+ let length = -1;
85
+ for (const [name, dir] of Object.entries(projects)) {
86
+ // The first project wins over a later one with the same directory.
87
+ if (dir.length > length && (cwd === dir || cwd.startsWith(dir.endsWith(sep) ? dir : dir + sep))) {
88
+ found = name;
89
+ length = dir.length;
90
+ }
91
+ }
92
+ return found;
93
+ }
@@ -0,0 +1,232 @@
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
+ /** The version of the protocol between the CLI and the host. A client refuses a host with another version. */
7
+ export declare const PROTOCOL_VERSION = 7;
8
+ /**
9
+ * The handle of the project agent, the host's root conversation, in `watch`, `input`, `abort` and `configure`. It is not
10
+ * hex, so no child has it.
11
+ */
12
+ export declare const AGENT_HANDLE = "agent";
13
+ export type ChildState = "busy" | "idle" | "error";
14
+ /** One child as the host reports it. Provider, model and thinking level come from the child's agent. */
15
+ export type ChildInfo = {
16
+ readonly handle: string;
17
+ readonly name?: string;
18
+ readonly state: ChildState;
19
+ readonly provider: string;
20
+ readonly model: string;
21
+ readonly thinking: ModelThinkingLevel;
22
+ /** The child's working directory. */
23
+ readonly cwd: string;
24
+ };
25
+ /** A task that `start` created, by its client key. `outcome` is there only when the task is terminal. */
26
+ export type TaskInfo = {
27
+ readonly key: string;
28
+ readonly id: TaskId<JsonValue>;
29
+ /** The name of the task definition. */
30
+ readonly kind: string;
31
+ /**
32
+ * The version on the task's record: the version of the definition that last ran or migrated it, so a client can tell
33
+ * which version of an entry the host used for this task. A task that no loaded definition can run keeps its version.
34
+ */
35
+ readonly version: number;
36
+ readonly status: TaskState<JsonValue, JsonValue>["status"];
37
+ readonly outcome?: TaskOutcome<JsonValue>;
38
+ };
39
+ /** A model that the host can run, with what the viewer needs to show and change it. */
40
+ export type ModelInfo = {
41
+ readonly provider: string;
42
+ readonly id: string;
43
+ readonly name: string;
44
+ readonly contextWindow: number;
45
+ /** The thinking levels of the model, from `off`. */
46
+ readonly thinkingLevels: readonly ModelThinkingLevel[];
47
+ };
48
+ /** One request line. The caller is `PI_SESSION_ID`, or `cli` without one. */
49
+ export type Request = {
50
+ readonly type: "hello";
51
+ readonly version: number;
52
+ } | {
53
+ readonly type: "spawn";
54
+ readonly requestId: string;
55
+ readonly caller: string;
56
+ readonly name?: string;
57
+ readonly provider: string;
58
+ readonly model: string;
59
+ /** The level the caller asks for; the host refuses it when the model does not have it. */
60
+ readonly thinking?: string;
61
+ /** The caller's own level, used without `thinking`, clamped to the nearest level that the model has. */
62
+ readonly callerThinking: string;
63
+ readonly cwd: string;
64
+ readonly tools?: readonly string[];
65
+ readonly prompt: string;
66
+ } | {
67
+ readonly type: "send";
68
+ readonly requestId: string;
69
+ readonly caller: string;
70
+ readonly handle: string;
71
+ readonly message: string;
72
+ readonly followUp: boolean;
73
+ } | {
74
+ readonly type: "wait";
75
+ readonly handle: string;
76
+ readonly timeout: number;
77
+ } | {
78
+ readonly type: "status";
79
+ readonly handle: string;
80
+ } | {
81
+ readonly type: "list";
82
+ readonly caller: string;
83
+ } | {
84
+ readonly type: "rename";
85
+ readonly caller: string;
86
+ readonly handle: string;
87
+ readonly name: string;
88
+ } | {
89
+ readonly type: "stop";
90
+ readonly caller: string;
91
+ readonly handle: string;
92
+ }
93
+ /**
94
+ * The viewer's stream of the child: the reply, then one `{ event: WatchLine }` line after each commit to the child's
95
+ * conversation and after each rename. Every other line on the connection stays a reply, in request order. A
96
+ * connection carries one watch.
97
+ */
98
+ | {
99
+ readonly type: "watch";
100
+ readonly handle: string;
101
+ }
102
+ /** Stops the child's current turn. The viewer sends it. */
103
+ | {
104
+ readonly type: "abort";
105
+ readonly handle: string;
106
+ }
107
+ /** The models that have a login, for the viewer's model picker and context window. */
108
+ | {
109
+ readonly type: "models";
110
+ }
111
+ /**
112
+ * The viewer's change of the child's model or thinking level. It has no caller. A new model keeps the thinking level
113
+ * when the model has it, and takes the nearest level that it has otherwise.
114
+ */
115
+ | {
116
+ readonly type: "configure";
117
+ readonly handle: string;
118
+ readonly provider?: string;
119
+ readonly model?: string;
120
+ readonly thinking?: ModelThinkingLevel;
121
+ }
122
+ /** The viewer's message to the child. It has no caller. */
123
+ | {
124
+ readonly type: "input";
125
+ readonly requestId: string;
126
+ readonly handle: string;
127
+ readonly message: string;
128
+ readonly followUp: boolean;
129
+ }
130
+ /**
131
+ * Starts a background task of the kind `kind`, a task of an entry in `entries.json`, in the root conversation.
132
+ * A repeated `key` gives the first task, and its new kind, version and input are ignored. With `version`, a host
133
+ * whose definition of `kind` has another version refuses before it creates anything.
134
+ */
135
+ | {
136
+ readonly type: "start";
137
+ readonly kind: string;
138
+ readonly input: JsonValue;
139
+ readonly key: string;
140
+ readonly version?: number;
141
+ } | {
142
+ readonly type: "task";
143
+ readonly key: string;
144
+ }
145
+ /** Waits until the task is terminal, and replies with its outcome. A client polls `task` for the status. */
146
+ | {
147
+ readonly type: "wait-task";
148
+ readonly key: string;
149
+ }
150
+ /** Aborts the task and the work that it owns. */
151
+ | {
152
+ readonly type: "abort-task";
153
+ readonly key: string;
154
+ };
155
+ /** The value of the reply to each request type. */
156
+ export type Replies = {
157
+ readonly hello: {
158
+ readonly version: number;
159
+ readonly pid: number;
160
+ };
161
+ readonly spawn: ChildInfo;
162
+ readonly send: {
163
+ readonly verb: string;
164
+ readonly child: ChildInfo;
165
+ };
166
+ readonly wait: {
167
+ readonly answer: string;
168
+ };
169
+ readonly status: ChildInfo;
170
+ readonly list: readonly ChildInfo[];
171
+ readonly rename: ChildInfo;
172
+ readonly stop: {
173
+ readonly handle: string;
174
+ readonly name?: string;
175
+ };
176
+ readonly watch: {
177
+ readonly view: ConversationView;
178
+ readonly child: ChildInfo;
179
+ };
180
+ readonly abort: ChildInfo;
181
+ readonly input: Replies["send"];
182
+ readonly models: readonly ModelInfo[];
183
+ readonly configure: ChildInfo;
184
+ readonly start: TaskInfo;
185
+ readonly task: TaskInfo;
186
+ readonly "wait-task": TaskInfo;
187
+ readonly "abort-task": TaskInfo;
188
+ };
189
+ /**
190
+ * An event of a watch after its reply, sent as `{ event: WatchLine }`: the changes of one commit or a rename, or the end
191
+ * when the child was stopped.
192
+ */
193
+ export type WatchLine = {
194
+ readonly ops: readonly Op[];
195
+ readonly child: ChildInfo;
196
+ readonly stopped?: undefined;
197
+ } | {
198
+ readonly stopped: true;
199
+ };
200
+ /** One reply line. */
201
+ export type Reply = {
202
+ readonly ok: true;
203
+ readonly value: unknown;
204
+ } | {
205
+ readonly ok: false;
206
+ readonly error: string;
207
+ };
208
+ /** The files of the host of `project`: `$XDG_STATE_HOME/harness/<project>/`, default `~/.local/state/harness/<project>/`. */
209
+ /**
210
+ * The CLI of this install, which the host start and the Herdr tabs run with this Node: `cli.js` next to the built
211
+ * modules, or `cli.ts` in a checkout, which Node runs with type stripping.
212
+ */
213
+ export declare function cliPath(): string;
214
+ export declare function statePaths(project: string): {
215
+ dir: string;
216
+ socket: string;
217
+ log: string;
218
+ };
219
+ export declare function isValidRunName(value: unknown): value is string;
220
+ /** The label of a child: its name, or its handle when it has no name. */
221
+ export declare function childLabel(child: {
222
+ readonly handle: string;
223
+ readonly name?: string | undefined;
224
+ }): string;
225
+ export declare function runDisplayName(metadata: {
226
+ readonly handle: string;
227
+ readonly name?: string | undefined;
228
+ }): string;
229
+ /** The line of one child in a list. */
230
+ export declare function childLine(child: ChildInfo): string;
231
+ /** Calls `onLine` with each line that arrives on `socket`, without its newline. */
232
+ export declare function onLines(socket: Socket, onLine: (line: string) => void): void;
@@ -0,0 +1,54 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ /** The version of the protocol between the CLI and the host. A client refuses a host with another version. */
5
+ export const PROTOCOL_VERSION = 7;
6
+ /**
7
+ * The handle of the project agent, the host's root conversation, in `watch`, `input`, `abort` and `configure`. It is not
8
+ * hex, so no child has it.
9
+ */
10
+ export const AGENT_HANDLE = "agent";
11
+ /** The files of the host of `project`: `$XDG_STATE_HOME/harness/<project>/`, default `~/.local/state/harness/<project>/`. */
12
+ /**
13
+ * The CLI of this install, which the host start and the Herdr tabs run with this Node: `cli.js` next to the built
14
+ * modules, or `cli.ts` in a checkout, which Node runs with type stripping.
15
+ */
16
+ export function cliPath() {
17
+ return fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./cli.ts" : "./cli.js", import.meta.url));
18
+ }
19
+ export function statePaths(project) {
20
+ const dir = join(process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "harness", project);
21
+ return { dir, socket: join(dir, "host.sock"), log: join(dir, "host.log") };
22
+ }
23
+ export function isValidRunName(value) {
24
+ return (typeof value === "string" &&
25
+ value.length > 0 &&
26
+ value.length <= 64 &&
27
+ value.trim() === value &&
28
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: a name must not contain control characters.
29
+ !/[\u0000-\u001f\u007f]/.test(value));
30
+ }
31
+ /** The label of a child: its name, or its handle when it has no name. */
32
+ export function childLabel(child) {
33
+ return child.name ?? child.handle;
34
+ }
35
+ export function runDisplayName(metadata) {
36
+ return metadata.name ? `${metadata.name} (${metadata.handle})` : metadata.handle;
37
+ }
38
+ /** The line of one child in a list. */
39
+ export function childLine(child) {
40
+ return `${runDisplayName(child)} ${child.state.padEnd(8)} ${child.provider}/${child.model} ${child.thinking}`;
41
+ }
42
+ /** Calls `onLine` with each line that arrives on `socket`, without its newline. */
43
+ export function onLines(socket, onLine) {
44
+ let buffer = "";
45
+ socket.setEncoding("utf8");
46
+ socket.on("data", (chunk) => {
47
+ buffer += chunk;
48
+ for (let end = buffer.indexOf("\n"); end !== -1; end = buffer.indexOf("\n")) {
49
+ const line = buffer.slice(0, end);
50
+ buffer = buffer.slice(end + 1);
51
+ onLine(line);
52
+ }
53
+ });
54
+ }
@@ -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,78 @@
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
+ return {
46
+ key,
47
+ id,
48
+ kind: record.kind,
49
+ version: record.version,
50
+ status: record.state.status,
51
+ ...(record.state.status === "terminal" ? { outcome: record.state.outcome } : {}),
52
+ };
53
+ }
54
+ /** Waits until the task of `key` is terminal, or the connection closes, and gives its info with the outcome. */
55
+ export async function waitTask(host, key) {
56
+ await host.harness.waitForTask(await taskId(host.harness, key), withAbortSignal(host.signal, context));
57
+ return taskInfo(host.harness, key);
58
+ }
59
+ /** Aborts the task of `key`. The abort reaches the conversations that the task owns, and their turns. */
60
+ export async function abortTask(harness, key) {
61
+ await harness.abortTask(await taskId(harness, key), context);
62
+ return taskInfo(harness, key);
63
+ }
64
+ /** The ID of the task that `start` created for `key`. */
65
+ async function taskId(harness, key) {
66
+ const state = await harness.snapshot(StartedTasks, context);
67
+ const id = state != null && Object.hasOwn(state.tasks, key) ? state.tasks[key] : undefined;
68
+ if (id === undefined)
69
+ throw new Error(`Unknown task: ${key}`);
70
+ return id;
71
+ }
72
+ /** The task of `kind` in `startable`. The entries give erased tasks, so this is where they get the type of `createTask`. */
73
+ function startableTask(startable, kind) {
74
+ const task = startable.tasks?.find((found) => found.definition.name === kind);
75
+ if (task === undefined)
76
+ throw new Error(`Unknown task kind: ${kind}`);
77
+ return task;
78
+ }
@@ -0,0 +1,3 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ export declare function color(name: Parameters<Theme["fg"]>[0], text: string): string;
3
+ export declare function bold(text: string): string;
package/dist/theme.js ADDED
@@ -0,0 +1,18 @@
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
+ const piTheme = (await import(__rewriteRelativeImportExtension(new URL("./modes/interactive/theme/theme.js", import.meta.resolve("@earendil-works/pi-coding-agent")).href)));
13
+ export function color(name, text) {
14
+ return piTheme.theme.fg(name, text);
15
+ }
16
+ export function bold(text) {
17
+ return piTheme.theme.bold(text);
18
+ }