@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 +13 -11
- package/dist/cli.js +12 -1
- package/dist/setup.d.ts +6 -6
- package/dist/setup.js +28 -23
- package/package.json +1 -2
- package/scripts/postinstall.js +4 -4
- package/pi/attachment.ts +0 -303
- package/pi/chat.ts +0 -286
- package/pi/index.ts +0 -170
- package/pi/mirror.ts +0 -218
- package/pi/model-sync.ts +0 -102
- package/pi/package.json +0 -15
- package/pi/program-status.ts +0 -66
- package/pi/usage-line.ts +0 -59
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# harness
|
|
2
2
|
|
|
3
|
-
Observable, persistent
|
|
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
|
|
17
|
-
|
|
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 "
|
|
22
|
+
pi install "npm:@vzlentin/pi-agents@$(harness --version)"
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
|
|
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
|
|
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
|
|
64
|
-
`
|
|
65
|
-
extension in Pi, run `npm run build`,
|
|
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
|
|
3
|
-
* stderr, and it does not throw for a Pi failure: it writes a
|
|
4
|
-
* that `setupOnce` reads unless `pi` failed, so the next run
|
|
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
|
|
3
|
-
* run it, since npm 12 does not run install scripts by default. workflows uses it
|
|
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
|
|
22
|
-
* stderr, and it does not throw for a Pi failure: it writes a
|
|
23
|
-
* that `setupOnce` reads unless `pi` failed, so the next run
|
|
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
|
-
*
|
|
80
|
-
* entry
|
|
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({
|
|
83
|
+
function registerExtension({ json }, piPackage) {
|
|
83
84
|
const label = basename(json.name);
|
|
84
|
-
const
|
|
85
|
-
if (piLists(
|
|
85
|
+
const source = `npm:${piPackage}@${json.version}`;
|
|
86
|
+
if (piLists(piPackage, source))
|
|
86
87
|
return true;
|
|
87
|
-
return runPi(label, ["install",
|
|
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
|
|
109
|
-
* the agent directory, with `~` or as a
|
|
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(
|
|
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
|
|
125
|
-
if (
|
|
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
|
-
|
|
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
|
+
"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": {
|
package/scripts/postinstall.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
// Registers `pi
|
|
2
|
-
// strip types under node_modules. Other installs, such as `npm ci` in a checkout
|
|
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
|
-
}
|