@vzlentin/harness 0.2.3 → 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/dist/mirror.js ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * A mirror session is a Pi session that shows a conversation of a harness host and sends what the user types to it.
3
+ * The conversation stays in the host; the Pi session keeps only its target, in one custom entry. This module has no
4
+ * runtime import of Pi, chord or pi-durable, so the Pi extension can load it.
5
+ */
6
+ import { randomUUID } from "node:crypto";
7
+ import { setTimeout as sleep } from "node:timers/promises";
8
+ import { Type } from "typebox";
9
+ import { Value } from "typebox/value";
10
+ import { connect } from "./client.js";
11
+ import { AGENT_HANDLE, errorMessage, runDisplayName, } from "./protocol.js";
12
+ import { applyChange } from "./view-change.js";
13
+ import { inboxOf, withdrawnTexts } from "./view-state.js";
14
+ /** The `customType` of the session entry that holds the target of a mirror session. */
15
+ export const MIRROR_ENTRY = "harness-mirror";
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
+ ]);
24
+ /** Whether the data of a session entry is a mirror target. */
25
+ export function isMirrorTarget(data) {
26
+ return Value.Check(MirrorTarget, data);
27
+ }
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" };
33
+ /** The arguments of `pi` that open a mirror session of `target`. */
34
+ export function piArgs(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];
40
+ return [`--${PI_FLAGS.project}`, target.project, ...child];
41
+ }
42
+ /**
43
+ * The arguments of the `harness` CLI that open `target` in Pi: `<project>` for the project agent, `--project <project>
44
+ * attach <handle>` for a child, and `--project <project> attach --conversation <id>` for a conversation.
45
+ */
46
+ export function harnessArgs(target) {
47
+ if (target.conversation !== undefined) {
48
+ return ["--project", target.project, "attach", "--conversation", target.conversation];
49
+ }
50
+ return target.handle === AGENT_HANDLE ? [target.project] : ["--project", target.project, "attach", target.handle];
51
+ }
52
+ /** The title of the agent of `target` before the host gives its name: the handle or the conversation ID of a child. */
53
+ export function targetTitle(target) {
54
+ if (target.conversation !== undefined)
55
+ return target.conversation;
56
+ return target.handle === AGENT_HANDLE ? `${target.project} agent` : target.handle;
57
+ }
58
+ const RETRY_DELAYS_MS = [0, 2000, 5000];
59
+ /** A connection that was opened again and ends sooner than this is not opened again: the host fails at each start. */
60
+ const SHORT_CONNECTION_MS = 10_000;
61
+ const HOST_CLOSED = "The harness host closed the connection";
62
+ /**
63
+ * A live copy of one conversation of a harness host, and the requests that act on it. It connects at once, which starts
64
+ * the host when none runs, and connects again when the host closes the connection, with one try after each of
65
+ * `retryDelaysMs`. It fails for good when the first connection fails, when all the tries fail, when the child is
66
+ * stopped, and when the host closes a connection that was opened again less than 10 s before, so that a host that fails
67
+ * at each start does not start again and again. `onChange` runs after each change of `status`, `view` and `child`, with
68
+ * the status before the change.
69
+ */
70
+ export class Mirror {
71
+ target;
72
+ /** The target of the requests: the handle or the conversation of `target`, without the project. */
73
+ #ref;
74
+ #onChange;
75
+ #retryDelaysMs;
76
+ #start;
77
+ /** Stops the wait for a host that starts, and the waits between the tries, when the mirror closes. */
78
+ #closing = new AbortController();
79
+ /** The messages that the host does not have yet, in the order of `send`. */
80
+ #outgoing = [];
81
+ #status = { kind: "connecting" };
82
+ #view;
83
+ #child;
84
+ #client;
85
+ #sending = false;
86
+ constructor(target, onChange, options = {}) {
87
+ this.target = target;
88
+ this.#ref = target.conversation === undefined ? { handle: target.handle } : { conversation: target.conversation };
89
+ this.#onChange = onChange;
90
+ this.#retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS;
91
+ this.#start = options.start ?? true;
92
+ void this.#run();
93
+ }
94
+ get status() {
95
+ return this.#status;
96
+ }
97
+ /** The conversation, as the last connection gave it. It is undefined before the first connection. */
98
+ get view() {
99
+ return this.#view;
100
+ }
101
+ /** The child or the project agent, as the last connection gave it. It is undefined before the first connection. */
102
+ get child() {
103
+ return this.#child;
104
+ }
105
+ /** Whether the conversation works, as an open connection tells. */
106
+ get busy() {
107
+ return this.#status.kind === "open" && this.#child?.state === "busy";
108
+ }
109
+ /**
110
+ * Sends `message`: a new turn when the conversation is idle, else a steer, or a follow-up with `followUp`. It resolves
111
+ * once the host has the message. The message waits while the mirror connects. When its connection closes before the
112
+ * host answers, it goes again on the next connection with the same request ID, so that the host takes it once. It
113
+ * rejects when the host refuses the message, and when the mirror fails or closes before the host has it.
114
+ */
115
+ send(message, followUp) {
116
+ if (this.#status.kind === "failed")
117
+ return Promise.reject(new Error(this.#status.reason));
118
+ if (this.#closing.signal.aborted)
119
+ return Promise.reject(new Error("The mirror is closed"));
120
+ const outgoing = { requestId: randomUUID(), message, followUp, sent: Promise.withResolvers() };
121
+ this.#outgoing.push(outgoing);
122
+ void this.#flush();
123
+ return outgoing.sent.promise;
124
+ }
125
+ /** Aborts the turn, and gives the texts of the queued messages that the host withdraws, steers first. */
126
+ async abort() {
127
+ const { client, view } = this.#connection();
128
+ const texts = withdrawnTexts(inboxOf(view));
129
+ this.#setChild(await client.request({ type: "abort", ...this.#ref }));
130
+ return texts;
131
+ }
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
+ */
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
+ }
141
+ const { client } = this.#connection();
142
+ this.#setChild(await client.request({ type: "configure", handle, ...change }));
143
+ }
144
+ /** Closes the connection and stops connecting. The conversation keeps running in the host. */
145
+ close() {
146
+ this.#closing.abort();
147
+ this.#client?.close();
148
+ this.#reject(new Error("The mirror is closed"));
149
+ }
150
+ /** Connects, follows the watch, and connects again after the host closed the connection, until a failure or `close()`. */
151
+ async #run() {
152
+ let attempt = await this.#connect([0]);
153
+ let again = false;
154
+ while (!(attempt instanceof Error)) {
155
+ const opened = Date.now();
156
+ this.#client = attempt.client;
157
+ this.#view = attempt.view;
158
+ this.#child = attempt.child;
159
+ this.#setStatus({ kind: "open" });
160
+ void this.#flush();
161
+ const end = await this.#follow(attempt.view, attempt.lines);
162
+ this.#client = undefined;
163
+ attempt.client.close();
164
+ if (this.#closing.signal.aborted)
165
+ return;
166
+ if (end === "stopped") {
167
+ this.#fail(`${runDisplayName(this.#child ?? attempt.child)} was stopped`);
168
+ return;
169
+ }
170
+ if (again && Date.now() - opened < SHORT_CONNECTION_MS) {
171
+ this.#fail(`${HOST_CLOSED}. Run /reload to attach again`);
172
+ return;
173
+ }
174
+ again = true;
175
+ this.#setStatus({ kind: "reconnecting" });
176
+ attempt = await this.#connect(this.#retryDelaysMs);
177
+ }
178
+ if (this.#closing.signal.aborted)
179
+ return;
180
+ this.#fail(again ? `${HOST_CLOSED}. Connecting again failed: ${attempt.message}` : attempt.message);
181
+ }
182
+ /**
183
+ * A connection with the watch of the target, after one try at each delay of `delays`. It is the error of the last try
184
+ * when all the tries fail, and an error after `close()`.
185
+ */
186
+ async #connect(delays) {
187
+ const { signal } = this.#closing;
188
+ let error = new Error("Could not connect to the harness host");
189
+ for (const delay of delays) {
190
+ if (delay > 0)
191
+ await sleep(delay, undefined, { signal }).catch(() => { });
192
+ if (signal.aborted)
193
+ break;
194
+ let client;
195
+ try {
196
+ client = await connect({ start: this.#start, project: this.target.project, signal });
197
+ if (client === undefined)
198
+ throw new Error("Could not connect to the harness host");
199
+ const watched = await client.watch(this.#ref);
200
+ if (signal.aborted) {
201
+ client.close();
202
+ break;
203
+ }
204
+ return { ...watched, client };
205
+ }
206
+ catch (cause) {
207
+ client?.close();
208
+ error = new Error(errorMessage(cause));
209
+ }
210
+ }
211
+ return signal.aborted ? new Error("The mirror is closed") : error;
212
+ }
213
+ /** Applies each change of the watch until it ends: `stopped` when the child was stopped, else `closed`. */
214
+ async #follow(view, lines) {
215
+ let current = view;
216
+ for await (const line of lines) {
217
+ if (line.stopped)
218
+ return "stopped";
219
+ current = applyChange(current, line.change);
220
+ this.#view = current;
221
+ this.#child = line.child;
222
+ this.#onChange(this.#status);
223
+ }
224
+ return "closed";
225
+ }
226
+ /** Sends the waiting messages on the open connection, one at a time, in order. */
227
+ async #flush() {
228
+ if (this.#sending)
229
+ return;
230
+ this.#sending = true;
231
+ try {
232
+ for (let next = this.#outgoing[0]; next !== undefined; next = this.#outgoing[0]) {
233
+ const client = this.#client;
234
+ if (client === undefined)
235
+ return;
236
+ const { requestId, message, followUp } = next;
237
+ try {
238
+ const reply = await client.request({
239
+ type: "input",
240
+ requestId,
241
+ ...this.#ref,
242
+ message,
243
+ followUp,
244
+ });
245
+ // `close()` and a failure settle the waiting messages themselves.
246
+ if (this.#outgoing[0] !== next)
247
+ return;
248
+ this.#outgoing.shift();
249
+ this.#setChild(reply.child);
250
+ next.sent.resolve();
251
+ }
252
+ catch (error) {
253
+ // After the end of the connection, the host may have the message or not, so it goes again.
254
+ if (client.ended || this.#outgoing[0] !== next)
255
+ return;
256
+ this.#outgoing.shift();
257
+ next.sent.reject(error);
258
+ }
259
+ }
260
+ }
261
+ finally {
262
+ this.#sending = false;
263
+ }
264
+ }
265
+ /** The open connection and its view, or an error without one. */
266
+ #connection() {
267
+ const client = this.#client;
268
+ const view = this.#view;
269
+ if (client === undefined || view === undefined)
270
+ throw new Error("Not connected to the harness host");
271
+ return { client, view };
272
+ }
273
+ #fail(reason) {
274
+ this.#reject(new Error(reason));
275
+ this.#setStatus({ kind: "failed", reason });
276
+ }
277
+ /** Rejects the waiting messages with `error`. */
278
+ #reject(error) {
279
+ for (const outgoing of this.#outgoing.splice(0))
280
+ outgoing.sent.reject(error);
281
+ }
282
+ #setStatus(status) {
283
+ const previous = this.#status;
284
+ this.#status = status;
285
+ this.#onChange(previous);
286
+ }
287
+ #setChild(child) {
288
+ this.#child = child;
289
+ this.#onChange(this.#status);
290
+ }
291
+ }
@@ -1,20 +1,24 @@
1
1
  import type { Socket } from "node:net";
2
2
  import type { JsonValue } from "@earendil-works/chord";
3
- import type { Op } from "@earendil-works/chord/delta";
4
3
  import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
5
4
  import type { ConversationView, TaskId, TaskOutcome, TaskState } from "@earendil-works/pi-durable";
6
5
  import { type Static, Type } from "typebox";
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 = 7;
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
- /** One child as the host reports it. Provider, model and thinking level come from the child's agent. */
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;
@@ -37,19 +41,21 @@ export type TaskInfo = {
37
41
  readonly status: TaskState<JsonValue, JsonValue>["status"];
38
42
  readonly outcome?: TaskOutcome<JsonValue>;
39
43
  };
40
- /** A model that the host can run, with what the viewer needs to show and change it. */
41
- export type ModelInfo = {
42
- readonly provider: string;
43
- readonly id: string;
44
- readonly name: string;
45
- readonly contextWindow: number;
46
- /** The thinking levels of the model, from `off`. */
47
- readonly thinkingLevels: readonly ModelThinkingLevel[];
48
- };
49
44
  /** Any JSON value. A request line is JSON, so `start` takes any input. */
50
45
  export declare const Json: Type.TCyclic<{
51
46
  Json: Type.TUnion<[Type.TNull, Type.TBoolean, Type.TNumber, Type.TString, Type.TArray<Type.TRef<"Json">>, Type.TRecord<"^.*$", Type.TRef<"Json">>]>;
52
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
+ };
53
59
  /** One request line. The caller is `PI_SESSION_ID`, or `cli` without one. */
54
60
  export type Request = {
55
61
  readonly type: "hello";
@@ -96,25 +102,19 @@ export type Request = {
96
102
  readonly handle: string;
97
103
  }
98
104
  /**
99
- * The viewer's stream of the child: the reply, then one `{ event: WatchLine }` line after each commit to the child's
100
- * conversation and after each rename. Every other line on the connection stays a reply, in request order. A
101
- * connection carries one watch.
105
+ * The stream of the child for a mirror session of the Pi extension: the reply, then one `{ event: WatchLine }` line
106
+ * after each commit to the child's conversation and after each rename. Every other line on the connection stays a
107
+ * reply, in request order. A connection carries one watch.
102
108
  */
103
- | {
109
+ | ({
104
110
  readonly type: "watch";
105
- readonly handle: string;
106
- }
107
- /** Stops the child's current turn. The viewer sends it. */
108
- | {
111
+ } & Target)
112
+ /** Stops the child's current turn. A mirror session sends it. */
113
+ | ({
109
114
  readonly type: "abort";
110
- readonly handle: string;
111
- }
112
- /** The models that have a login, for the viewer's model picker and context window. */
113
- | {
114
- readonly type: "models";
115
- }
115
+ } & Target)
116
116
  /**
117
- * The viewer's change of the child's model or thinking level. It has no caller. A new model keeps the thinking level
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
118
118
  * when the model has it, and takes the nearest level that it has otherwise. The host refuses a level that the model
119
119
  * does not have.
120
120
  */
@@ -125,14 +125,13 @@ export type Request = {
125
125
  readonly model?: string;
126
126
  readonly thinking?: ModelThinkingLevel;
127
127
  }
128
- /** The viewer's message to the child. It has no caller. */
129
- | {
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
+ | ({
130
130
  readonly type: "input";
131
131
  readonly requestId: string;
132
- readonly handle: string;
133
132
  readonly message: string;
134
133
  readonly followUp: boolean;
135
- }
134
+ } & Target)
136
135
  /**
137
136
  * Starts a background task of the kind `kind`, a task of an entry in `entries.json`, in the root conversation.
138
137
  * A repeated `key` gives the first task, and its new kind, version and input are ignored. With `version`, a host
@@ -203,27 +202,45 @@ export declare const RequestLine: Type.TUnion<[Type.TObject<{
203
202
  type: Type.TLiteral<"stop">;
204
203
  caller: Type.TString;
205
204
  handle: Type.TString;
206
- }>, Type.TObject<{
205
+ }>, Type.TUnion<[Type.TObject<{
207
206
  type: Type.TLiteral<"watch">;
208
207
  handle: Type.TString;
208
+ conversation: Type.TOptional<Type.TNever>;
209
209
  }>, Type.TObject<{
210
+ type: Type.TLiteral<"watch">;
211
+ conversation: Type.TString;
212
+ handle: Type.TOptional<Type.TNever>;
213
+ }>]>, Type.TUnion<[Type.TObject<{
210
214
  type: Type.TLiteral<"abort">;
211
215
  handle: Type.TString;
216
+ conversation: Type.TOptional<Type.TNever>;
212
217
  }>, Type.TObject<{
213
- type: Type.TLiteral<"models">;
214
- }>, Type.TObject<{
218
+ type: Type.TLiteral<"abort">;
219
+ conversation: Type.TString;
220
+ handle: Type.TOptional<Type.TNever>;
221
+ }>]>, Type.TObject<{
215
222
  type: Type.TLiteral<"configure">;
216
223
  handle: Type.TString;
217
224
  provider: Type.TOptional<Type.TString>;
218
225
  model: Type.TOptional<Type.TString>;
219
226
  thinking: Type.TOptional<Type.TString>;
220
- }>, Type.TObject<{
221
- type: Type.TLiteral<"input">;
227
+ }>, Type.TUnion<[Type.TObject<{
222
228
  requestId: Type.TString;
223
- handle: Type.TString;
224
229
  message: Type.TString;
225
230
  followUp: Type.TBoolean;
231
+ } & {
232
+ type: Type.TLiteral<"input">;
233
+ handle: Type.TString;
234
+ conversation: Type.TOptional<Type.TNever>;
226
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<{
227
244
  type: Type.TLiteral<"start">;
228
245
  kind: Type.TString;
229
246
  input: Type.TCyclic<{
@@ -269,7 +286,6 @@ export type Replies = {
269
286
  };
270
287
  readonly abort: ChildInfo;
271
288
  readonly input: Replies["send"];
272
- readonly models: readonly ModelInfo[];
273
289
  readonly configure: ChildInfo;
274
290
  readonly start: TaskInfo;
275
291
  readonly task: TaskInfo;
@@ -277,11 +293,11 @@ export type Replies = {
277
293
  readonly "abort-task": TaskInfo;
278
294
  };
279
295
  /**
280
- * An event of a watch after its reply, sent as `{ event: WatchLine }`: the changes of one commit or a rename, or the end
281
- * when the child was stopped.
296
+ * An event of a watch after its reply, sent as `{ event: WatchLine }`: the change of one commit, an empty change after a
297
+ * rename, or the end when the child was stopped.
282
298
  */
283
299
  export type WatchLine = {
284
- readonly ops: readonly Op[];
300
+ readonly change: ViewChange;
285
301
  readonly child: ChildInfo;
286
302
  readonly stopped?: undefined;
287
303
  } | {
@@ -308,16 +324,26 @@ export type StatePaths = {
308
324
  };
309
325
  export declare function statePaths(project: string): StatePaths;
310
326
  export declare function isValidRunName(value: string): boolean;
311
- /** The label of a child: its name, or its handle when it has no name. */
312
- export declare function childLabel(child: {
327
+ type Labeled = {
313
328
  readonly handle: string;
314
329
  readonly name?: string | undefined;
315
- }): string;
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;
316
334
  export declare function runDisplayName(metadata: {
317
335
  readonly handle: string;
318
336
  readonly name?: string | undefined;
319
337
  }): string;
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;
343
+ /** The message of a thrown value. */
344
+ export declare function errorMessage(cause: unknown): string;
320
345
  /** The line of one child in a list. */
321
346
  export declare function childLine(child: ChildInfo): string;
322
347
  /** Calls `onLine` with each line that arrives on `socket`, without its newline. */
323
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 = 7;
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,9 +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
- Type.Object({ type: Type.Literal("watch"), handle: Type.String() }),
56
- Type.Object({ type: Type.Literal("abort"), handle: Type.String() }),
57
- Type.Object({ type: Type.Literal("models") }),
75
+ targeted("watch", {}),
76
+ targeted("abort", {}),
58
77
  Type.Object({
59
78
  type: Type.Literal("configure"),
60
79
  handle: Type.String(),
@@ -62,13 +81,7 @@ export const RequestLine = Type.Union([
62
81
  model: Type.Optional(Type.String()),
63
82
  thinking: Type.Optional(Type.String()),
64
83
  }),
65
- Type.Object({
66
- type: Type.Literal("input"),
67
- requestId: Type.String(),
68
- handle: Type.String(),
69
- message: Type.String(),
70
- followUp: Type.Boolean(),
71
- }),
84
+ targeted("input", { requestId: Type.String(), message: Type.String(), followUp: Type.Boolean() }),
72
85
  Type.Object({
73
86
  type: Type.Literal("start"),
74
87
  kind: Type.String(),
@@ -107,6 +120,17 @@ export function childLabel(child) {
107
120
  export function runDisplayName(metadata) {
108
121
  return metadata.name ? `${metadata.name} (${metadata.handle})` : metadata.handle;
109
122
  }
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
+ */
127
+ export function childTitle(child) {
128
+ return child.handle === AGENT_HANDLE || child.conversation !== undefined ? childLabel(child) : runDisplayName(child);
129
+ }
130
+ /** The message of a thrown value. */
131
+ export function errorMessage(cause) {
132
+ return cause instanceof Error ? cause.message : String(cause);
133
+ }
110
134
  /** The line of one child in a list. */
111
135
  export function childLine(child) {
112
136
  return `${runDisplayName(child)} ${child.state.padEnd(8)} ${child.provider}/${child.model} ${child.thinking}`;
package/dist/setup.d.ts CHANGED
@@ -10,3 +10,5 @@ export declare function setup(packageDir: string): void;
10
10
  * throws; an error is a warning.
11
11
  */
12
12
  export declare function setupOnce(packageDir: string): void;
13
+ /** The `PATH` without the `node_modules/.bin` folders that npm adds: pi-coding-agent there has its own `pi`. */
14
+ export declare function userPath(): string;
package/dist/setup.js CHANGED
@@ -10,6 +10,7 @@ import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { Type } from "typebox";
12
12
  import { Value } from "typebox/value";
13
+ import { errorMessage } from "./protocol.js";
13
14
  const PackageJson = Type.Object({ name: Type.String(), version: Type.String() });
14
15
  const PiSettings = Type.Object({
15
16
  packages: Type.Optional(Type.Array(Type.Union([Type.String(), Type.Object({ source: Type.String() })]))),
@@ -42,7 +43,7 @@ export function setupOnce(packageDir) {
42
43
  writeStamp(installed);
43
44
  }
44
45
  catch (error) {
45
- process.stderr.write(`harness: setup failed: ${error instanceof Error ? error.message : String(error)}\n`);
46
+ process.stderr.write(`harness: setup failed: ${errorMessage(error)}\n`);
46
47
  }
47
48
  }
48
49
  /**
@@ -137,7 +138,7 @@ function expandHome(path) {
137
138
  return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
138
139
  }
139
140
  /** The `PATH` without the `node_modules/.bin` folders that npm adds: pi-coding-agent there has its own `pi`. */
140
- function userPath() {
141
+ export function userPath() {
141
142
  return (process.env.PATH ?? "")
142
143
  .split(delimiter)
143
144
  .filter((entry) => !entry.endsWith(join("node_modules", ".bin")))