@vzlentin/harness 0.3.0 → 0.4.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,140 @@
1
+ /**
2
+ * The targets of the host's requests: the children by handle, the project agent, and the conversations of the children
3
+ * library by ID, with the `Children` records of the host.
4
+ */
5
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
6
+ import * as durable from "@earendil-works/pi-durable";
7
+ import { defineDoc } from "@earendil-works/pi-durable";
8
+ import { createChildren } from "./children.js";
9
+ import { AGENT_HANDLE } from "./protocol.js";
10
+ const context = BACKGROUND_CONTEXT;
11
+ /**
12
+ * The children by handle, and the handle of each spawn request ID. Version 2 leaves each child's last request to the
13
+ * children library; a store with version 1 has children that the library does not know.
14
+ */
15
+ export const Children = defineDoc({
16
+ kind: "subagent.children",
17
+ version: 2,
18
+ scope: "session",
19
+ initial: () => ({ children: {}, requests: {} }),
20
+ });
21
+ /** The request ID of the project agent's newest input, which its `error` state comes from. */
22
+ const AgentState = defineDoc({
23
+ kind: "harness.agent",
24
+ version: 1,
25
+ scope: "conversation",
26
+ history: "latest",
27
+ fork: "initial",
28
+ initial: () => ({ lastRequest: null }),
29
+ });
30
+ /** The children library, on this process's pi-durable. */
31
+ export const library = createChildren(durable);
32
+ /**
33
+ * The conversation of `ref`: a child or the project agent by handle, or a conversation of the children library by ID,
34
+ * with its info, its input, and the record of a child.
35
+ */
36
+ export async function target(handler, ref) {
37
+ if (ref.conversation !== undefined)
38
+ return conversationTarget(handler, ref.conversation);
39
+ const { handle } = ref;
40
+ if (handle !== AGENT_HANDLE)
41
+ return childTarget(handler, handle, await childRecord(handler.harness, handle));
42
+ const root = await handler.agent();
43
+ const { harness, place, project } = handler;
44
+ const turn = library.turn(place, root.id, root, async (readContext) => (await harness.snapshot(AgentState, root.id, readContext))?.lastRequest, async (tx, requestId) => {
45
+ (await tx.doc(AgentState, root.id)).lastRequest = requestId;
46
+ });
47
+ return {
48
+ conversation: root,
49
+ info: async () => ({
50
+ ...(await agentFields(root)),
51
+ handle,
52
+ name: `${project} agent`,
53
+ state: await turn.state(context),
54
+ }),
55
+ send: (input) => turn.send(input, context),
56
+ record: undefined,
57
+ };
58
+ }
59
+ /**
60
+ * The target of the library conversation `id`, of any owner, such as a child of a task. It has no record, and it takes
61
+ * input only while it works.
62
+ */
63
+ async function conversationTarget(handler, id) {
64
+ const child = await library.inspect(handler.place, id, context);
65
+ if (child === undefined)
66
+ throw new Error(`Unknown conversation: ${id}`);
67
+ const { conversation, lineage } = child;
68
+ const info = async () => {
69
+ const childState = await child.state(context);
70
+ const state = childState === "closed" ? "idle" : childState;
71
+ return { handle: id, conversation: id, name: lineage.key, state, ...(await agentFields(conversation)) };
72
+ };
73
+ return {
74
+ conversation,
75
+ info,
76
+ send: async (input) => {
77
+ if ((await child.state(context)) !== "busy")
78
+ throw new Error(`${lineage.key} takes input only while it works`);
79
+ // Small race: a turn that ends between the check and the submit starts a new turn; needs busy-only admission in
80
+ // pi-durable.
81
+ await conversation.submit({ ...input, whenBusy: "steer" }, context);
82
+ },
83
+ record: undefined,
84
+ };
85
+ }
86
+ /** The target of the child `handle`, whose record is `record`. */
87
+ export async function childTarget(handler, handle, record) {
88
+ const child = await childOf(handler, record);
89
+ return {
90
+ conversation: child.conversation,
91
+ info: () => childInfo(handler, handle, record),
92
+ send: (input) => child.send(input, context),
93
+ record,
94
+ };
95
+ }
96
+ export async function childRecord(harness, handle) {
97
+ const state = await harness.snapshot(Children, context);
98
+ const record = recordOf(state, handle);
99
+ if (record === undefined)
100
+ throw new Error(`Unknown subagent: ${handle}`);
101
+ return record;
102
+ }
103
+ /** The record of `handle`, which must belong to `caller`. */
104
+ export async function ownRecord(harness, handle, caller) {
105
+ const record = await childRecord(harness, handle);
106
+ if (record.caller !== caller)
107
+ throw new Error(`Subagent ${handle} belongs to another session`);
108
+ return record;
109
+ }
110
+ export async function childOf(handler, record) {
111
+ const child = await library.find(handler.place, handler.owner, { id: String(record.conversationId) }, context);
112
+ if (child === undefined)
113
+ throw new Error(`Missing conversation ${record.conversationId}`);
114
+ return child;
115
+ }
116
+ /** The info of `handle`. A caller that already has the record gives it as `record`. */
117
+ export async function childInfo(handler, handle, record) {
118
+ record ??= await childRecord(handler.harness, handle);
119
+ const child = await childOf(handler, record);
120
+ const childState = await child.state(context);
121
+ // A closed child is idle: the watch can still send it before its record goes and the `stopped` line follows.
122
+ const state = childState === "closed" ? "idle" : childState;
123
+ const fields = await agentFields(child.conversation);
124
+ return record.name === undefined ? { handle, state, ...fields } : { handle, name: record.name, state, ...fields };
125
+ }
126
+ /** The model, thinking level and directory of the agent of `conversation`, for its info. */
127
+ async function agentFields(conversation) {
128
+ const agent = await conversation.agent(context);
129
+ return {
130
+ provider: agent.model?.provider ?? "",
131
+ model: agent.model?.modelId ?? "",
132
+ thinking: agent.thinkingLevel,
133
+ // `spawnChild` refuses a spawn without `cwd`; the fallback only satisfies pi-durable's optional type.
134
+ cwd: agent.cwd ?? "",
135
+ };
136
+ }
137
+ /** The record of `handle` in the `Children` value `state`. */
138
+ export function recordOf(state, handle) {
139
+ return state != null && Object.hasOwn(state.children, handle) ? state.children[handle] : undefined;
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzlentin/harness",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A durable agent harness on Pi, starting with observable, persistent subagents",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,7 +19,6 @@
19
19
  },
