@vzlentin/harness 0.3.1 → 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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # harness
2
2
 
3
- Observable, persistent subagents on [Pi](https://github.com/earendil-works/pi)'s libraries and
3
+ Observable, persistent (sub)agents on [Pi](https://github.com/earendil-works/pi)'s libraries and
4
4
  [pi-durable](https://www.npmjs.com/package/@earendil-works/pi-durable). Each subagent is a conversation in a durable
5
5
  host process. You can spawn it, check its status, wait for its answer, steer it, and talk to it live in Pi.
6
6
 
@@ -13,22 +13,23 @@ with `/login` in `pi`, or set the API key of the provider, such as `ANTHROPIC_AP
13
13
  npm install -g @vzlentin/harness
14
14
  ```
15
15
 
16
- This works with npm 11 and npm 12. It gives you the `harness` command, and a setup runs `pi install` on the `pi/`
17
- folder of the package, which adds the `subagent` tool, the `/subagent` and `/harness` commands and the subagents widget
18
- to Pi. Pi and the `harness` command use the same host. npm 12 runs no install script by default, so there the first
16
+ This works with npm 11 and npm 12. It gives you the `harness` command, and a setup runs `pi install` on
17
+ `npm:@vzlentin/pi-agents` at the same version, which adds the `subagent` tool, the `/subagent` and `/harness` commands
18
+ and the subagents widget to Pi. Pi and the `harness` command use the same host. npm 12 runs no install script by default, so there the first
19
19
  `harness` command does the setup. When `pi` is not on your `PATH`, or the step fails, the setup prints the command to run later:
20
20
 
21
21
  ```sh
22
- pi install "$(realpath "$(npm root -g)/@vzlentin/harness/pi")"
22
+ pi install "npm:@vzlentin/pi-agents@$(harness --version)"
23
23
  ```
24
24
 
25
- When you install with `--prefix`, give the same `--prefix` to `npm root -g`.
25
+ With only Pi, run `pi install npm:@vzlentin/pi-agents` and start `pi`. The extension asks to install harness with
26
+ `npm install -g`. Answer yes, then run `/reload`.
26
27
 
27
28
  ### Update and remove
28
29
 
29
30
  ```sh
30
- npm install -g @vzlentin/harness@latest
31
- pi remove "$(realpath "$(npm root -g)/@vzlentin/harness/pi")" # first: npm runs no script on uninstall
31
+ npm install -g @vzlentin/harness@latest # the setup pins the extension to this version
32
+ pi remove npm:@vzlentin/pi-agents # npm runs no script on uninstall
32
33
  npm uninstall -g @vzlentin/harness
33
34
  ```
34
35
 
@@ -60,6 +61,7 @@ XDG_STATE_HOME=/tmp/harness-dev node src/cli.ts list
60
61
  `node src/cli.ts` runs the CLI of this checkout. Give it its own `XDG_STATE_HOME`, so its hosts do not share sockets and
61
62
  stores with the hosts of the installed `harness`.
62
63
 
63
- The checkout runs from its TypeScript source. The package contains `dist/`, the Pi extension in `pi/` and its
64
- `postinstall` script. To release, merge the release PR `release: <version>` that CI keeps open: CI then publishes that version. `npm run check` builds `dist/` too, before the type check. The extension imports `../dist/`, so to try the checkout's
65
- extension in Pi, run `npm run build`, then `pi install ./pi`, and build again after each change in `src/`.
64
+ The checkout runs from its TypeScript source. The package contains `dist/` and its `postinstall` script. The Pi
65
+ extension in `pi/` is the package `@vzlentin/pi-agents`. To release, merge the release PR `release: <version>` that CI keeps open: CI then publishes both packages at that version. `npm run check` builds `dist/` too, before the type check. The extension
66
+ loads `dist/` of the global harness, so to try the checkout's extension in Pi, run `npm run build`, `npm link`, then
67
+ `pi install ./pi`, and build again after each change in `src/`.
package/dist/cli.js CHANGED
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { readFileSync } from "node:fs";
4
5
  import { fileURLToPath } from "node:url";
6
+ import { Type } from "typebox";
7
+ import { Value } from "typebox/value";
5
8
  import { request } from "./client.js";
6
9
  import { piEnv } from "./herdr.js";
7
10
  import { piArgs } from "./mirror.js";
@@ -27,6 +30,7 @@ function usage() {
27
30
  harness attach --conversation <id>
28
31
  harness create <name> --cwd <dir>
29
32
  harness projects
33
+ harness --version
30
34
  Any command but harness <project> takes --project <name> before it; without it, the project is HARNESS_PROJECT, then the cwd.`);
31
35
  }
32
36
  function valueAfter(args, index, option) {
@@ -236,7 +240,14 @@ async function main() {
236
240
  // npm 12 runs no install script by default, so an installed CLI finishes the setup. A checkout runs `cli.ts`, and
237
241
  // the host starts from a command that already did it.
238
242
  if (import.meta.url.endsWith(".js") && command !== "host")
239
- setupOnce(fileURLToPath(new URL("..", import.meta.url)));
243
+ setupOnce(fileURLToPath(new URL("..", import.meta.url)), "@vzlentin/pi-agents");
244
+ if (command === "--version") {
245
+ if (project !== undefined || args.length !== 0)
246
+ usage();
247
+ const json = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
248
+ process.stdout.write(`${Value.Parse(Type.Object({ version: Type.String() }), json).version}\n`);
249
+ return;
250
+ }
240
251
  if (command === "create")
241
252
  return createCommand(args);
242
253
  if (command === "projects")
package/dist/setup.d.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
- * Registers `<packageDir>/pi` in Pi when `packageDir` is a top-level global npm install. It writes its messages to
3
- * stderr, and it does not throw for a Pi failure: it writes a warning with the command to run. It writes the stamp
4
- * that `setupOnce` reads unless `pi` failed, so the next run tries again. Without `pi` on PATH it writes the stamp, so
5
- * a user without Pi gets the hint once per version.
2
+ * Registers `npm:<piPackage>@<version>` in Pi when `packageDir` is a top-level global npm install, with the version of
3
+ * `<packageDir>/package.json`. It writes its messages to stderr, and it does not throw for a Pi failure: it writes a
4
+ * warning with the command to run. It writes the stamp that `setupOnce` reads unless `pi` failed, so the next run
5
+ * tries again. Without `pi` on PATH it writes the stamp, so a user without Pi gets the hint once per version.
6
6
  */
7
- export declare function setup(packageDir: string): void;
7
+ export declare function setup(packageDir: string, piPackage: string): void;
8
8
  /**
9
9
  * `setup` once per version and path of the package: it does nothing when the stamp of `setup` exists. It never
10
10
  * throws; an error is a warning.
11
11
  */
12
- export declare function setupOnce(packageDir: string): void;
12
+ export declare function setupOnce(packageDir: string, piPackage: string): void;
13
13
  /** The `PATH` without the `node_modules/.bin` folders that npm adds: pi-coding-agent there has its own `pi`. */
14
14
  export declare function userPath(): string;
package/dist/setup.js CHANGED
@@ -1,6 +1,7 @@
1
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`.
2
+ * Registers the Pi package of a globally installed package in Pi, as `npm:<piPackage>@<version>`. The `postinstall`
3
+ * script and the CLI of the package run it, since npm 12 does not run install scripts by default. workflows uses it
4
+ * too, as `@vzlentin/harness/setup`, with its own Pi package.
4
5
  */
5
6
  import { spawnSync } from "node:child_process";
6
7
  import { createHash } from "node:crypto";
@@ -18,28 +19,28 @@ const PiSettings = Type.Object({
18
19
  /** The prefixes of the Pi package sources that are not local paths, as Pi reads them. */
19
20
  const REMOTE = ["npm:", "git:", "github:", "http:", "https:", "ssh:", "builtin:"];
20
21
  /**
21
- * Registers `<packageDir>/pi` in Pi when `packageDir` is a top-level global npm install. It writes its messages to
22
- * stderr, and it does not throw for a Pi failure: it writes a warning with the command to run. It writes the stamp
23
- * that `setupOnce` reads unless `pi` failed, so the next run tries again. Without `pi` on PATH it writes the stamp, so
24
- * a user without Pi gets the hint once per version.
22
+ * Registers `npm:<piPackage>@<version>` in Pi when `packageDir` is a top-level global npm install, with the version of
23
+ * `<packageDir>/package.json`. It writes its messages to stderr, and it does not throw for a Pi failure: it writes a
24
+ * warning with the command to run. It writes the stamp that `setupOnce` reads unless `pi` failed, so the next run
25
+ * tries again. Without `pi` on PATH it writes the stamp, so a user without Pi gets the hint once per version.
25
26
  */
26
- export function setup(packageDir) {
27
+ export function setup(packageDir, piPackage) {
27
28
  const installed = readInstalled(packageDir);
28
29
  if (installed === undefined || !isTopLevelGlobal(installed))
29
30
  return;
30
- if (registerExtension(installed))
31
+ if (registerExtension(installed, piPackage))
31
32
  writeStamp(installed);
32
33
  }
33
34
  /**
34
35
  * `setup` once per version and path of the package: it does nothing when the stamp of `setup` exists. It never
35
36
  * throws; an error is a warning.
36
37
  */
37
- export function setupOnce(packageDir) {
38
+ export function setupOnce(packageDir, piPackage) {
38
39
  try {
39
40
  const installed = readInstalled(packageDir);
40
41
  if (installed === undefined || !isTopLevelGlobal(installed) || existsSync(stampPath(installed)))
41
42
  return;
42
- if (registerExtension(installed))
43
+ if (registerExtension(installed, piPackage))
43
44
  writeStamp(installed);
44
45
  }
45
46
  catch (error) {
@@ -76,15 +77,15 @@ function writeStamp(installed) {
76
77
  writeFileSync(path, `${installed.dir}\n${installed.json.version}\n`);
77
78
  }
78
79
  /**
79
- * False when `pi install` failed. It runs no `pi` when Pi already lists the extension, as `pi install` would write the
80
- * entry again in its own form, relative to the agent directory.
80
+ * Runs `pi install npm:<piPackage>@<version>` unless Pi already lists it. False when `pi install` failed. Pi replaces
81
+ * an entry with the same package name, so an older version or a range is updated in place.
81
82
  */
82
- function registerExtension({ dir, json }) {
83
+ function registerExtension({ json }, piPackage) {
83
84
  const label = basename(json.name);
84
- const extension = join(dir, "pi");
85
- if (piLists(extension))
85
+ const source = `npm:${piPackage}@${json.version}`;
86
+ if (piLists(piPackage, source))
86
87
  return true;
87
- return runPi(label, ["install", extension], `To add the Pi extension of ${label}, run: pi install ${extension}`);
88
+ return runPi(label, ["install", source], `To add the Pi extension of ${label}, run: pi install ${source}`);
88
89
  }
89
90
  /** Runs `pi`, with a warning when it is missing or fails. False when it failed; true when it worked or is missing. */
90
91
  function runPi(label, args, later) {
@@ -105,10 +106,11 @@ function runPi(label, args, later) {
105
106
  return true;
106
107
  }
107
108
  /**
108
- * Whether the `packages` of Pi's global settings list the folder `extension` as a local path, absolute or relative to
109
- * the agent directory, with `~` or as a `file:` URL, as Pi resolves them.
109
+ * Whether the `packages` of Pi's global settings list `source` exactly, or a local folder whose `package.json` has the
110
+ * name `piPackage`, at any version. A local path is absolute or relative to the agent directory, with `~` or as a
111
+ * `file:` URL, as Pi resolves it. Another remote source, such as another `npm:` spec, is not listed.
110
112
  */
111
- function piLists(extension) {
113
+ function piLists(piPackage, source) {
112
114
  const env = process.env.PI_CODING_AGENT_DIR;
113
115
  const agentDir = env ? expandHome(env) : join(homedir(), ".pi", "agent");
114
116
  let settings;
@@ -121,12 +123,15 @@ function piLists(extension) {
121
123
  if (!Value.Check(PiSettings, settings))
122
124
  return false;
123
125
  return (settings.packages ?? []).some((item) => {
124
- const source = (Value.Check(Type.String(), item) ? item : item.source).trim();
125
- if (REMOTE.some((prefix) => source.startsWith(prefix)))
126
+ const entry = (Value.Check(Type.String(), item) ? item : item.source).trim();
127
+ if (entry === source)
128
+ return true;
129
+ if (REMOTE.some((prefix) => entry.startsWith(prefix)))
126
130
  return false;
127
- const path = source.startsWith("file://") ? fileURLToPath(source) : resolve(agentDir, expandHome(source));
128
131
  try {
129
- return realpathSync(path) === extension;
132
+ const path = entry.startsWith("file://") ? fileURLToPath(entry) : resolve(agentDir, expandHome(entry));
133
+ const json = JSON.parse(readFileSync(join(path, "package.json"), "utf8"));
134
+ return Value.Check(PackageJson, json) && json.name === piPackage;
130
135
  }
131
136
  catch {
132
137
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzlentin/harness",
3
- "version": "0.3.1",
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,303 +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 { ProgramStatusReport } from "./program-status.ts";
24
- import { usageLine } from "./usage-line.ts";
25
-
26
- /** The key of the status widget above the editor. */
27
- const WIDGET = "harness-mirror";
28
-
29
- export class Attachment {
30
- readonly entryId: string;
31
- readonly models: ModelSync;
32
- // After a session switch, `#pi` and `#ctx` are stale and throw, so only code that checks `#closed` uses them. The
33
- // components that Pi draws use `#theme` instead.
34
- readonly #pi: ExtensionAPI;
35
- readonly #ctx: ExtensionContext;
36
- #theme: Theme;
37
- readonly #frame: tui.Container;
38
- readonly #status = new StatusArea();
39
- readonly #mirror: Mirror;
40
- #ui: TUI | undefined;
41
- #chat: ChatView | undefined;
42
- /** The state of the agent for Herdr, made when the agent is first known. */
43
- #reporter: AgentReporter | undefined;
44
- /** The state of the agent for the terminal, made with the TUI. */
45
- #programStatus: ProgramStatusReport | undefined;
46
- #expanded = false;
47
- #closed = false;
48
-
49
- constructor(pi: ExtensionAPI, ctx: ExtensionContext, entryId: string, target: MirrorTarget, frame: tui.Container) {
50
- this.entryId = entryId;
51
- this.#pi = pi;
52
- this.#ctx = ctx;
53
- this.#theme = ctx.ui.theme;
54
- this.#frame = frame;
55
- this.#frame.clear();
56
- this.#frame.addChild(this.#line("muted", () => `Connecting to the ${targetTitle(target)}...`));
57
- // Pi calls the factory at once, which gives the TUI that the tool cards need.
58
- ctx.ui.setWidget(
59
- WIDGET,
60
- (ui, theme) => {
61
- this.#ui = ui;
62
- this.#theme = theme;
63
- this.#programStatus ??= new ProgramStatusReport(ui.terminal);
64
-
65
- return this.#status;
66
- },
67
- { placement: "aboveEditor" },
68
- );
69
- ctx.ui.setEditorComponent((ui, theme, keybindings) => new MirrorEditor(ui, theme, keybindings, this));
70
- this.#mirror = new Mirror(target, (previous) => this.#update(previous));
71
- this.#programStatus?.update(this.#mirror);
72
- this.models = new ModelSync(pi, ctx, this.#mirror, (cause) => this.#notify(cause));
73
- }
74
-
75
- get busy(): boolean {
76
- return this.#mirror.busy;
77
- }
78
-
79
- /**
80
- * Sends `text` to the agent: a new turn when it is idle, else a steer, or a follow-up with `followUp`. A text that
81
- * does not reach the agent goes back to the editor, after the text there, so it is not lost.
82
- */
83
- send(text: string, followUp: boolean): void {
84
- this.#mirror.send(text, followUp).catch((cause: unknown) => {
85
- if (this.#closed) return;
86
- const editor = this.#ctx.ui.getEditorText();
87
- this.#ctx.ui.setEditorText([editor, text].filter((part) => part.trim() !== "").join("\n\n"));
88
- this.#ctx.ui.notify(`harness: not sent: ${errorMessage(cause)}`, "error");
89
- });
90
- }
91
-
92
- /** Aborts the agent's turn. The queued messages that the host withdraws go back to `editor`, steers first. */
93
- abort(editor: Pick<tui.Editor, "getText" | "setText">): void {
94
- this.#mirror.abort().then(
95
- (texts) => {
96
- if (this.#closed) return;
97
- const text = [...texts, editor.getText()].filter((part) => part.trim() !== "").join("\n\n");
98
-
99
- if (text !== editor.getText()) editor.setText(text);
100
- },
101
- (cause: unknown) => this.#notify(cause),
102
- );
103
- }
104
-
105
- /** Takes the theme and the expanded state of tool output that Pi gives when it draws the entry again. */
106
- restyle(theme: Theme, expanded: boolean): void {
107
- this.#theme = theme;
108
- this.#expanded = expanded;
109
- this.#chat?.setExpanded(expanded);
110
- this.showThinkingSetting();
111
- }
112
-
113
- /** Shows or hides the thinking of the answers, as Pi's `hideThinkingBlock` setting says. */
114
- showThinkingSetting(): void {
115
- if (!this.#closed) this.#chat?.setHideThinking(this.#pi.getSettings().hideThinkingBlock ?? false);
116
- }
117
-
118
- /** Closes the connection and gives Pi back its editor. The agent keeps running in the host. */
119
- close(): void {
120
- if (this.#closed) return;
121
- this.#closed = true;
122
- this.models.close();
123
- this.#mirror.close();
124
- this.#chat?.stopTimers();
125
- this.#status.dispose();
126
- this.#ctx.ui.setWidget(WIDGET, undefined);
127
- this.#ctx.ui.setEditorComponent(undefined);
128
- void this.#reporter?.release();
129
- this.#programStatus?.leave(this.#mirror);
130
- }
131
-
132
- /** Shows each change of the mirror, whose status was `previous` before it. */
133
- #update(previous: MirrorStatus): void {
134
- const ui = this.#ui;
135
-
136
- if (ui === undefined || this.#closed) return;
137
- const { status, view, child } = this.#mirror;
138
-
139
- if (view !== undefined && child !== undefined) this.#showTranscript(ui, view, child, previous);
140
- this.#showStatus(ui, view, child);
141
-
142
- if (status.kind === "failed" && previous.kind !== "failed") this.#showFailure(status.reason);
143
- this.#reporter?.report(this.#mirror.busy);
144
- this.#programStatus?.update(this.#mirror);
145
-
146
- if (child !== undefined) this.#followAgent(child);
147
- ui.requestRender();
148
- }
149
-
150
- /**
151
- * Shows the transcript under its header, and makes the Herdr reporter at the first connection. It applies `view` only
152
- * on an open connection.
153
- */
154
- #showTranscript(ui: TUI, view: ConversationView, child: ChildInfo, previous: MirrorStatus): void {
155
- const chat = this.#chat ?? this.#startTranscript(ui, child);
156
-
157
- if (this.#mirror.status.kind === "open") chat.apply(view);
158
- // A running bash card keeps its timer until it gets a final result, and no connection gives one now.
159
- else if (previous.kind === "open") chat.stopTimers();
160
- }
161
-
162
- #startTranscript(ui: TUI, first: ChildInfo): ChatView {
163
- const chat = new ChatView(ui, first.cwd, (name, text) => this.#theme.fg(name, text));
164
- this.#chat = chat;
165
- chat.setExpanded(this.#expanded);
166
- this.showThinkingSetting();
167
- this.#frame.clear();
168
- this.#frame.addChild(this.#line("muted", () => this.#header(this.#mirror.child ?? first)));
169
- this.#frame.addChild(chat.transcript);
170
- this.#reporter = agentReporter(process.env, childLabel(first), this.#mirror.target);
171
-
172
- return chat;
173
- }
174
-
175
- /** Shows above the editor what the agent does, its queued messages and its usage line. */
176
- #showStatus(ui: TUI, view: ConversationView | undefined, child: ChildInfo | undefined): void {
177
- const { status, busy } = this.#mirror;
178
- const lines: string[] = [];
179
-
180
- if (view !== undefined && status.kind === "open") {
181
- for (const { mode, text } of queued(inboxOf(view))) {
182
- lines.push(this.#theme.fg("dim", `${mode === "steer" ? "Steer" : "Follow-up"}: ${text.split("\n")[0] ?? ""}`));
183
- }
184
- }
185
-
186
- if (view !== undefined && child !== undefined) {
187
- lines.push(usageLine(this.#theme, this.#ctx.modelRegistry, child, view));
188
- }
189
-
190
- const working =
191
- status.kind === "reconnecting"
192
- ? "Connecting again to the harness host..."
193
- : busy && view !== undefined
194
- ? statusText(liveOf(view)) || "Working..."
195
- : "";
196
-
197
- this.#status.show(ui, this.#theme, working, lines);
198
- }
199
-
200
- #showFailure(reason: string): void {
201
- // Without a transcript, the error takes the place of the connecting line.
202
- if (this.#chat === undefined) this.#frame.clear();
203
- this.#frame.addChild(this.#line("error", () => reason));
204
- this.#ctx.ui.notify(`harness: ${reason}`, "error");
205
- }
206
-
207
- /** Gives Pi the agent's title, and on an open connection its model and thinking level. */
208
- #followAgent(child: ChildInfo): void {
209
- if (this.#mirror.status.kind === "open") this.models.fromAgent(child);
210
- const title = childTitle(child);
211
-
212
- if (this.#pi.getSessionName() !== title) this.#pi.setSessionName(title);
213
- }
214
-
215
- #notify(cause: unknown): void {
216
- if (!this.#closed) this.#ctx.ui.notify(`harness: ${errorMessage(cause)}`, "error");
217
- }
218
-
219
- #header(child: ChildInfo): string {
220
- const theme = this.#theme;
221
-
222
- return [theme.bold(theme.fg("accent", "harness")), theme.fg("text", childTitle(child)), child.cwd].join(" │ ");
223
- }
224
-
225
- /** One line in the theme color `name`, colored at each render so that it follows a change of theme. */
226
- #line(name: ThemeColor, text: () => string): Component {
227
- return {
228
- render: (width) => [tui.truncateToWidth(` ${this.#theme.fg(name, text())}`, width)],
229
- invalidate: () => {},
230
- };
231
- }
232
- }
233
-
234
- /**
235
- * The editor of a mirror session. While the agent works, the interrupt key (Esc) aborts its turn and the follow-up key
236
- * (Alt+Enter) queues the text as a follow-up. Pi's editor handles every other key. After the key that hides thinking
237
- * (Ctrl+T), which Pi applies only to its own messages, the transcript of the agent follows the setting. After the key
238
- * that cycles the thinking level (Shift+Tab), the agent gets the level of Pi.
239
- */
240
- class MirrorEditor extends coding.CustomEditor {
241
- readonly #keys: KeybindingsManager;
242
- readonly #attachment: Attachment;
243
-
244
- constructor(ui: TUI, theme: EditorTheme, keys: KeybindingsManager, attachment: Attachment) {
245
- super(ui, theme, keys);
246
- this.#keys = keys;
247
- this.#attachment = attachment;
248
- }
249
-
250
- handleInput(data: string): void {
251
- if (this.#attachment.busy && this.#keys.matches(data, "app.interrupt") && !this.isShowingAutocomplete()) {
252
- this.#attachment.abort(this);
253
-
254
- return;
255
- }
256
-
257
- if (this.#attachment.busy && this.#keys.matches(data, "app.message.followUp")) {
258
- const text = this.getExpandedText().trim();
259
-
260
- if (text !== "") {
261
- this.addToHistory(text);
262
- this.setText("");
263
- this.#attachment.send(text, true);
264
- }
265
-
266
- return;
267
- }
268
-
269
- super.handleInput(data);
270
-
271
- if (this.#keys.matches(data, "app.thinking.toggle")) this.#attachment.showThinkingSetting();
272
-
273
- if (this.#keys.matches(data, "app.thinking.cycle")) this.#attachment.models.piCycled();
274
- }
275
- }
276
-
277
- /** The status of the agent above the editor: Pi's working indicator while it works, then some lines. */
278
- class StatusArea extends tui.Container {
279
- #loader: tui.Loader | undefined;
280
-
281
- /** Shows `status` with a spinner, or nothing when it is empty, then each of `lines`, cut to the width. */
282
- show(ui: TUI, theme: Theme, status: string, lines: readonly string[]): void {
283
- if (status === "") {
284
- this.dispose();
285
- } else if (this.#loader === undefined) {
286
- const spinner = (text: string) => theme.fg("accent", text);
287
- this.#loader = new tui.Loader(ui, spinner, (text) => theme.fg("muted", text), status);
288
- } else {
289
- this.#loader.setMessage(status);
290
- }
291
-
292
- this.clear();
293
-
294
- if (this.#loader !== undefined) this.addChild(this.#loader);
295
-
296
- for (const line of lines) this.addChild(new tui.TruncatedText(line, 1, 0));
297
- }
298
-
299
- dispose(): void {
300
- this.#loader?.stop();
301
- this.#loader = undefined;
302
- }
303
- }