@vzlentin/harness 0.3.0 → 0.3.1
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 +1 -1
- package/dist/children.d.ts +5 -0
- package/dist/children.js +14 -4
- package/dist/cli.js +14 -7
- package/dist/client.d.ts +4 -4
- package/dist/client.js +5 -4
- package/dist/herdr.d.ts +14 -0
- package/dist/herdr.js +65 -12
- package/dist/host.js +15 -109
- package/dist/mirror.d.ts +22 -7
- package/dist/mirror.js +40 -12
- package/dist/protocol.d.ts +59 -24
- package/dist/protocol.js +29 -12
- package/dist/targets.d.ts +67 -0
- package/dist/targets.js +140 -0
- package/package.json +1 -1
- package/pi/attachment.ts +7 -0
- package/pi/mirror.ts +22 -10
- package/pi/program-status.ts +66 -0
package/README.md
CHANGED
|
@@ -61,5 +61,5 @@ XDG_STATE_HOME=/tmp/harness-dev node src/cli.ts list
|
|
|
61
61
|
stores with the hosts of the installed `harness`.
|
|
62
62
|
|
|
63
63
|
The checkout runs from its TypeScript source. The package contains `dist/`, the Pi extension in `pi/` and its
|
|
64
|
-
`postinstall` script.
|
|
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
65
|
extension in Pi, run `npm run build`, then `pi install ./pi`, and build again after each change in `src/`.
|
package/dist/children.d.ts
CHANGED
|
@@ -183,6 +183,11 @@ export type Children = {
|
|
|
183
183
|
} | {
|
|
184
184
|
readonly id: string;
|
|
185
185
|
}, context: Context): Promise<Child | undefined>;
|
|
186
|
+
/**
|
|
187
|
+
* The lineage, conversation and state of the child with the conversation ID `id`, of any owner, without its owner
|
|
188
|
+
* operations. Nothing for a root, an unknown ID, or text that is not an ID.
|
|
189
|
+
*/
|
|
190
|
+
inspect(place: Place, id: string, context: Context): Promise<Pick<Child, "lineage" | "conversation" | "state"> | undefined>;
|
|
186
191
|
/** Where the conversation `id` sits in its tree; nothing for a root, or another conversation the library did not open. */
|
|
187
192
|
lineage(store: Pick<Store, "snapshot">, id: string, context: Context): Promise<Lineage | undefined>;
|
|
188
193
|
/**
|
package/dist/children.js
CHANGED
|
@@ -245,7 +245,8 @@ export function createChildren(durable) {
|
|
|
245
245
|
},
|
|
246
246
|
};
|
|
247
247
|
}
|
|
248
|
-
|
|
248
|
+
/** The child of a key or a conversation ID, of any owner, with its document. */
|
|
249
|
+
async function load(place, which, context) {
|
|
249
250
|
const id = "key" in which
|
|
250
251
|
? (await place.store.snapshot(Keys, which.key, context))?.conversation
|
|
251
252
|
: conversationIdOf(which.id);
|
|
@@ -255,15 +256,24 @@ export function createChildren(durable) {
|
|
|
255
256
|
const conversation = await place.store.conversation(id, context);
|
|
256
257
|
if (doc === undefined || conversation === undefined)
|
|
257
258
|
return undefined;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
259
|
+
return { child: makeChild(place, id, lineageOf(doc), conversation), owner: doc.owner };
|
|
260
|
+
}
|
|
261
|
+
const find = async (place, owner, which, context) => {
|
|
262
|
+
const loaded = await load(place, which, context);
|
|
263
|
+
if (loaded === undefined)
|
|
264
|
+
return undefined;
|
|
265
|
+
if (!sameOwner(loaded.owner, ownerState(owner))) {
|
|
266
|
+
throw new Error(`The child ${loaded.child.id} belongs to another owner`);
|
|
267
|
+
}
|
|
268
|
+
return loaded.child;
|
|
261
269
|
};
|
|
270
|
+
const inspect = async (place, id, context) => (await load(place, { id }, context))?.child;
|
|
262
271
|
return {
|
|
263
272
|
extension,
|
|
264
273
|
answerExtension: answers.extension,
|
|
265
274
|
lineage,
|
|
266
275
|
find,
|
|
276
|
+
inspect,
|
|
267
277
|
turn,
|
|
268
278
|
async open(place, owner, key, spec, context, setup) {
|
|
269
279
|
const existing = await find(place, owner, { key }, context);
|
package/dist/cli.js
CHANGED
|
@@ -24,6 +24,7 @@ function usage() {
|
|
|
24
24
|
harness stop <handle>
|
|
25
25
|
harness list
|
|
26
26
|
harness attach <handle>
|
|
27
|
+
harness attach --conversation <id>
|
|
27
28
|
harness create <name> --cwd <dir>
|
|
28
29
|
harness projects
|
|
29
30
|
Any command but harness <project> takes --project <name> before it; without it, the project is HARNESS_PROJECT, then the cwd.`);
|
|
@@ -182,12 +183,17 @@ async function hostCommand(args, project) {
|
|
|
182
183
|
await host.closed;
|
|
183
184
|
process.exit(0);
|
|
184
185
|
}
|
|
185
|
-
/** Opens a child in Pi in this terminal. */
|
|
186
|
+
/** Opens a child, or a conversation of the children library with `--conversation <id>`, in Pi in this terminal. */
|
|
186
187
|
async function attachCommand(args) {
|
|
187
|
-
const [
|
|
188
|
-
|
|
188
|
+
const [first, id] = args;
|
|
189
|
+
let ref;
|
|
190
|
+
if (args.length === 1 && first !== undefined && first !== "--conversation")
|
|
191
|
+
ref = { handle: first };
|
|
192
|
+
else if (args.length === 2 && first === "--conversation" && id)
|
|
193
|
+
ref = { conversation: id };
|
|
194
|
+
else
|
|
189
195
|
usage();
|
|
190
|
-
await openInPi({ project: resolveProject(process.env, process.cwd()),
|
|
196
|
+
await openInPi({ project: resolveProject(process.env, process.cwd()), ...ref });
|
|
191
197
|
}
|
|
192
198
|
/** Opens the agent of the project `name`, which is `main` or a project, in Pi in this terminal. */
|
|
193
199
|
async function agentCommand(name, project) {
|
|
@@ -197,11 +203,12 @@ async function agentCommand(name, project) {
|
|
|
197
203
|
}
|
|
198
204
|
/**
|
|
199
205
|
* Runs Pi in this terminal with a mirror session of `target` (see the Pi extension), and exits with its code. It starts
|
|
200
|
-
* the host when none runs. A first watch fails before Pi starts for an unknown child
|
|
201
|
-
* model.
|
|
206
|
+
* the host when none runs. A first watch fails before Pi starts for an unknown child, an unknown conversation and a
|
|
207
|
+
* project agent without a model.
|
|
202
208
|
*/
|
|
203
209
|
async function openInPi(target) {
|
|
204
|
-
|
|
210
|
+
const ref = target.conversation === undefined ? { handle: target.handle } : { conversation: target.conversation };
|
|
211
|
+
await request({ type: "watch", ...ref }, { start: true, project: target.project });
|
|
205
212
|
const result = spawnSync("pi", piArgs(target), {
|
|
206
213
|
stdio: "inherit",
|
|
207
214
|
env: { ...piEnv(process.env), PATH: userPath() },
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Socket } from "node:net";
|
|
2
|
-
import { type Replies, type Request, type WatchLine } from "./protocol.ts";
|
|
2
|
+
import { type Replies, type Request, type Target, type WatchLine } from "./protocol.ts";
|
|
3
3
|
/**
|
|
4
4
|
* A connection to the host. Requests are answered in order, one reply line for each request line. After `watch`, the
|
|
5
5
|
* event lines of the watch come between the replies.
|
|
@@ -17,10 +17,10 @@ export declare class Client {
|
|
|
17
17
|
type: T;
|
|
18
18
|
}>): Promise<Replies[T]>;
|
|
19
19
|
/**
|
|
20
|
-
* Sends the `watch` request of `handle
|
|
21
|
-
* Other requests can follow on this client. Call it once.
|
|
20
|
+
* Sends the `watch` request of `target`, a handle or a conversation ID. Its `lines` are the events of the watch, and
|
|
21
|
+
* end when the connection closes. Other requests can follow on this client. Call it once.
|
|
22
22
|
*/
|
|
23
|
-
watch(
|
|
23
|
+
watch(target: string | Target): Promise<Replies["watch"] & {
|
|
24
24
|
lines: AsyncIterable<WatchLine>;
|
|
25
25
|
}>;
|
|
26
26
|
close(): void;
|
package/dist/client.js
CHANGED
|
@@ -65,13 +65,14 @@ export class Client {
|
|
|
65
65
|
return reply.promise;
|
|
66
66
|
}
|
|
67
67
|
/**
|
|
68
|
-
* Sends the `watch` request of `handle
|
|
69
|
-
* Other requests can follow on this client. Call it once.
|
|
68
|
+
* Sends the `watch` request of `target`, a handle or a conversation ID. Its `lines` are the events of the watch, and
|
|
69
|
+
* end when the connection closes. Other requests can follow on this client. Call it once.
|
|
70
70
|
*/
|
|
71
|
-
async watch(
|
|
71
|
+
async watch(target) {
|
|
72
72
|
const lines = new Readable({ objectMode: true, read() { } });
|
|
73
73
|
this.#lines = lines;
|
|
74
|
-
const
|
|
74
|
+
const ref = target instanceof Object ? target : { handle: target };
|
|
75
|
+
const reply = await this.request({ type: "watch", ...ref });
|
|
75
76
|
return { ...reply, lines };
|
|
76
77
|
}
|
|
77
78
|
close() {
|
package/dist/herdr.d.ts
CHANGED
|
@@ -18,6 +18,20 @@ export declare function herdr(args: readonly string[], env: NodeJS.ProcessEnv):
|
|
|
18
18
|
* `herdr` call fails or gives no pane ID.
|
|
19
19
|
*/
|
|
20
20
|
export declare function openChildTab(child: ChildInfo, env: NodeJS.ProcessEnv): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Opens the children library conversation `conversation` in Pi, on the project of `env` and this process's cwd: in a
|
|
23
|
+
* new pane to the right of the Herdr pane `place.pane`, or in a new tab of the Herdr workspace `place.workspace`. The
|
|
24
|
+
* pane or tab gets the key of the conversation as its label. For a conversation, `ChildInfo` gives the same ID in
|
|
25
|
+
* `handle` and in `conversation`. It rejects when a `herdr` call fails or gives no pane ID, and then makes no more
|
|
26
|
+
* calls.
|
|
27
|
+
*/
|
|
28
|
+
export declare function openConversationPane(conversation: ChildInfo, place: {
|
|
29
|
+
pane: string;
|
|
30
|
+
workspace?: never;
|
|
31
|
+
} | {
|
|
32
|
+
workspace: string;
|
|
33
|
+
pane?: never;
|
|
34
|
+
}, env: NodeJS.ProcessEnv): Promise<void>;
|
|
21
35
|
/**
|
|
22
36
|
* Reports the state of the agent `label` to the Herdr pane of `HARNESS_HERDR_PANE`, with the command that opens
|
|
23
37
|
* `target` as the resume command. Both methods do nothing without that variable, so only a Pi that the CLI
|
package/dist/herdr.js
CHANGED
|
@@ -13,6 +13,8 @@ const HERDR_SOURCE = "custom:harness";
|
|
|
13
13
|
const PANE_VARIABLE = "HARNESS_HERDR_PANE";
|
|
14
14
|
/** The part of the output of `herdr tab create` that has the pane of the tab. */
|
|
15
15
|
const TabCreated = Type.Object({ result: Type.Object({ root_pane: Type.Object({ pane_id: Type.String() }) }) });
|
|
16
|
+
/** The part of the output of `herdr pane split` that has the new pane. */
|
|
17
|
+
const PaneSplit = Type.Object({ result: Type.Object({ pane: Type.Object({ pane_id: Type.String() }) }) });
|
|
16
18
|
/**
|
|
17
19
|
* The words of the command that opens `target` in Pi, with this Node and this package's CLI, so it does not need the
|
|
18
20
|
* CLI on PATH. Herdr wants a plain command name as the first word of a resume command, so it starts with `env`.
|
|
@@ -20,6 +22,24 @@ const TabCreated = Type.Object({ result: Type.Object({ root_pane: Type.Object({
|
|
|
20
22
|
function openCommand(target) {
|
|
21
23
|
return ["env", process.execPath, cliPath(), ...harnessArgs(target)];
|
|
22
24
|
}
|
|
25
|
+
/** The `--env` flags of `herdr` that give `XDG_STATE_HOME` and `XDG_CONFIG_HOME` of `env`, when they are set. */
|
|
26
|
+
function envFlags(env) {
|
|
27
|
+
// The new pane must find the same host and projects as `statePaths()` in protocol.ts and `projectsPath()` in
|
|
28
|
+
// projects.ts.
|
|
29
|
+
const state = env.XDG_STATE_HOME;
|
|
30
|
+
const config = env.XDG_CONFIG_HOME;
|
|
31
|
+
return [
|
|
32
|
+
...(state ? ["--env", `XDG_STATE_HOME=${state}`] : []),
|
|
33
|
+
...(config ? ["--env", `XDG_CONFIG_HOME=${config}`] : []),
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
/** Runs the command that opens `target` in Pi in the Herdr pane `pane`, with each word in single quotes. */
|
|
37
|
+
async function runOpen(pane, target, env) {
|
|
38
|
+
const command = openCommand(target)
|
|
39
|
+
.map((word) => `'${word.replaceAll("'", "'\\''")}'`)
|
|
40
|
+
.join(" ");
|
|
41
|
+
await herdr(["pane", "run", pane, command], env);
|
|
42
|
+
}
|
|
23
43
|
/** Whether this process runs in a Herdr pane, also as the Pi that the CLI starts there. */
|
|
24
44
|
export function inHerdr(env) {
|
|
25
45
|
return Boolean(env.HERDR_ENV || env[PANE_VARIABLE]);
|
|
@@ -49,19 +69,56 @@ export function herdr(args, env) {
|
|
|
49
69
|
*/
|
|
50
70
|
export async function openChildTab(child, env) {
|
|
51
71
|
const project = resolveProject(env, process.cwd());
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
72
|
+
const created = await herdr(["tab", "create", "--cwd", child.cwd, "--label", childLabel(child), "--focus", ...envFlags(env)], env);
|
|
73
|
+
let output;
|
|
74
|
+
try {
|
|
75
|
+
output = JSON.parse(created);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
output = undefined;
|
|
79
|
+
}
|
|
80
|
+
if (!Value.Check(TabCreated, output))
|
|
81
|
+
throw new Error(`herdr tab create gave no pane ID: ${created.trim()}`);
|
|
82
|
+
await runOpen(output.result.root_pane.pane_id, { project, handle: child.handle }, env);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Opens the children library conversation `conversation` in Pi, on the project of `env` and this process's cwd: in a
|
|
86
|
+
* new pane to the right of the Herdr pane `place.pane`, or in a new tab of the Herdr workspace `place.workspace`. The
|
|
87
|
+
* pane or tab gets the key of the conversation as its label. For a conversation, `ChildInfo` gives the same ID in
|
|
88
|
+
* `handle` and in `conversation`. It rejects when a `herdr` call fails or gives no pane ID, and then makes no more
|
|
89
|
+
* calls.
|
|
90
|
+
*/
|
|
91
|
+
export async function openConversationPane(conversation, place, env) {
|
|
92
|
+
const project = resolveProject(env, process.cwd());
|
|
93
|
+
const target = { project, conversation: conversation.handle };
|
|
94
|
+
const label = childLabel(conversation);
|
|
95
|
+
if (place.pane !== undefined) {
|
|
96
|
+
const split = await herdr(["pane", "split", place.pane, "--direction", "right", "--cwd", conversation.cwd, ...envFlags(env)], env);
|
|
97
|
+
let output;
|
|
98
|
+
try {
|
|
99
|
+
output = JSON.parse(split);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
output = undefined;
|
|
103
|
+
}
|
|
104
|
+
if (!Value.Check(PaneSplit, output))
|
|
105
|
+
throw new Error(`herdr pane split gave no pane ID: ${split.trim()}`);
|
|
106
|
+
const pane = output.result.pane.pane_id;
|
|
107
|
+
await herdr(["pane", "rename", pane, label], env);
|
|
108
|
+
await runOpen(pane, target, env);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
55
111
|
const created = await herdr([
|
|
56
112
|
"tab",
|
|
57
113
|
"create",
|
|
114
|
+
"--workspace",
|
|
115
|
+
place.workspace,
|
|
58
116
|
"--cwd",
|
|
59
|
-
|
|
117
|
+
conversation.cwd,
|
|
60
118
|
"--label",
|
|
61
|
-
|
|
119
|
+
label,
|
|
62
120
|
"--focus",
|
|
63
|
-
...(
|
|
64
|
-
...(config ? ["--env", `XDG_CONFIG_HOME=${config}`] : []),
|
|
121
|
+
...envFlags(env),
|
|
65
122
|
], env);
|
|
66
123
|
let output;
|
|
67
124
|
try {
|
|
@@ -72,11 +129,7 @@ export async function openChildTab(child, env) {
|
|
|
72
129
|
}
|
|
73
130
|
if (!Value.Check(TabCreated, output))
|
|
74
131
|
throw new Error(`herdr tab create gave no pane ID: ${created.trim()}`);
|
|
75
|
-
|
|
76
|
-
const command = openCommand({ project, handle: child.handle })
|
|
77
|
-
.map((word) => `'${word.replaceAll("'", "'\\''")}'`)
|
|
78
|
-
.join(" ");
|
|
79
|
-
await herdr(["pane", "run", pane, command], env);
|
|
132
|
+
await runOpen(output.result.root_pane.pane_id, target, env);
|
|
80
133
|
}
|
|
81
134
|
/**
|
|
82
135
|
* Reports the state of the agent `label` to the Herdr pane of `HARNESS_HERDR_PANE`, with the command that opens
|
package/dist/host.js
CHANGED
|
@@ -9,16 +9,17 @@ import * as ai from "@earendil-works/pi-ai";
|
|
|
9
9
|
import { CodemodeSandbox } from "@earendil-works/pi-codemode";
|
|
10
10
|
import { getAgentDir, ProjectTrustStore, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import * as durable from "@earendil-works/pi-durable";
|
|
12
|
-
import { AgentDoc, createRegistry,
|
|
12
|
+
import { AgentDoc, createRegistry, Harness, } from "@earendil-works/pi-durable";
|
|
13
13
|
import { createCodemodeExtension } from "@earendil-works/pi-durable/codemode";
|
|
14
14
|
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
|
|
15
15
|
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
|
|
16
16
|
import { Type } from "typebox";
|
|
17
17
|
import { Value } from "typebox/value";
|
|
18
|
-
import {
|
|
18
|
+
import { resolveModel } from "./children.js";
|
|
19
19
|
import { ChildContext } from "./context.js";
|
|
20
20
|
import { loadEntries } from "./load-entries.js";
|
|
21
|
-
import {
|
|
21
|
+
import { errorMessage, onLines, PROTOCOL_VERSION, RequestLine, } from "./protocol.js";
|
|
22
|
+
import { Children, childInfo, childOf, childRecord, childTarget, library, ownRecord, recordOf, target, } from "./targets.js";
|
|
22
23
|
import { abortTask, startableTasks, startTask, taskInfo, waitTask } from "./tasks.js";
|
|
23
24
|
import { subagentTool } from "./tool.js";
|
|
24
25
|
import { viewChange } from "./view-change.js";
|
|
@@ -28,27 +29,6 @@ const IDLE_CHECK_MS = 60_000;
|
|
|
28
29
|
const context = BACKGROUND_CONTEXT;
|
|
29
30
|
/** The `detail` of a failed turn that is an error object. */
|
|
30
31
|
const ErrorDetail = Type.Object({ message: Type.String() });
|
|
31
|
-
/**
|
|
32
|
-
* The children by handle, and the handle of each spawn request ID. Version 2 leaves each child's last request to the
|
|
33
|
-
* children library; a store with version 1 has children that the library does not know.
|
|
34
|
-
*/
|
|
35
|
-
const Children = defineDoc({
|
|
36
|
-
kind: "subagent.children",
|
|
37
|
-
version: 2,
|
|
38
|
-
scope: "session",
|
|
39
|
-
initial: () => ({ children: {}, requests: {} }),
|
|
40
|
-
});
|
|
41
|
-
/** The request ID of the project agent's newest input, which its `error` state comes from. */
|
|
42
|
-
const AgentState = defineDoc({
|
|
43
|
-
kind: "harness.agent",
|
|
44
|
-
version: 1,
|
|
45
|
-
scope: "conversation",
|
|
46
|
-
history: "latest",
|
|
47
|
-
fork: "initial",
|
|
48
|
-
initial: () => ({ lastRequest: null }),
|
|
49
|
-
});
|
|
50
|
-
/** The children library, on this process's pi-durable. */
|
|
51
|
-
const library = createChildren(durable);
|
|
52
32
|
/**
|
|
53
33
|
* Runs the host of `project` in this process: the store in `stateDir`, the children's models from `models`, the
|
|
54
34
|
* project agent in `cwd`, and the socket. It is undefined when another host holds the lock.
|
|
@@ -267,7 +247,8 @@ async function handleRequest(request, handler) {
|
|
|
267
247
|
}
|
|
268
248
|
}
|
|
269
249
|
case "input":
|
|
270
|
-
|
|
250
|
+
// A conversation takes input only while it works, so its input is always a steer.
|
|
251
|
+
return sendTo(request.conversation === undefined ? request : { ...request, followUp: false }, await target(handler, request));
|
|
271
252
|
case "wait":
|
|
272
253
|
return waitChild(request, handler);
|
|
273
254
|
case "status":
|
|
@@ -279,7 +260,7 @@ async function handleRequest(request, handler) {
|
|
|
279
260
|
case "watch":
|
|
280
261
|
if (handler.connection === undefined)
|
|
281
262
|
throw new Error("A watch needs a connection");
|
|
282
|
-
return serveWatch(handler, request
|
|
263
|
+
return serveWatch(handler, request, handler.connection);
|
|
283
264
|
case "start":
|
|
284
265
|
// A child that a task opens without a model takes the project agent's, so the agent is set up first. Without
|
|
285
266
|
// a model for the agent, the task still starts: its children may name their own.
|
|
@@ -483,70 +464,18 @@ async function markSent(harness, handle, requestId, add) {
|
|
|
483
464
|
}, context);
|
|
484
465
|
}
|
|
485
466
|
async function abortChild(request, handler) {
|
|
486
|
-
const found = await target(handler, request
|
|
467
|
+
const found = await target(handler, request);
|
|
487
468
|
// Without `background`, an abort of the project agent does not reach the anchors of the children, which keep running.
|
|
488
469
|
await found.conversation.abort(context);
|
|
489
470
|
return found.info();
|
|
490
471
|
}
|
|
491
472
|
async function configureChild(request, handler) {
|
|
492
|
-
|
|
473
|
+
// Only the handle: the schema of `configure` does not refuse an extra `conversation` key.
|
|
474
|
+
const found = await target(handler, { handle: request.handle });
|
|
493
475
|
const agent = await found.conversation.agent(context);
|
|
494
476
|
await found.conversation.configure(resolveModel(handler.place.models, agent, request), context);
|
|
495
477
|
return found.info();
|
|
496
478
|
}
|
|
497
|
-
/**
|
|
498
|
-
* The conversation of `handle`, a child or the project agent, with its info, its input, and the record of a child.
|
|
499
|
-
*/
|
|
500
|
-
async function target(handler, handle) {
|
|
501
|
-
if (handle !== AGENT_HANDLE)
|
|
502
|
-
return childTarget(handler, handle, await childRecord(handler.harness, handle));
|
|
503
|
-
const root = await handler.agent();
|
|
504
|
-
const { harness, place, project } = handler;
|
|
505
|
-
const turn = library.turn(place, root.id, root, async (readContext) => (await harness.snapshot(AgentState, root.id, readContext))?.lastRequest, async (tx, requestId) => {
|
|
506
|
-
(await tx.doc(AgentState, root.id)).lastRequest = requestId;
|
|
507
|
-
});
|
|
508
|
-
return {
|
|
509
|
-
conversation: root,
|
|
510
|
-
info: async () => ({
|
|
511
|
-
...(await agentFields(root)),
|
|
512
|
-
handle,
|
|
513
|
-
name: `${project} agent`,
|
|
514
|
-
state: await turn.state(context),
|
|
515
|
-
}),
|
|
516
|
-
send: (input) => turn.send(input, context),
|
|
517
|
-
record: undefined,
|
|
518
|
-
};
|
|
519
|
-
}
|
|
520
|
-
/** The target of the child `handle`, whose record is `record`. */
|
|
521
|
-
async function childTarget(handler, handle, record) {
|
|
522
|
-
const child = await childOf(handler, record);
|
|
523
|
-
return {
|
|
524
|
-
conversation: child.conversation,
|
|
525
|
-
info: () => childInfo(handler, handle, record),
|
|
526
|
-
send: (input) => child.send(input, context),
|
|
527
|
-
record,
|
|
528
|
-
};
|
|
529
|
-
}
|
|
530
|
-
async function childRecord(harness, handle) {
|
|
531
|
-
const state = await harness.snapshot(Children, context);
|
|
532
|
-
const record = recordOf(state, handle);
|
|
533
|
-
if (record === undefined)
|
|
534
|
-
throw new Error(`Unknown subagent: ${handle}`);
|
|
535
|
-
return record;
|
|
536
|
-
}
|
|
537
|
-
/** The record of `handle`, which must belong to `caller`. */
|
|
538
|
-
async function ownRecord(harness, handle, caller) {
|
|
539
|
-
const record = await childRecord(harness, handle);
|
|
540
|
-
if (record.caller !== caller)
|
|
541
|
-
throw new Error(`Subagent ${handle} belongs to another session`);
|
|
542
|
-
return record;
|
|
543
|
-
}
|
|
544
|
-
async function childOf(handler, record) {
|
|
545
|
-
const child = await library.find(handler.place, handler.owner, { id: String(record.conversationId) }, context);
|
|
546
|
-
if (child === undefined)
|
|
547
|
-
throw new Error(`Missing conversation ${record.conversationId}`);
|
|
548
|
-
return child;
|
|
549
|
-
}
|
|
550
479
|
function failureText(handle, reason, detail) {
|
|
551
480
|
if (reason === "aborted")
|
|
552
481
|
return `${handle} was aborted`;
|
|
@@ -557,27 +486,6 @@ function failureText(handle, reason, detail) {
|
|
|
557
486
|
: undefined;
|
|
558
487
|
return text === undefined ? `${handle} failed: ${reason}` : `${handle} failed: ${text}`;
|
|
559
488
|
}
|
|
560
|
-
/** The info of `handle`. A caller that already has the record gives it as `record`. */
|
|
561
|
-
async function childInfo(handler, handle, record) {
|
|
562
|
-
record ??= await childRecord(handler.harness, handle);
|
|
563
|
-
const child = await childOf(handler, record);
|
|
564
|
-
const childState = await child.state(context);
|
|
565
|
-
// A closed child is idle: the watch can still send it before its record goes and the `stopped` line follows.
|
|
566
|
-
const state = childState === "closed" ? "idle" : childState;
|
|
567
|
-
const fields = await agentFields(child.conversation);
|
|
568
|
-
return record.name === undefined ? { handle, state, ...fields } : { handle, name: record.name, state, ...fields };
|
|
569
|
-
}
|
|
570
|
-
/** The model, thinking level and directory of the agent of `conversation`, for its info. */
|
|
571
|
-
async function agentFields(conversation) {
|
|
572
|
-
const agent = await conversation.agent(context);
|
|
573
|
-
return {
|
|
574
|
-
provider: agent.model?.provider ?? "",
|
|
575
|
-
model: agent.model?.modelId ?? "",
|
|
576
|
-
thinking: agent.thinkingLevel,
|
|
577
|
-
// `spawnChild` refuses a spawn without `cwd`; the fallback only satisfies pi-durable's optional type.
|
|
578
|
-
cwd: agent.cwd ?? "",
|
|
579
|
-
};
|
|
580
|
-
}
|
|
581
489
|
/**
|
|
582
490
|
* The root conversation as the agent of `project`. The first time, it gets Pi's default model when it has a login, else
|
|
583
491
|
* the first model with a login, Pi's default thinking level, and the directory `cwd`. Without a model with a login, it
|
|
@@ -611,7 +519,8 @@ async function setUpAgent(root, models, settings, project, cwd) {
|
|
|
611
519
|
* `drain` then stays on the closed socket and goes with it. When the child stops, the client gets a `stopped` event and the connection ends; when the conversation
|
|
612
520
|
* watch ends for another reason, the connection ends.
|
|
613
521
|
*/
|
|
614
|
-
async function serveWatch(handler,
|
|
522
|
+
async function serveWatch(handler, ref, connection) {
|
|
523
|
+
const handle = ref.conversation ?? ref.handle;
|
|
615
524
|
const { harness } = handler;
|
|
616
525
|
const { socket } = connection;
|
|
617
526
|
const write = (event) => socket.write(`${JSON.stringify({ event })}\n`);
|
|
@@ -628,8 +537,9 @@ async function serveWatch(handler, handle, connection) {
|
|
|
628
537
|
let conversation;
|
|
629
538
|
let last;
|
|
630
539
|
try {
|
|
631
|
-
found = await target(handler,
|
|
632
|
-
// The project agent
|
|
540
|
+
found = await target(handler, ref);
|
|
541
|
+
// The project agent and a conversation target have no record and no `Children` watch: they are never renamed
|
|
542
|
+
// or stopped.
|
|
633
543
|
if (found.record !== undefined) {
|
|
634
544
|
// The `Children` watch opens before the info, so it gets each later rename and stop of the child.
|
|
635
545
|
children = await harness.watchDoc(Children, watchContext);
|
|
@@ -683,10 +593,6 @@ async function serveWatch(handler, handle, connection) {
|
|
|
683
593
|
});
|
|
684
594
|
return { view: conversation.value, child: last };
|
|
685
595
|
}
|
|
686
|
-
/** The record of `handle` in the `Children` value `state`. */
|
|
687
|
-
function recordOf(state, handle) {
|
|
688
|
-
return state != null && Object.hasOwn(state.children, handle) ? state.children[handle] : undefined;
|
|
689
|
-
}
|
|
690
596
|
/**
|
|
691
597
|
* The lock of the host, a write transaction on `lock.sqlite`, held until `close()`. The system drops it when the
|
|
692
598
|
* process ends. It is undefined when another process holds it. `IMMEDIATE`, not `EXCLUSIVE`: two hosts that start at
|
package/dist/mirror.d.ts
CHANGED
|
@@ -3,27 +3,39 @@ import { type Static, Type } from "typebox";
|
|
|
3
3
|
import { type ChildInfo, type Request } from "./protocol.ts";
|
|
4
4
|
/** The `customType` of the session entry that holds the target of a mirror session. */
|
|
5
5
|
export declare const MIRROR_ENTRY = "harness-mirror";
|
|
6
|
-
/**
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* The conversation that a mirror session shows, in the host of `project`: the child or project agent `handle`, or the
|
|
8
|
+
* conversation `conversation` that the children library opened. Each refuses the other key.
|
|
9
|
+
*/
|
|
10
|
+
export declare const MirrorTarget: Type.TUnion<[Type.TObject<{
|
|
8
11
|
project: Type.TString;
|
|
9
12
|
handle: Type.TString;
|
|
10
|
-
|
|
13
|
+
conversation: Type.TOptional<Type.TNever>;
|
|
14
|
+
}>, Type.TObject<{
|
|
15
|
+
project: Type.TString;
|
|
16
|
+
conversation: Type.TString;
|
|
17
|
+
handle: Type.TOptional<Type.TNever>;
|
|
18
|
+
}>]>;
|
|
11
19
|
export type MirrorTarget = Static<typeof MirrorTarget>;
|
|
12
20
|
/** Whether the data of a session entry is a mirror target. */
|
|
13
21
|
export declare function isMirrorTarget(data: unknown): data is MirrorTarget;
|
|
14
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* The Pi flags that open a mirror session of a target: the project, and the child or the conversation in place of the
|
|
24
|
+
* project agent.
|
|
25
|
+
*/
|
|
15
26
|
export declare const PI_FLAGS: {
|
|
16
27
|
readonly project: "harness";
|
|
17
28
|
readonly child: "harness-child";
|
|
29
|
+
readonly conversation: "harness-conversation";
|
|
18
30
|
};
|
|
19
31
|
/** The arguments of `pi` that open a mirror session of `target`. */
|
|
20
32
|
export declare function piArgs(target: MirrorTarget): string[];
|
|
21
33
|
/**
|
|
22
34
|
* The arguments of the `harness` CLI that open `target` in Pi: `<project>` for the project agent, `--project <project>
|
|
23
|
-
* attach <handle>` for a child.
|
|
35
|
+
* attach <handle>` for a child, and `--project <project> attach --conversation <id>` for a conversation.
|
|
24
36
|
*/
|
|
25
37
|
export declare function harnessArgs(target: MirrorTarget): string[];
|
|
26
|
-
/** The title of the agent of `target` before the host gives its name. */
|
|
38
|
+
/** The title of the agent of `target` before the host gives its name: the handle or the conversation ID of a child. */
|
|
27
39
|
export declare function targetTitle(target: MirrorTarget): string;
|
|
28
40
|
/** A change of the model or the thinking level of the mirrored conversation. */
|
|
29
41
|
export type MirrorConfigure = Omit<Extract<Request, {
|
|
@@ -73,7 +85,10 @@ export declare class Mirror {
|
|
|
73
85
|
send(message: string, followUp: boolean): Promise<void>;
|
|
74
86
|
/** Aborts the turn, and gives the texts of the queued messages that the host withdraws, steers first. */
|
|
75
87
|
abort(): Promise<string[]>;
|
|
76
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Changes the model or the thinking level. The host refuses a model it does not know and a level it does not have. A
|
|
90
|
+
* conversation of the children library refuses it without a request.
|
|
91
|
+
*/
|
|
77
92
|
configure(change: MirrorConfigure): Promise<void>;
|
|
78
93
|
/** Closes the connection and stops connecting. The conversation keeps running in the host. */
|
|
79
94
|
close(): void;
|
package/dist/mirror.js
CHANGED
|
@@ -13,28 +13,46 @@ import { applyChange } from "./view-change.js";
|
|
|
13
13
|
import { inboxOf, withdrawnTexts } from "./view-state.js";
|
|
14
14
|
/** The `customType` of the session entry that holds the target of a mirror session. */
|
|
15
15
|
export const MIRROR_ENTRY = "harness-mirror";
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The conversation that a mirror session shows, in the host of `project`: the child or project agent `handle`, or the
|
|
18
|
+
* conversation `conversation` that the children library opened. Each refuses the other key.
|
|
19
|
+
*/
|
|
20
|
+
export const MirrorTarget = Type.Union([
|
|
21
|
+
Type.Object({ project: Type.String(), handle: Type.String(), conversation: Type.Optional(Type.Never()) }),
|
|
22
|
+
Type.Object({ project: Type.String(), conversation: Type.String(), handle: Type.Optional(Type.Never()) }),
|
|
23
|
+
]);
|
|
18
24
|
/** Whether the data of a session entry is a mirror target. */
|
|
19
25
|
export function isMirrorTarget(data) {
|
|
20
26
|
return Value.Check(MirrorTarget, data);
|
|
21
27
|
}
|
|
22
|
-
/**
|
|
23
|
-
|
|
28
|
+
/**
|
|
29
|
+
* The Pi flags that open a mirror session of a target: the project, and the child or the conversation in place of the
|
|
30
|
+
* project agent.
|
|
31
|
+
*/
|
|
32
|
+
export const PI_FLAGS = { project: "harness", child: "harness-child", conversation: "harness-conversation" };
|
|
24
33
|
/** The arguments of `pi` that open a mirror session of `target`. */
|
|
25
34
|
export function piArgs(target) {
|
|
26
|
-
const child = target.
|
|
35
|
+
const child = target.conversation !== undefined
|
|
36
|
+
? [`--${PI_FLAGS.conversation}`, target.conversation]
|
|
37
|
+
: target.handle === AGENT_HANDLE
|
|
38
|
+
? []
|
|
39
|
+
: [`--${PI_FLAGS.child}`, target.handle];
|
|
27
40
|
return [`--${PI_FLAGS.project}`, target.project, ...child];
|
|
28
41
|
}
|
|
29
42
|
/**
|
|
30
43
|
* The arguments of the `harness` CLI that open `target` in Pi: `<project>` for the project agent, `--project <project>
|
|
31
|
-
* attach <handle>` for a child.
|
|
44
|
+
* attach <handle>` for a child, and `--project <project> attach --conversation <id>` for a conversation.
|
|
32
45
|
*/
|
|
33
46
|
export function harnessArgs(target) {
|
|
47
|
+
if (target.conversation !== undefined) {
|
|
48
|
+
return ["--project", target.project, "attach", "--conversation", target.conversation];
|
|
49
|
+
}
|
|
34
50
|
return target.handle === AGENT_HANDLE ? [target.project] : ["--project", target.project, "attach", target.handle];
|
|
35
51
|
}
|
|
36
|
-
/** The title of the agent of `target` before the host gives its name. */
|
|
52
|
+
/** The title of the agent of `target` before the host gives its name: the handle or the conversation ID of a child. */
|
|
37
53
|
export function targetTitle(target) {
|
|
54
|
+
if (target.conversation !== undefined)
|
|
55
|
+
return target.conversation;
|
|
38
56
|
return target.handle === AGENT_HANDLE ? `${target.project} agent` : target.handle;
|
|
39
57
|
}
|
|
40
58
|
const RETRY_DELAYS_MS = [0, 2000, 5000];
|
|
@@ -51,6 +69,8 @@ const HOST_CLOSED = "The harness host closed the connection";
|
|
|
51
69
|
*/
|
|
52
70
|
export class Mirror {
|
|
53
71
|
target;
|
|
72
|
+
/** The target of the requests: the handle or the conversation of `target`, without the project. */
|
|
73
|
+
#ref;
|
|
54
74
|
#onChange;
|
|
55
75
|
#retryDelaysMs;
|
|
56
76
|
#start;
|
|
@@ -65,6 +85,7 @@ export class Mirror {
|
|
|
65
85
|
#sending = false;
|
|
66
86
|
constructor(target, onChange, options = {}) {
|
|
67
87
|
this.target = target;
|
|
88
|
+
this.#ref = target.conversation === undefined ? { handle: target.handle } : { conversation: target.conversation };
|
|
68
89
|
this.#onChange = onChange;
|
|
69
90
|
this.#retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS;
|
|
70
91
|
this.#start = options.start ?? true;
|
|
@@ -105,13 +126,20 @@ export class Mirror {
|
|
|
105
126
|
async abort() {
|
|
106
127
|
const { client, view } = this.#connection();
|
|
107
128
|
const texts = withdrawnTexts(inboxOf(view));
|
|
108
|
-
this.#setChild(await client.request({ type: "abort",
|
|
129
|
+
this.#setChild(await client.request({ type: "abort", ...this.#ref }));
|
|
109
130
|
return texts;
|
|
110
131
|
}
|
|
111
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* Changes the model or the thinking level. The host refuses a model it does not know and a level it does not have. A
|
|
134
|
+
* conversation of the children library refuses it without a request.
|
|
135
|
+
*/
|
|
112
136
|
async configure(change) {
|
|
137
|
+
const { handle } = this.target;
|
|
138
|
+
if (handle === undefined) {
|
|
139
|
+
throw new Error("A conversation of the children library keeps its model and thinking level");
|
|
140
|
+
}
|
|
113
141
|
const { client } = this.#connection();
|
|
114
|
-
this.#setChild(await client.request({ type: "configure", handle
|
|
142
|
+
this.#setChild(await client.request({ type: "configure", handle, ...change }));
|
|
115
143
|
}
|
|
116
144
|
/** Closes the connection and stops connecting. The conversation keeps running in the host. */
|
|
117
145
|
close() {
|
|
@@ -168,7 +196,7 @@ export class Mirror {
|
|
|
168
196
|
client = await connect({ start: this.#start, project: this.target.project, signal });
|
|
169
197
|
if (client === undefined)
|
|
170
198
|
throw new Error("Could not connect to the harness host");
|
|
171
|
-
const watched = await client.watch(this
|
|
199
|
+
const watched = await client.watch(this.#ref);
|
|
172
200
|
if (signal.aborted) {
|
|
173
201
|
client.close();
|
|
174
202
|
break;
|
|
@@ -210,7 +238,7 @@ export class Mirror {
|
|
|
210
238
|
const reply = await client.request({
|
|
211
239
|
type: "input",
|
|
212
240
|
requestId,
|
|
213
|
-
|
|
241
|
+
...this.#ref,
|
|
214
242
|
message,
|
|
215
243
|
followUp,
|
|
216
244
|
});
|
package/dist/protocol.d.ts
CHANGED
|
@@ -5,16 +5,20 @@ import type { ConversationView, TaskId, TaskOutcome, TaskState } from "@earendil
|
|
|
5
5
|
import { type Static, Type } from "typebox";
|
|
6
6
|
import type { ViewChange } from "./view-change.ts";
|
|
7
7
|
/** The version of the protocol between the CLI and the host. A client refuses a host with another version. */
|
|
8
|
-
export declare const PROTOCOL_VERSION =
|
|
8
|
+
export declare const PROTOCOL_VERSION = 9;
|
|
9
9
|
/**
|
|
10
10
|
* The handle of the project agent, the host's root conversation, in `watch`, `input`, `abort` and `configure`. It is not
|
|
11
11
|
* hex, so no child has it.
|
|
12
12
|
*/
|
|
13
13
|
export declare const AGENT_HANDLE = "agent";
|
|
14
14
|
export type ChildState = "busy" | "idle" | "error";
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* One child as the host reports it. Provider, model and thinking level come from the child's agent. For a `{ conversation }`
|
|
17
|
+
* target, `handle` and `conversation` are the conversation ID, and `name` is the child's lineage key.
|
|
18
|
+
*/
|
|
16
19
|
export type ChildInfo = {
|
|
17
20
|
readonly handle: string;
|
|
21
|
+
readonly conversation?: string;
|
|
18
22
|
readonly name?: string;
|
|
19
23
|
readonly state: ChildState;
|
|
20
24
|
readonly provider: string;
|
|
@@ -41,6 +45,17 @@ export type TaskInfo = {
|
|
|
41
45
|
export declare const Json: Type.TCyclic<{
|
|
42
46
|
Json: Type.TUnion<[Type.TNull, Type.TBoolean, Type.TNumber, Type.TString, Type.TArray<Type.TRef<"Json">>, Type.TRecord<"^.*$", Type.TRef<"Json">>]>;
|
|
43
47
|
}, "Json">;
|
|
48
|
+
/**
|
|
49
|
+
* The target of `watch`, `input` and `abort`: a handle, or the ID of a conversation that the children library opened,
|
|
50
|
+
* such as a child of a task.
|
|
51
|
+
*/
|
|
52
|
+
export type Target = {
|
|
53
|
+
readonly handle: string;
|
|
54
|
+
readonly conversation?: never;
|
|
55
|
+
} | {
|
|
56
|
+
readonly conversation: string;
|
|
57
|
+
readonly handle?: never;
|
|
58
|
+
};
|
|
44
59
|
/** One request line. The caller is `PI_SESSION_ID`, or `cli` without one. */
|
|
45
60
|
export type Request = {
|
|
46
61
|
readonly type: "hello";
|
|
@@ -91,15 +106,13 @@ export type Request = {
|
|
|
91
106
|
* after each commit to the child's conversation and after each rename. Every other line on the connection stays a
|
|
92
107
|
* reply, in request order. A connection carries one watch.
|
|
93
108
|
*/
|
|
94
|
-
| {
|
|
109
|
+
| ({
|
|
95
110
|
readonly type: "watch";
|
|
96
|
-
|
|
97
|
-
}
|
|
111
|
+
} & Target)
|
|
98
112
|
/** Stops the child's current turn. A mirror session sends it. */
|
|
99
|
-
| {
|
|
113
|
+
| ({
|
|
100
114
|
readonly type: "abort";
|
|
101
|
-
|
|
102
|
-
}
|
|
115
|
+
} & Target)
|
|
103
116
|
/**
|
|
104
117
|
* A mirror session's change of the child's model or thinking level. It has no caller. A new model keeps the thinking level
|
|
105
118
|
* when the model has it, and takes the nearest level that it has otherwise. The host refuses a level that the model
|
|
@@ -112,14 +125,13 @@ export type Request = {
|
|
|
112
125
|
readonly model?: string;
|
|
113
126
|
readonly thinking?: ModelThinkingLevel;
|
|
114
127
|
}
|
|
115
|
-
/** A mirror session's message to the child. It has no caller. */
|
|
116
|
-
| {
|
|
128
|
+
/** A mirror session's message to the child. It has no caller. A conversation takes it only while it works, as a steer. */
|
|
129
|
+
| ({
|
|
117
130
|
readonly type: "input";
|
|
118
131
|
readonly requestId: string;
|
|
119
|
-
readonly handle: string;
|
|
120
132
|
readonly message: string;
|
|
121
133
|
readonly followUp: boolean;
|
|
122
|
-
}
|
|
134
|
+
} & Target)
|
|
123
135
|
/**
|
|
124
136
|
* Starts a background task of the kind `kind`, a task of an entry in `entries.json`, in the root conversation.
|
|
125
137
|
* A repeated `key` gives the first task, and its new kind, version and input are ignored. With `version`, a host
|
|
@@ -190,25 +202,45 @@ export declare const RequestLine: Type.TUnion<[Type.TObject<{
|
|
|
190
202
|
type: Type.TLiteral<"stop">;
|
|
191
203
|
caller: Type.TString;
|
|
192
204
|
handle: Type.TString;
|
|
193
|
-
}>, Type.TObject<{
|
|
205
|
+
}>, Type.TUnion<[Type.TObject<{
|
|
194
206
|
type: Type.TLiteral<"watch">;
|
|
195
207
|
handle: Type.TString;
|
|
208
|
+
conversation: Type.TOptional<Type.TNever>;
|
|
196
209
|
}>, Type.TObject<{
|
|
210
|
+
type: Type.TLiteral<"watch">;
|
|
211
|
+
conversation: Type.TString;
|
|
212
|
+
handle: Type.TOptional<Type.TNever>;
|
|
213
|
+
}>]>, Type.TUnion<[Type.TObject<{
|
|
197
214
|
type: Type.TLiteral<"abort">;
|
|
198
215
|
handle: Type.TString;
|
|
216
|
+
conversation: Type.TOptional<Type.TNever>;
|
|
199
217
|
}>, Type.TObject<{
|
|
218
|
+
type: Type.TLiteral<"abort">;
|
|
219
|
+
conversation: Type.TString;
|
|
220
|
+
handle: Type.TOptional<Type.TNever>;
|
|
221
|
+
}>]>, Type.TObject<{
|
|
200
222
|
type: Type.TLiteral<"configure">;
|
|
201
223
|
handle: Type.TString;
|
|
202
224
|
provider: Type.TOptional<Type.TString>;
|
|
203
225
|
model: Type.TOptional<Type.TString>;
|
|
204
226
|
thinking: Type.TOptional<Type.TString>;
|
|
205
|
-
}>, Type.TObject<{
|
|
206
|
-
type: Type.TLiteral<"input">;
|
|
227
|
+
}>, Type.TUnion<[Type.TObject<{
|
|
207
228
|
requestId: Type.TString;
|
|
208
|
-
handle: Type.TString;
|
|
209
229
|
message: Type.TString;
|
|
210
230
|
followUp: Type.TBoolean;
|
|
231
|
+
} & {
|
|
232
|
+
type: Type.TLiteral<"input">;
|
|
233
|
+
handle: Type.TString;
|
|
234
|
+
conversation: Type.TOptional<Type.TNever>;
|
|
211
235
|
}>, Type.TObject<{
|
|
236
|
+
requestId: Type.TString;
|
|
237
|
+
message: Type.TString;
|
|
238
|
+
followUp: Type.TBoolean;
|
|
239
|
+
} & {
|
|
240
|
+
type: Type.TLiteral<"input">;
|
|
241
|
+
conversation: Type.TString;
|
|
242
|
+
handle: Type.TOptional<Type.TNever>;
|
|
243
|
+
}>]>, Type.TObject<{
|
|
212
244
|
type: Type.TLiteral<"start">;
|
|
213
245
|
kind: Type.TString;
|
|
214
246
|
input: Type.TCyclic<{
|
|
@@ -292,23 +324,26 @@ export type StatePaths = {
|
|
|
292
324
|
};
|
|
293
325
|
export declare function statePaths(project: string): StatePaths;
|
|
294
326
|
export declare function isValidRunName(value: string): boolean;
|
|
295
|
-
|
|
296
|
-
export declare function childLabel(child: {
|
|
327
|
+
type Labeled = {
|
|
297
328
|
readonly handle: string;
|
|
298
329
|
readonly name?: string | undefined;
|
|
299
|
-
|
|
330
|
+
readonly conversation?: string | undefined;
|
|
331
|
+
};
|
|
332
|
+
/** The label of a child: its name, or its handle when it has no name. */
|
|
333
|
+
export declare function childLabel(child: Labeled): string;
|
|
300
334
|
export declare function runDisplayName(metadata: {
|
|
301
335
|
readonly handle: string;
|
|
302
336
|
readonly name?: string | undefined;
|
|
303
337
|
}): string;
|
|
304
|
-
/**
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
338
|
+
/**
|
|
339
|
+
* The title of a child in a client: `<name> (<handle>)`, or only the name for the project agent, which says what it is,
|
|
340
|
+
* and for a conversation, whose name is its key.
|
|
341
|
+
*/
|
|
342
|
+
export declare function childTitle(child: Labeled): string;
|
|
309
343
|
/** The message of a thrown value. */
|
|
310
344
|
export declare function errorMessage(cause: unknown): string;
|
|
311
345
|
/** The line of one child in a list. */
|
|
312
346
|
export declare function childLine(child: ChildInfo): string;
|
|
313
347
|
/** Calls `onLine` with each line that arrives on `socket`, without its newline. */
|
|
314
348
|
export declare function onLines(socket: Socket, onLine: (line: string) => void): void;
|
|
349
|
+
export {};
|
package/dist/protocol.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
5
|
/** The version of the protocol between the CLI and the host. A client refuses a host with another version. */
|
|
6
|
-
export const PROTOCOL_VERSION =
|
|
6
|
+
export const PROTOCOL_VERSION = 9;
|
|
7
7
|
/**
|
|
8
8
|
* The handle of the project agent, the host's root conversation, in `watch`, `input`, `abort` and `configure`. It is not
|
|
9
9
|
* hex, so no child has it.
|
|
@@ -20,6 +20,26 @@ export const Json = Type.Cyclic({
|
|
|
20
20
|
Type.Record(Type.String(), Type.Ref("Json")),
|
|
21
21
|
]),
|
|
22
22
|
}, "Json");
|
|
23
|
+
/**
|
|
24
|
+
* The request objects of `type` with `fields` and a target: one with `handle`, one with `conversation`. Each refuses the
|
|
25
|
+
* other key, because an object schema accepts extra keys.
|
|
26
|
+
*/
|
|
27
|
+
function targeted(type, fields) {
|
|
28
|
+
return Type.Union([
|
|
29
|
+
Type.Object({
|
|
30
|
+
...fields,
|
|
31
|
+
type: Type.Literal(type),
|
|
32
|
+
handle: Type.String(),
|
|
33
|
+
conversation: Type.Optional(Type.Never()),
|
|
34
|
+
}),
|
|
35
|
+
Type.Object({
|
|
36
|
+
...fields,
|
|
37
|
+
type: Type.Literal(type),
|
|
38
|
+
conversation: Type.String(),
|
|
39
|
+
handle: Type.Optional(Type.Never()),
|
|
40
|
+
}),
|
|
41
|
+
]);
|
|
42
|
+
}
|
|
23
43
|
/**
|
|
24
44
|
* The check of a request line on the host. It is looser than `Request`, so the host answers a spawn without `cwd` and an
|
|
25
45
|
* unknown thinking level with its own errors.
|
|
@@ -52,8 +72,8 @@ export const RequestLine = Type.Union([
|
|
|
52
72
|
Type.Object({ type: Type.Literal("list"), caller: Type.String() }),
|
|
53
73
|
Type.Object({ type: Type.Literal("rename"), caller: Type.String(), handle: Type.String(), name: Type.String() }),
|
|
54
74
|
Type.Object({ type: Type.Literal("stop"), caller: Type.String(), handle: Type.String() }),
|
|
55
|
-
|
|
56
|
-
|
|
75
|
+
targeted("watch", {}),
|
|
76
|
+
targeted("abort", {}),
|
|
57
77
|
Type.Object({
|
|
58
78
|
type: Type.Literal("configure"),
|
|
59
79
|
handle: Type.String(),
|
|
@@ -61,13 +81,7 @@ export const RequestLine = Type.Union([
|
|
|
61
81
|
model: Type.Optional(Type.String()),
|
|
62
82
|
thinking: Type.Optional(Type.String()),
|
|
63
83
|
}),
|
|
64
|
-
Type.
|
|
65
|
-
type: Type.Literal("input"),
|
|
66
|
-
requestId: Type.String(),
|
|
67
|
-
handle: Type.String(),
|
|
68
|
-
message: Type.String(),
|
|
69
|
-
followUp: Type.Boolean(),
|
|
70
|
-
}),
|
|
84
|
+
targeted("input", { requestId: Type.String(), message: Type.String(), followUp: Type.Boolean() }),
|
|
71
85
|
Type.Object({
|
|
72
86
|
type: Type.Literal("start"),
|
|
73
87
|
kind: Type.String(),
|
|
@@ -106,9 +120,12 @@ export function childLabel(child) {
|
|
|
106
120
|
export function runDisplayName(metadata) {
|
|
107
121
|
return metadata.name ? `${metadata.name} (${metadata.handle})` : metadata.handle;
|
|
108
122
|
}
|
|
109
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* The title of a child in a client: `<name> (<handle>)`, or only the name for the project agent, which says what it is,
|
|
125
|
+
* and for a conversation, whose name is its key.
|
|
126
|
+
*/
|
|
110
127
|
export function childTitle(child) {
|
|
111
|
-
return child.handle === AGENT_HANDLE ? childLabel(child) : runDisplayName(child);
|
|
128
|
+
return child.handle === AGENT_HANDLE || child.conversation !== undefined ? childLabel(child) : runDisplayName(child);
|
|
112
129
|
}
|
|
113
130
|
/** The message of a thrown value. */
|
|
114
131
|
export function errorMessage(cause) {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as durable from "@earendil-works/pi-durable";
|
|
2
|
+
import { type Conversation, type ConversationId, type Harness } from "@earendil-works/pi-durable";
|
|
3
|
+
import { type Child, type Input, type Owner, type Place } from "./children.ts";
|
|
4
|
+
import { type ChildInfo, type Target } from "./protocol.ts";
|
|
5
|
+
/** What the targets use of the host. */
|
|
6
|
+
export type TargetHost = {
|
|
7
|
+
readonly harness: Harness;
|
|
8
|
+
/** The host's store, models and tools, for the children library. */
|
|
9
|
+
readonly place: Place;
|
|
10
|
+
readonly owner: Owner;
|
|
11
|
+
readonly project: string;
|
|
12
|
+
/** The project agent: the root conversation, with a model and the project directory from its first use. */
|
|
13
|
+
readonly agent: () => Promise<Conversation>;
|
|
14
|
+
};
|
|
15
|
+
export type ChildRecord = {
|
|
16
|
+
name?: string;
|
|
17
|
+
/** `PI_SESSION_ID` of the spawner: a Pi session, the conversation ID of another child, or `cli`. */
|
|
18
|
+
caller: string;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
conversationId: ConversationId;
|
|
21
|
+
/** The request IDs of the spawn and of each `send` of the caller, in order. */
|
|
22
|
+
sent?: string[];
|
|
23
|
+
/** How many requests of `sent` a `wait` gave the answer of. */
|
|
24
|
+
read?: number;
|
|
25
|
+
};
|
|
26
|
+
export type ChildrenState = {
|
|
27
|
+
children: Record<string, ChildRecord>;
|
|
28
|
+
requests: Record<string, string>;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The children by handle, and the handle of each spawn request ID. Version 2 leaves each child's last request to the
|
|
32
|
+
* children library; a store with version 1 has children that the library does not know.
|
|
33
|
+
*/
|
|
34
|
+
export declare const Children: durable.SessionDocToken<ChildrenState>;
|
|
35
|
+
/** The children library, on this process's pi-durable. */
|
|
36
|
+
export declare const library: import("./children.ts").Children;
|
|
37
|
+
/**
|
|
38
|
+
* The conversation of `ref`: a child or the project agent by handle, or a conversation of the children library by ID,
|
|
39
|
+
* with its info, its input, and the record of a child.
|
|
40
|
+
*/
|
|
41
|
+
export declare function target(handler: TargetHost, ref: Target): Promise<{
|
|
42
|
+
conversation: Conversation;
|
|
43
|
+
info: () => Promise<ChildInfo>;
|
|
44
|
+
send: (input: Input) => Promise<void>;
|
|
45
|
+
record: undefined;
|
|
46
|
+
} | {
|
|
47
|
+
conversation: Conversation;
|
|
48
|
+
info: () => Promise<ChildInfo>;
|
|
49
|
+
send: (input: Input) => Promise<void>;
|
|
50
|
+
record: ChildRecord;
|
|
51
|
+
}>;
|
|
52
|
+
export type Found = Awaited<ReturnType<typeof target>>;
|
|
53
|
+
/** The target of the child `handle`, whose record is `record`. */
|
|
54
|
+
export declare function childTarget(handler: TargetHost, handle: string, record: ChildRecord): Promise<{
|
|
55
|
+
conversation: Conversation;
|
|
56
|
+
info: () => Promise<ChildInfo>;
|
|
57
|
+
send: (input: Input) => Promise<void>;
|
|
58
|
+
record: ChildRecord;
|
|
59
|
+
}>;
|
|
60
|
+
export declare function childRecord(harness: Harness, handle: string): Promise<ChildRecord>;
|
|
61
|
+
/** The record of `handle`, which must belong to `caller`. */
|
|
62
|
+
export declare function ownRecord(harness: Harness, handle: string, caller: string): Promise<ChildRecord>;
|
|
63
|
+
export declare function childOf(handler: Pick<TargetHost, "place" | "owner">, record: ChildRecord): Promise<Child>;
|
|
64
|
+
/** The info of `handle`. A caller that already has the record gives it as `record`. */
|
|
65
|
+
export declare function childInfo(handler: Pick<TargetHost, "harness" | "place" | "owner">, handle: string, record?: ChildRecord): Promise<ChildInfo>;
|
|
66
|
+
/** The record of `handle` in the `Children` value `state`. */
|
|
67
|
+
export declare function recordOf(state: Readonly<ChildrenState> | null | undefined, handle: string): ChildRecord | undefined;
|
package/dist/targets.js
ADDED
|
@@ -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
package/pi/attachment.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { type ChildInfo, childLabel, childTitle, errorMessage } from "../dist/pr
|
|
|
20
20
|
import { inboxOf, liveOf, queued, statusText } from "../dist/view-state.js";
|
|
21
21
|
import { ChatView } from "./chat.ts";
|
|
22
22
|
import { ModelSync } from "./model-sync.ts";
|
|
23
|
+
import { ProgramStatusReport } from "./program-status.ts";
|
|
23
24
|
import { usageLine } from "./usage-line.ts";
|
|
24
25
|
|
|
25
26
|
/** The key of the status widget above the editor. */
|
|
@@ -40,6 +41,8 @@ export class Attachment {
|
|
|
40
41
|
#chat: ChatView | undefined;
|
|
41
42
|
/** The state of the agent for Herdr, made when the agent is first known. */
|
|
42
43
|
#reporter: AgentReporter | undefined;
|
|
44
|
+
/** The state of the agent for the terminal, made with the TUI. */
|
|
45
|
+
#programStatus: ProgramStatusReport | undefined;
|
|
43
46
|
#expanded = false;
|
|
44
47
|
#closed = false;
|
|
45
48
|
|
|
@@ -57,6 +60,7 @@ export class Attachment {
|
|
|
57
60
|
(ui, theme) => {
|
|
58
61
|
this.#ui = ui;
|
|
59
62
|
this.#theme = theme;
|
|
63
|
+
this.#programStatus ??= new ProgramStatusReport(ui.terminal);
|
|
60
64
|
|
|
61
65
|
return this.#status;
|
|
62
66
|
},
|
|
@@ -64,6 +68,7 @@ export class Attachment {
|
|
|
64
68
|
);
|
|
65
69
|
ctx.ui.setEditorComponent((ui, theme, keybindings) => new MirrorEditor(ui, theme, keybindings, this));
|
|
66
70
|
this.#mirror = new Mirror(target, (previous) => this.#update(previous));
|
|
71
|
+
this.#programStatus?.update(this.#mirror);
|
|
67
72
|
this.models = new ModelSync(pi, ctx, this.#mirror, (cause) => this.#notify(cause));
|
|
68
73
|
}
|
|
69
74
|
|
|
@@ -121,6 +126,7 @@ export class Attachment {
|
|
|
121
126
|
this.#ctx.ui.setWidget(WIDGET, undefined);
|
|
122
127
|
this.#ctx.ui.setEditorComponent(undefined);
|
|
123
128
|
void this.#reporter?.release();
|
|
129
|
+
this.#programStatus?.leave(this.#mirror);
|
|
124
130
|
}
|
|
125
131
|
|
|
126
132
|
/** Shows each change of the mirror, whose status was `previous` before it. */
|
|
@@ -135,6 +141,7 @@ export class Attachment {
|
|
|
135
141
|
|
|
136
142
|
if (status.kind === "failed" && previous.kind !== "failed") this.#showFailure(status.reason);
|
|
137
143
|
this.#reporter?.report(this.#mirror.busy);
|
|
144
|
+
this.#programStatus?.update(this.#mirror);
|
|
138
145
|
|
|
139
146
|
if (child !== undefined) this.#followAgent(child);
|
|
140
147
|
ui.requestRender();
|
package/pi/mirror.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mirror sessions: a Pi session that shows a child or the agent of a harness project in Pi's own chat, and sends what
|
|
3
3
|
* the user types to it. The agent runs in the host of the project; the session keeps only its target, in one custom
|
|
4
|
-
* entry. `/harness [project]` opens one in a new session, and `pi --harness <project> [--harness-child <handle>
|
|
5
|
-
* session that Pi starts with.
|
|
4
|
+
* entry. `/harness [project]` opens one in a new session, and `pi --harness <project> [--harness-child <handle> |
|
|
5
|
+
* --harness-conversation <id>]` in the session that Pi starts with.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
@@ -45,6 +45,12 @@ export function registerMirror(pi: ExtensionAPI): void {
|
|
|
45
45
|
description: "Show this subagent of the harness project in the new session, in place of the project agent",
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
pi.registerFlag(PI_FLAGS.conversation, {
|
|
49
|
+
type: "string",
|
|
50
|
+
description:
|
|
51
|
+
"Show this conversation of the children library of the harness project in the new session, in place of the project agent",
|
|
52
|
+
});
|
|
53
|
+
|
|
48
54
|
pi.registerEntryRenderer(MIRROR_ENTRY, (entry, options, theme) => {
|
|
49
55
|
if (attached?.entryId === entry.id) attached.restyle(theme, options.expanded);
|
|
50
56
|
|
|
@@ -125,15 +131,23 @@ function mirrorEntry(entries: readonly SessionEntry[]): MirrorEntry | undefined
|
|
|
125
131
|
}
|
|
126
132
|
|
|
127
133
|
/**
|
|
128
|
-
* The mirror entry for `--harness <project>` and `--harness-
|
|
129
|
-
* with. Without a project, it is the project of `HARNESS_PROJECT` and Pi's working
|
|
130
|
-
*
|
|
134
|
+
* The mirror entry for `--harness <project>`, `--harness-child <handle>` and `--harness-conversation <id>`, written in
|
|
135
|
+
* the session that Pi starts with. Without a project, it is the project of `HARNESS_PROJECT` and Pi's working
|
|
136
|
+
* directory; without a child or a conversation, the project agent. It refuses both a child and a conversation, and a
|
|
137
|
+
* session with messages, such as one of `--continue`, because Pi's own agent answered there.
|
|
131
138
|
*/
|
|
132
139
|
function flagEntry(pi: ExtensionAPI, ctx: ExtensionContext): MirrorEntry | undefined {
|
|
133
140
|
const project = stringFlag(pi, PI_FLAGS.project);
|
|
134
141
|
const child = stringFlag(pi, PI_FLAGS.child);
|
|
142
|
+
const conversation = stringFlag(pi, PI_FLAGS.conversation);
|
|
143
|
+
|
|
144
|
+
if (project === undefined && child === undefined && conversation === undefined) return undefined;
|
|
135
145
|
|
|
136
|
-
if (
|
|
146
|
+
if (child && conversation) {
|
|
147
|
+
ctx.ui.notify("harness: use --harness-child or --harness-conversation, not both", "error");
|
|
148
|
+
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
137
151
|
|
|
138
152
|
if (ctx.sessionManager.getBranch().some((entry) => entry.type === "message")) {
|
|
139
153
|
ctx.ui.notify("harness: --harness needs a new session, not one with messages", "error");
|
|
@@ -144,10 +158,8 @@ function flagEntry(pi: ExtensionAPI, ctx: ExtensionContext): MirrorEntry | undef
|
|
|
144
158
|
let target: MirrorTarget;
|
|
145
159
|
|
|
146
160
|
try {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
handle: child || AGENT_HANDLE,
|
|
150
|
-
};
|
|
161
|
+
const name = project ? checkProject(project) : resolveProject(process.env, ctx.cwd);
|
|
162
|
+
target = conversation ? { project: name, conversation } : { project: name, handle: child || AGENT_HANDLE };
|
|
151
163
|
} catch (error) {
|
|
152
164
|
ctx.ui.notify(`harness: ${errorMessage(error)}`, "error");
|
|
153
165
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The state of the agent of a mirror session for the terminal, with the Program Status Protocol (OSC 7501): `working`
|
|
3
|
+
* while it works, `error` when its last request failed, and `idle` in all other cases.
|
|
4
|
+
*/
|
|
5
|
+
import type { ProgramStatus, Terminal } from "@earendil-works/pi-tui";
|
|
6
|
+
import { type Mirror, targetTitle } from "../dist/mirror.js";
|
|
7
|
+
import { childTitle } from "../dist/protocol.js";
|
|
8
|
+
|
|
9
|
+
/** The program name of each report. */
|
|
10
|
+
const APP = "harness";
|
|
11
|
+
|
|
12
|
+
type Mirrored = Pick<Mirror, "target" | "status" | "child" | "view" | "busy">;
|
|
13
|
+
|
|
14
|
+
/** The title of the agent of `mirror`. */
|
|
15
|
+
function title({ target, child }: Pick<Mirrored, "target" | "child">): string {
|
|
16
|
+
return child === undefined ? targetTitle(target) : childTitle(child);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The first line of the error of the last failed request, or `Error` when there is none. */
|
|
20
|
+
function errorLine({ view }: Pick<Mirrored, "view">): string {
|
|
21
|
+
const failed = view?.entries.findLast((entry) => {
|
|
22
|
+
const message = entry.model?.[0];
|
|
23
|
+
|
|
24
|
+
return entry.kind === "pi.assistant" && message?.role === "assistant" && message.stopReason === "error";
|
|
25
|
+
})?.model?.[0];
|
|
26
|
+
|
|
27
|
+
const text = failed?.role === "assistant" ? failed.errorMessage : undefined;
|
|
28
|
+
|
|
29
|
+
return text?.split(/\r?\n/, 1)[0]?.trim() || "Error";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The state of the agent of `mirror`. */
|
|
33
|
+
function programStatus(mirror: Mirrored): ProgramStatus {
|
|
34
|
+
if (mirror.busy) return { state: "working", app: APP, message: title(mirror) };
|
|
35
|
+
|
|
36
|
+
if (mirror.status.kind === "open" && mirror.child?.state === "error") {
|
|
37
|
+
return { state: "error", app: APP, message: errorLine(mirror) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { state: "idle", app: APP, message: title(mirror) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class ProgramStatusReport {
|
|
44
|
+
readonly #terminal: Pick<Terminal, "setProgramStatus">;
|
|
45
|
+
/** The state and the message of the last report. */
|
|
46
|
+
#last: string | undefined;
|
|
47
|
+
|
|
48
|
+
constructor(terminal: Pick<Terminal, "setProgramStatus">) {
|
|
49
|
+
this.#terminal = terminal;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Reports the state of the agent of `mirror` when its state or its message changed since the last report. */
|
|
53
|
+
update(mirror: Mirrored): void {
|
|
54
|
+
const report = programStatus(mirror);
|
|
55
|
+
const key = `${report.state}\n${report.message}`;
|
|
56
|
+
|
|
57
|
+
if (key === this.#last) return;
|
|
58
|
+
this.#last = key;
|
|
59
|
+
this.#terminal.setProgramStatus(report);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Reports `idle` one more time, when Pi leaves the session of `mirror`. */
|
|
63
|
+
leave(mirror: Pick<Mirrored, "target" | "child">): void {
|
|
64
|
+
this.#terminal.setProgramStatus({ state: "idle", app: APP, message: title(mirror) });
|
|
65
|
+
}
|
|
66
|
+
}
|