20
20
  "files": [
21
21
  "dist",
22
- "pi",
23
22
  "scripts/postinstall.js"
24
23
  ],
25
24
  "engines": {
@@ -1,12 +1,12 @@
1
- // Registers `pi/` in Pi after a global install, with `setup` of `dist/setup.js`. Plain JavaScript, since Node does not
2
- // strip types under node_modules. Other installs, such as `npm ci` in a checkout without `dist/`, do nothing, and an
3
- // error must not fail the npm install.
1
+ // Registers `npm:@vzlentin/pi-agents@<version>` in Pi after a global install, with `setup` of `dist/setup.js`. Plain
2
+ // JavaScript, since Node does not strip types under node_modules. Other installs, such as `npm ci` in a checkout
3
+ // without `dist/`, do nothing, and an error must not fail the npm install.
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  if (process.env.npm_config_global === "true") {
7
7
  try {
8
8
  const { setup } = await import("../dist/setup.js");
9
- setup(fileURLToPath(new URL("..", import.meta.url)));
9
+ setup(fileURLToPath(new URL("..", import.meta.url)), "@vzlentin/pi-agents");
10
10
  } catch (error) {
11
11
  console.warn(`harness: setup failed: ${error instanceof Error ? error.message : String(error)}`);
12
12
  }
package/pi/attachment.ts DELETED
@@ -1,296 +0,0 @@
1
- /**
2
- * The mirror of one Pi session: the frame of its entry in Pi's chat, which holds the transcript, the status above the
3
- * editor, and an editor whose interrupt and follow-up keys act on the agent while it works. The connection to the host
4
- * is the `Mirror`, and the model and thinking level of the session are the `ModelSync`.
5
- */
6
- import type {
7
- ExtensionAPI,
8
- ExtensionContext,
9
- KeybindingsManager,
10
- Theme,
11
- ThemeColor,
12
- } from "@earendil-works/pi-coding-agent";
13
- import * as coding from "@earendil-works/pi-coding-agent";
14
- import type { ConversationView } from "@earendil-works/pi-durable";
15
- import type { Component, EditorTheme, TUI } from "@earendil-works/pi-tui";
16
- import * as tui from "@earendil-works/pi-tui";
17
- import { type AgentReporter, agentReporter } from "../dist/herdr.js";
18
- import { Mirror, type MirrorStatus, type MirrorTarget, targetTitle } from "../dist/mirror.js";
19
- import { type ChildInfo, childLabel, childTitle, errorMessage } from "../dist/protocol.js";
20
- import { inboxOf, liveOf, queued, statusText } from "../dist/view-state.js";
21
- import { ChatView } from "./chat.ts";
22
- import { ModelSync } from "./model-sync.ts";
23
- import { usageLine } from "./usage-line.ts";
24
-
25
- /** The key of the status widget above the editor. */
26
- const WIDGET = "harness-mirror";
27
-
28
- export class Attachment {
29
- readonly entryId: string;
30
- readonly models: ModelSync;
31
- // After a session switch, `#pi` and `#ctx` are stale and throw, so only code that checks `#closed` uses them. The
32
- // components that Pi draws use `#theme` instead.
33
- readonly #pi: ExtensionAPI;
34
- readonly #ctx: ExtensionContext;
35
- #theme: Theme;
36
- readonly #frame: tui.Container;
37
- readonly #status = new StatusArea();
38
- readonly #mirror: Mirror;
39
- #ui: TUI | undefined;
40
- #chat: ChatView | undefined;
41
- /** The state of the agent for Herdr, made when the agent is first known. */
42
- #reporter: AgentReporter | undefined;
43
- #expanded = false;
44
- #closed = false;
45
-
46
- constructor(pi: ExtensionAPI, ctx: ExtensionContext, entryId: string, target: MirrorTarget, frame: tui.Container) {
47
- this.entryId = entryId;
48
- this.#pi = pi;
49
- this.#ctx = ctx;
50
- this.#theme = ctx.ui.theme;
51
- this.#frame = frame;
52
- this.#frame.clear();
53
- this.#frame.addChild(this.#line("muted", () => `Connecting to the ${targetTitle(target)}...`));
54
- // Pi calls the factory at once, which gives the TUI that the tool cards need.
55
- ctx.ui.setWidget(
56
- WIDGET,
57
- (ui, theme) => {
58
- this.#ui = ui;
59
- this.#theme = theme;
60
-
61
- return this.#status;
62
- },
63
- { placement: "aboveEditor" },
64
- );
65
- ctx.ui.setEditorComponent((ui, theme, keybindings) => new MirrorEditor(ui, theme, keybindings, this));
66
- this.#mirror = new Mirror(target, (previous) => this.#update(previous));
67
- this.models = new ModelSync(pi, ctx, this.#mirror, (cause) => this.#notify(cause));
68
- }
69
-
70
- get busy(): boolean {
71
- return this.#mirror.busy;
72
- }
73
-
74
- /**
75
- * Sends `text` to the agent: a new turn when it is idle, else a steer, or a follow-up with `followUp`. A text that
76
- * does not reach the agent goes back to the editor, after the text there, so it is not lost.
77
- */
78
- send(text: string, followUp: boolean): void {
79
- this.#mirror.send(text, followUp).catch((cause: unknown) => {
80
- if (this.#closed) return;
81
- const editor = this.#ctx.ui.getEditorText();
82
- this.#ctx.ui.setEditorText([editor, text].filter((part) => part.trim() !== "").join("\n\n"));
83
- this.#ctx.ui.notify(`harness: not sent: ${errorMessage(cause)}`, "error");
84
- });
85
- }
86
-
87
- /** Aborts the agent's turn. The queued messages that the host withdraws go back to `editor`, steers first. */
88
- abort(editor: Pick<tui.Editor, "getText" | "setText">): void {
89
- this.#mirror.abort().then(
90
- (texts) => {
91
- if (this.#closed) return;
92
- const text = [...texts, editor.getText()].filter((part) => part.trim() !== "").join("\n\n");
93
-
94
- if (text !== editor.getText()) editor.setText(text);
95
- },
96
- (cause: unknown) => this.#notify(cause),
97
- );
98
- }
99
-
100
- /** Takes the theme and the expanded state of tool output that Pi gives when it draws the entry again. */
101
- restyle(theme: Theme, expanded: boolean): void {
102
- this.#theme = theme;
103
- this.#expanded = expanded;
104
- this.#chat?.setExpanded(expanded);
105
- this.showThinkingSetting();
106
- }
107
-
108
- /** Shows or hides the thinking of the answers, as Pi's `hideThinkingBlock` setting says. */
109
- showThinkingSetting(): void {
110
- if (!this.#closed) this.#chat?.setHideThinking(this.#pi.getSettings().hideThinkingBlock ?? false);
111
- }
112
-
113
- /** Closes the connection and gives Pi back its editor. The agent keeps running in the host. */
114
- close(): void {
115
- if (this.#closed) return;
116
- this.#closed = true;
117
- this.models.close();
118
- this.#mirror.close();
119
- this.#chat?.stopTimers();
120
- this.#status.dispose();
121
- this.#ctx.ui.setWidget(WIDGET, undefined);
122
- this.#ctx.ui.setEditorComponent(undefined);
123
- void this.#reporter?.release();
124
- }
125
-
126
- /** Shows each change of the mirror, whose status was `previous` before it. */
127
- #update(previous: MirrorStatus): void {
128
- const ui = this.#ui;
129
-
130
- if (ui === undefined || this.#closed) return;
131
- const { status, view, child } = this.#mirror;
132
-
133
- if (view !== undefined && child !== undefined) this.#showTranscript(ui, view, child, previous);
134
- this.#showStatus(ui, view, child);
135
-
136
- if (status.kind === "failed" && previous.kind !== "failed") this.#showFailure(status.reason);
137
- this.#reporter?.report(this.#mirror.busy);
138
-
139
- if (child !== undefined) this.#followAgent(child);
140
- ui.requestRender();
141
- }
142
-
143
- /**
144
- * Shows the transcript under its header, and makes the Herdr reporter at the first connection. It applies `view` only
145
- * on an open connection.
146
- */
147
- #showTranscript(ui: TUI, view: ConversationView, child: ChildInfo, previous: MirrorStatus): void {
148
- const chat = this.#chat ?? this.#startTranscript(ui, child);
149
-
150
- if (this.#mirror.status.kind === "open") chat.apply(view);
151
- // A running bash card keeps its timer until it gets a final result, and no connection gives one now.
152
- else if (previous.kind === "open") chat.stopTimers();
153
- }
154
-
155
- #startTranscript(ui: TUI, first: ChildInfo): ChatView {
156
- const chat = new ChatView(ui, first.cwd, (name, text) => this.#theme.fg(name, text));
157
- this.#chat = chat;
158
- chat.setExpanded(this.#expanded);
159
- this.showThinkingSetting();
160
- this.#frame.clear();
161
- this.#frame.addChild(this.#line("muted", () => this.#header(this.#mirror.child ?? first)));
162
- this.#frame.addChild(chat.transcript);
163
- this.#reporter = agentReporter(process.env, childLabel(first), this.#mirror.target);
164
-
165
- return chat;
166
- }
167
-
168
- /** Shows above the editor what the agent does, its queued messages and its usage line. */
169
- #showStatus(ui: TUI, view: ConversationView | undefined, child: ChildInfo | undefined): void {
170
- const { status, busy } = this.#mirror;
171
- const lines: string[] = [];
172
-
173
- if (view !== undefined && status.kind === "open") {
174
- for (const { mode, text } of queued(inboxOf(view))) {
175
- lines.push(this.#theme.fg("dim", `${mode === "steer" ? "Steer" : "Follow-up"}: ${text.split("\n")[0] ?? ""}`));
176
- }
177
- }
178
-
179
- if (view !== undefined && child !== undefined) {
180
- lines.push(usageLine(this.#theme, this.#ctx.modelRegistry, child, view));
181
- }
182
-
183
- const working =
184
- status.kind === "reconnecting"
185
- ? "Connecting again to the harness host..."
186
- : busy && view !== undefined
187
- ? statusText(liveOf(view)) || "Working..."
188
- : "";
189
-
190
- this.#status.show(ui, this.#theme, working, lines);
191
- }
192
-
193
- #showFailure(reason: string): void {
194
- // Without a transcript, the error takes the place of the connecting line.
195
- if (this.#chat === undefined) this.#frame.clear();
196
- this.#frame.addChild(this.#line("error", () => reason));
197
- this.#ctx.ui.notify(`harness: ${reason}`, "error");
198
- }
199
-
200
- /** Gives Pi the agent's title, and on an open connection its model and thinking level. */
201
- #followAgent(child: ChildInfo): void {
202
- if (this.#mirror.status.kind === "open") this.models.fromAgent(child);
203
- const title = childTitle(child);
204
-
205
- if (this.#pi.getSessionName() !== title) this.#pi.setSessionName(title);
206
- }
207
-
208
- #notify(cause: unknown): void {
209
- if (!this.#closed) this.#ctx.ui.notify(`harness: ${errorMessage(cause)}`, "error");
210
- }
211
-
212
- #header(child: ChildInfo): string {
213
- const theme = this.#theme;
214
-
215
- return [theme.bold(theme.fg("accent", "harness")), theme.fg("text", childTitle(child)), child.cwd].join(" │ ");
216
- }
217
-
218
- /** One line in the theme color `name`, colored at each render so that it follows a change of theme. */
219
- #line(name: ThemeColor, text: () => string): Component {
220
- return {
221
- render: (width) => [tui.truncateToWidth(` ${this.#theme.fg(name, text())}`, width)],
222
- invalidate: () => {},
223
- };
224
- }
225
- }
226
-
227
- /**
228
- * The editor of a mirror session. While the agent works, the interrupt key (Esc) aborts its turn and the follow-up key
229
- * (Alt+Enter) queues the text as a follow-up. Pi's editor handles every other key. After the key that hides thinking
230
- * (Ctrl+T), which Pi applies only to its own messages, the transcript of the agent follows the setting. After the key
231
- * that cycles the thinking level (Shift+Tab), the agent gets the level of Pi.
232
- */
233
- class MirrorEditor extends coding.CustomEditor {
234
- readonly #keys: KeybindingsManager;
235
- readonly #attachment: Attachment;
236
-
237
- constructor(ui: TUI, theme: EditorTheme, keys: KeybindingsManager, attachment: Attachment) {
238
- super(ui, theme, keys);
239
- this.#keys = keys;
240
- this.#attachment = attachment;
241
- }
242
-
243
- handleInput(data: string): void {
244
- if (this.#attachment.busy && this.#keys.matches(data, "app.interrupt") && !this.isShowingAutocomplete()) {
245
- this.#attachment.abort(this);
246
-
247
- return;
248
- }
249
-
250
- if (this.#attachment.busy && this.#keys.matches(data, "app.message.followUp")) {
251
- const text = this.getExpandedText().trim();
252
-
253
- if (text !== "") {
254
- this.addToHistory(text);
255
- this.setText("");
256
- this.#attachment.send(text, true);
257
- }
258
-
259
- return;
260
- }
261
-
262
- super.handleInput(data);
263
-
264
- if (this.#keys.matches(data, "app.thinking.toggle")) this.#attachment.showThinkingSetting();
265
-
266
- if (this.#keys.matches(data, "app.thinking.cycle")) this.#attachment.models.piCycled();
267
- }
268
- }
269
-
270
- /** The status of the agent above the editor: Pi's working indicator while it works, then some lines. */
271
- class StatusArea extends tui.Container {
272
- #loader: tui.Loader | undefined;
273
-
274
- /** Shows `status` with a spinner, or nothing when it is empty, then each of `lines`, cut to the width. */
275
- show(ui: TUI, theme: Theme, status: string, lines: readonly string[]): void {
276
- if (status === "") {
277
- this.dispose();
278
- } else if (this.#loader === undefined) {
279
- const spinner = (text: string) => theme.fg("accent", text);
280
- this.#loader = new tui.Loader(ui, spinner, (text) => theme.fg("muted", text), status);
281
- } else {
282
- this.#loader.setMessage(status);
283
- }
284
-
285
- this.clear();
286
-
287
- if (this.#loader !== undefined) this.addChild(this.#loader);
288
-
289
- for (const line of lines) this.addChild(new tui.TruncatedText(line, 1, 0));
290
- }
291
-
292
- dispose(): void {
293
- this.#loader?.stop();
294
- this.#loader = undefined;
295
- }
296
- }