@vzlentin/harness 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/host.js CHANGED
@@ -22,16 +22,20 @@ import * as durable from "@earendil-works/pi-durable";
22
22
  import { AgentDoc, createRegistry, defineDoc, Harness, } from "@earendil-works/pi-durable";
23
23
  import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
24
24
  import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
25
+ import { Type } from "typebox";
26
+ import { Value } from "typebox/value";
25
27
  import { createChildren, resolveModel, } from "./children.js";
26
28
  import { ChildContext } from "./context.js";
27
29
  import { readEntries } from "./entries.js";
28
- import { AGENT_HANDLE, onLines, PROTOCOL_VERSION, } from "./protocol.js";
30
+ import { AGENT_HANDLE, onLines, PROTOCOL_VERSION, RequestLine, } from "./protocol.js";
29
31
  import { abortTask, startableTasks, startTask, taskInfo, waitTask } from "./tasks.js";
30
32
  import { subagentTool } from "./tool.js";
31
33
  /** The host exits after this long with no connection, no live task and no request. */
32
34
  const IDLE_EXIT_MS = 5 * 60_000;
33
35
  const IDLE_CHECK_MS = 60_000;
34
36
  const context = BACKGROUND_CONTEXT;
37
+ /** The `detail` of a failed turn that is an error object. */
38
+ const ErrorDetail = Type.Object({ message: Type.String() });
35
39
  /**
36
40
  * The children by handle, and the handle of each spawn request ID. Version 2 leaves each child's last request to the
37
41
  * children library; a store with version 1 has children that the library does not know.
@@ -70,7 +74,7 @@ export async function runHost(options) {
70
74
  const settings = SettingsManager.create(stateDir, agentDir, {
71
75
  projectTrusted: new ProjectTrustStore(agentDir).get(stateDir) === true,
72
76
  });
73
- const warn = (error) => process.stderr.write(`warning: ${error instanceof Error ? error.message : String(error)}\n`);
77
+ const warn = (cause) => process.stderr.write(`warning: ${cause instanceof Error ? cause.message : String(cause)}\n`);
74
78
  // The store is there once the harness is open; entries get `place` before, and use the store in their tasks.
75
79
  let harness;
76
80
  const place = {
@@ -84,18 +88,18 @@ export async function runHost(options) {
84
88
  return registry.snapshot();
85
89
  },
86
90
  };
87
- const entries = await loadEntries(stateDir, agentDir, settings, place, warn).catch((error) => {
91
+ const entries = await loadEntries(stateDir, agentDir, settings, place, warn).catch((cause) => {
88
92
  lock.close();
89
- throw error;
93
+ throw cause;
90
94
  });
91
95
  const envs = new Map();
92
96
  const startable = startableTasks(entries.tasks);
93
97
  let base;
94
98
  // The project agent is the root conversation, set up at its first use. A failed set-up runs again at the next use.
95
99
  let agentSetUp;
96
- const agent = () => (agentSetUp ??= setUpAgent(base.root, models, settings, project, cwd).catch((error) => {
100
+ const agent = () => (agentSetUp ??= setUpAgent(base.root, models, settings, project, cwd).catch((cause) => {
97
101
  agentSetUp = undefined;
98
- throw error;
102
+ throw cause;
99
103
  }));
100
104
  // A child calls the host in this process, not through the socket, so a long `wait` cannot hold up the requests
101
105
  // of a connection. Its caller is its conversation ID, which the depth limit and the owner checks key on.
@@ -174,7 +178,7 @@ export async function runHost(options) {
174
178
  let start;
175
179
  const connection = { socket, afterReply: (afterReply) => (start = afterReply) };
176
180
  try {
177
- const request = JSON.parse(line);
181
+ const request = parseRequest(line);
178
182
  if (request.type !== "hello" && request.type !== "list" && request.type !== "status") {
179
183
  lastActivity = Date.now();
180
184
  }
@@ -204,11 +208,11 @@ export async function runHost(options) {
204
208
  server.off("error", rejectListen);
205
209
  resolveListen();
206
210
  });
207
- }).catch(async (error) => {
211
+ }).catch(async (cause) => {
208
212
  await entries.close();
209
213
  await harness.close(context);
210
214
  lock.close();
211
- throw error;
215
+ throw cause;
212
216
  });
213
217
  let closing;
214
218
  const close = () => {
@@ -252,8 +256,19 @@ async function handleRequest(request, handler) {
252
256
  return { version: PROTOCOL_VERSION, pid: process.pid };
253
257
  case "spawn":
254
258
  return spawnChild(request, handler);
255
- case "send":
256
- return sendTo(request, await childTarget(handler, request.handle, await ownRecord(handler.harness, request.handle, request.caller)));
259
+ case "send": {
260
+ const record = await ownRecord(handler.harness, request.handle, request.caller);
261
+ // Mark the request first: a wait that sees it as the last request must find it in `sent`.
262
+ const added = await markSent(handler.harness, request.handle, request.requestId, true);
263
+ try {
264
+ return await sendTo(request, await childTarget(handler, request.handle, record));
265
+ }
266
+ catch (error) {
267
+ if (added)
268
+ await markSent(handler.harness, request.handle, request.requestId, false);
269
+ throw error;
270
+ }
271
+ }
257
272
  case "input":
258
273
  return sendTo(request, await target(handler, request.handle));
259
274
  case "wait":
@@ -319,12 +334,19 @@ async function handleRequest(request, handler) {
319
334
  delete state.requests[requestId];
320
335
  }
321
336
  }, context);
322
- return { handle: request.handle, ...(record.name === undefined ? {} : { name: record.name }) };
337
+ return record.name === undefined ? { handle: request.handle } : { handle: request.handle, name: record.name };
323
338
  }
324
- default:
325
- throw new Error(`Unknown request: ${request.type}`);
326
339
  }
327
340
  }
341
+ /** The request of a line from a client, checked against the protocol. */
342
+ function parseRequest(line) {
343
+ const request = JSON.parse(line);
344
+ if (Value.Check(RequestLine, request))
345
+ return request;
346
+ throw new Error(Value.Check(Type.Object({ type: Type.String() }), request)
347
+ ? `Unknown or invalid request: ${request.type}`
348
+ : "Invalid request");
349
+ }
328
350
  async function spawnChild(request, handler) {
329
351
  if (!request.cwd)
330
352
  throw new Error("spawn requires a working directory");
@@ -337,27 +359,25 @@ async function spawnChild(request, handler) {
337
359
  request.caller === String(handler.root.id)
338
360
  ? request.caller
339
361
  : undefined;
340
- const spec = {
362
+ let spec = {
341
363
  ...(parent === undefined ? { root: request.caller } : { parent }),
342
364
  provider: request.provider,
343
365
  model: request.model,
344
- ...(request.thinking === undefined ? {} : { thinking: request.thinking }),
345
366
  fallbackThinking: request.callerThinking,
346
367
  cwd: request.cwd,
347
- ...(request.tools === undefined ? {} : { tools: request.tools }),
348
368
  };
369
+ if (request.thinking !== undefined)
370
+ spec = { ...spec, thinking: request.thinking };
371
+ if (request.tools !== undefined)
372
+ spec = { ...spec, tools: request.tools };
349
373
  // The record is written in the commit that creates the child.
350
374
  await library.open(place, handler.owner, `sub:${request.requestId}`, spec, context, async (tx, id) => {
351
375
  const current = await tx.doc(Children);
352
376
  let fresh = randomBytes(3).toString("hex");
353
377
  while (Object.hasOwn(current.children, fresh))
354
378
  fresh = randomBytes(3).toString("hex");
355
- current.children[fresh] = {
356
- ...(request.name === undefined ? {} : { name: request.name }),
357
- caller: request.caller,
358
- createdAt: new Date().toISOString(),
359
- conversationId: id,
360
- };
379
+ const record = { caller: request.caller, createdAt: new Date().toISOString(), conversationId: id };
380
+ current.children[fresh] = request.name === undefined ? record : { name: request.name, ...record };
361
381
  current.requests[request.requestId] = fresh;
362
382
  });
363
383
  handle = (await harness.snapshot(Children, context))?.requests[request.requestId];
@@ -367,6 +387,7 @@ async function spawnChild(request, handler) {
367
387
  const record = await childRecord(harness, handle);
368
388
  // The request ID makes a repeated spawn find the first submission, also after a crash before it.
369
389
  await (await childOf(handler, record)).send({ type: "input", content: request.prompt, requestId: `spawn:${request.requestId}` }, context);
390
+ await markSent(harness, handle, `spawn:${request.requestId}`, true);
370
391
  return childInfo(handler, handle, record);
371
392
  }
372
393
  /** Sends the input of `request` to `found`. */
@@ -384,23 +405,87 @@ function sendVerb(busy, followUp) {
384
405
  return !busy ? "Prompted" : followUp ? "Queued follow-up for" : "Steered";
385
406
  }
386
407
  async function waitChild(request, handler) {
387
- const child = await childOf(handler, await childRecord(handler.harness, request.handle));
408
+ const record = await childRecord(handler.harness, request.handle);
409
+ const child = await childOf(handler, record);
388
410
  const timeout = AbortSignal.timeout(request.timeout * 1000);
411
+ const waiting = withAbortSignal(AbortSignal.any([timeout, handler.signal]), context);
412
+ let main;
389
413
  let answer;
414
+ let earlier;
390
415
  try {
391
- answer = await child.settled(withAbortSignal(AbortSignal.any([timeout, handler.signal]), context));
416
+ main = await child.lastRequest(waiting);
417
+ answer = await child.settled(waiting, main ?? undefined);
418
+ // Read the record after the answer: a send is in `sent` before it can be the last request.
419
+ const current = (await childRecord(handler.harness, request.handle)).sent ?? [];
420
+ const unread = unreadBefore(current, record.read ?? 0, main, record.sent ?? []);
421
+ earlier = [];
422
+ for (const requestId of unread.ids) {
423
+ const before = await child.settled(waiting, requestId);
424
+ // A steer goes into the turn that was running, so its request can share that turn's answer.
425
+ if (!("entry" in before && "entry" in answer && before.entry === answer.entry))
426
+ earlier.push(before);
427
+ }
428
+ await handler.harness.commit(async (tx) => {
429
+ const latest = (await tx.doc(Children)).children[request.handle];
430
+ if (latest !== undefined)
431
+ latest.read = Math.max(latest.read ?? 0, unread.read);
432
+ }, context);
392
433
  }
393
434
  catch (error) {
394
435
  if (timeout.aborted)
395
436
  throw new Error(`Timed out after ${request.timeout}s waiting for ${request.handle}`);
396
437
  throw error;
397
438
  }
398
- // A cut answer still gives its text.
439
+ const texts = earlier.map((before) => {
440
+ const text = answerText(request.handle, before);
441
+ return text.failed ? `(no answer: ${text.text})` : text.text;
442
+ });
443
+ const last = answerText(request.handle, answer);
444
+ const all = [...texts, last.text];
445
+ const parts = all.map((text, index) => `--- Answer ${index + 1} of ${all.length} ---\n${text}`);
446
+ const text = texts.length === 0 ? last.text : `${all.length} unread answers, oldest first.\n\n${parts.join("\n\n")}`;
447
+ // A failed last turn is an error that still gives the earlier answers.
448
+ if (last.failed)
449
+ throw new Error(text);
450
+ return { answer: text };
451
+ }
452
+ /**
453
+ * The requests of `sent` from `read` on that a wait for `main` gives before the answer to `main`, and the new `read`.
454
+ * When `main` is a request of the caller, they are the ones before it, and `main` counts as read; a send after it
455
+ * stays unread. Otherwise, such as for viewer input, they are the caller's requests known when the wait started.
456
+ */
457
+ function unreadBefore(sent, read, main, atStart) {
458
+ const index = main === null ? -1 : sent.indexOf(main);
459
+ if (index >= read)
460
+ return { ids: sent.slice(read, index), read: index + 1 };
461
+ if (index >= 0)
462
+ return { ids: [], read };
463
+ return { ids: atStart.slice(read), read: Math.max(read, atStart.length) };
464
+ }
465
+ /** The text of `answer`, or the failure of a turn with no answer. A cut answer still gives its text. */
466
+ function answerText(handle, answer) {
399
467
  if (answer.status === "ok" || answer.reason !== "unanswered")
400
- return { answer: answer.text };
401
- if (answer.cause === "queued")
402
- throw new Error(`${request.handle} stopped with queued messages after a failed turn`);
403
- throw new Error(failureText(request.handle, answer.cause, answer.detail));
468
+ return { text: answer.text, failed: false };
469
+ if (answer.cause === "queued") {
470
+ return { text: `${handle} stopped with queued messages after a failed turn`, failed: true };
471
+ }
472
+ return { text: failureText(handle, answer.cause, answer.detail), failed: true };
473
+ }
474
+ /**
475
+ * Adds `requestId` to the requests of `handle` that a `wait` gives the answers of, or removes it after a send that
476
+ * failed. A repeated request is not added. True when it changed the record.
477
+ */
478
+ async function markSent(harness, handle, requestId, add) {
479
+ return harness.commit(async (tx) => {
480
+ const record = (await tx.doc(Children)).children[handle];
481
+ if (record === undefined)
482
+ return false;
483
+ const sent = record.sent ?? [];
484
+ if (add === sent.includes(requestId))
485
+ return false;
486
+ record.sent = add ? [...sent, requestId] : sent.filter((id) => id !== requestId);
487
+ return true;
488
+ }, context);
404
489
  }
405
490
  async function abortChild(request, handler) {
406
491
  const found = await target(handler, request.handle);
@@ -479,9 +564,9 @@ async function childOf(handler, record) {
479
564
  function failureText(handle, reason, detail) {
480
565
  if (reason === "aborted")
481
566
  return `${handle} was aborted`;
482
- const text = typeof detail === "string"
567
+ const text = Value.Check(Type.String(), detail)
483
568
  ? detail
484
- : typeof detail?.message === "string"
569
+ : Value.Check(ErrorDetail, detail)
485
570
  ? detail.message
486
571
  : undefined;
487
572
  return text === undefined ? `${handle} failed: ${reason}` : `${handle} failed: ${text}`;
@@ -493,12 +578,8 @@ async function childInfo(handler, handle, record) {
493
578
  const childState = await child.state(context);
494
579
  // A closed child is idle: the watch can still send it before its record goes and the `stopped` line follows.
495
580
  const state = childState === "closed" ? "idle" : childState;
496
- return {
497
- handle,
498
- ...(record.name === undefined ? {} : { name: record.name }),
499
- state,
500
- ...(await agentFields(child.conversation)),
501
- };
581
+ const fields = await agentFields(child.conversation);
582
+ return record.name === undefined ? { handle, state, ...fields } : { handle, name: record.name, state, ...fields };
502
583
  }
503
584
  /** The model, thinking level and directory of the agent of `conversation`, for its info. */
504
585
  async function agentFields(conversation) {
@@ -643,11 +724,11 @@ function piSettings(settings, extensions) {
643
724
  get stream() {
644
725
  const provider = settings.getProviderRetrySettings();
645
726
  const idle = settings.getHttpIdleTimeoutMs();
646
- return {
727
+ const stream = {
647
728
  timeoutMs: provider.timeoutMs ?? (idle === 0 ? 2147483647 : idle),
648
729
  maxRetryDelayMs: provider.maxRetryDelayMs,
649
- ...(provider.maxRetries === undefined ? {} : { maxRetries: provider.maxRetries }),
650
730
  };
731
+ return provider.maxRetries === undefined ? stream : { ...stream, maxRetries: provider.maxRetries };
651
732
  },
652
733
  get retry() {
653
734
  return settings.getRetrySettings();
@@ -675,6 +756,12 @@ function liveTasks(tasks) {
675
756
  }
676
757
  return tasks.length - dead.size;
677
758
  }
759
+ /** A `package.json`, which is a JSON object. */
760
+ const PackageJson = Type.Object({});
761
+ /** The part of a package's `package.json` that names its entries. */
762
+ const Manifest = Type.Object({
763
+ piDurable: Type.Optional(Type.Object({ extensions: Type.Optional(Type.Array(Type.String())) })),
764
+ });
678
765
  /**
679
766
  * The `piDurable` entries of the installed packages in Pi's `settingsManager` for `cwd`, then the entries of
680
767
  * `entries.json` (see `readEntries`), in one shared event bus, as one entry whose `close()` closes each of them and
@@ -687,7 +774,16 @@ async function loadEntries(cwd, agentDir, settingsManager, place, warn) {
687
774
  .listConfiguredPackages()
688
775
  .flatMap((found) => found.installedPath ?? []);
689
776
  for (const root of new Set(roots)) {
690
- const manifest = JSON.parse(await readFile(join(root, "package.json"), "utf8").catch(() => "{}"));
777
+ const path = join(root, "package.json");
778
+ const manifest = JSON.parse(await readFile(path, "utf8").catch(() => "{}"));
779
+ if (!Value.Check(PackageJson, manifest)) {
780
+ warn(new Error(`${path} is not a valid package.json`));
781
+ continue;
782
+ }
783
+ if (!Value.Check(Manifest, manifest)) {
784
+ warn(new Error(`${path} has an invalid piDurable field`));
785
+ continue;
786
+ }
691
787
  for (const path of manifest.piDurable?.extensions ?? [])
692
788
  modules.add(resolvePath(root, path));
693
789
  }
package/dist/projects.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join, resolve, sep } from "node:path";
4
+ import { Type } from "typebox";
5
+ import { Value } from "typebox/value";
4
6
  /** The project that has no directory. A cwd outside every project goes to it. */
5
7
  export const MAIN_PROJECT = "main";
6
8
  const NAME = /^[a-z][a-z0-9-]{0,31}$/;
@@ -19,20 +21,20 @@ export function readProjects() {
19
21
  text = readFileSync(path, "utf8");
20
22
  }
21
23
  catch (error) {
22
- if (error.code === "ENOENT")
24
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
23
25
  return {};
24
26
  throw error;
25
27
  }
26
28
  const projects = JSON.parse(text);
27
- if (typeof projects !== "object" || projects === null || Array.isArray(projects)) {
29
+ if (!Value.Check(Type.Record(Type.String(), Type.Unknown()), projects)) {
28
30
  throw new Error(`${path} is not an object of project names to directories`);
29
31
  }
30
- for (const [name, dir] of Object.entries(projects)) {
31
- if (!NAME.test(name) || name === MAIN_PROJECT || typeof dir !== "string" || !isAbsolute(dir)) {
32
+ return Object.fromEntries(Object.entries(projects).map(([name, dir]) => {
33
+ if (!NAME.test(name) || name === MAIN_PROJECT || !Value.Check(Type.String(), dir) || !isAbsolute(dir)) {
32
34
  throw new Error(`${path} has an invalid project: ${name}`);
33
35
  }
34
- }
35
- return projects;
36
+ return [name, dir];
37
+ }));
36
38
  }
37
39
  /**
38
40
  * Adds the project `name` with the real path of `dir` to the projects, and gives that path. It throws for an invalid or
@@ -71,7 +73,8 @@ export function checkProject(name, projects = readProjects()) {
71
73
  }
72
74
  /** The directory of the project `name`: the home directory for `main`. It throws for an unknown project. */
73
75
  export function projectDir(name, projects = readProjects()) {
74
- return checkProject(name, projects) === MAIN_PROJECT ? homedir() : projects[name];
76
+ // `checkProject` passes only `main`, which `projects` never has, and the names in `projects`.
77
+ return projects[checkProject(name, projects)] ?? homedir();
75
78
  }
76
79
  /**
77
80
  * The project of a command: `HARNESS_PROJECT` in `env`, then the project with the longest directory that is `cwd` or
@@ -3,6 +3,7 @@ import type { JsonValue } from "@earendil-works/chord";
3
3
  import type { Op } from "@earendil-works/chord/delta";
4
4
  import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
5
5
  import type { ConversationView, TaskId, TaskOutcome, TaskState } from "@earendil-works/pi-durable";
6
+ import { type Static, Type } from "typebox";
6
7
  /** The version of the protocol between the CLI and the host. A client refuses a host with another version. */
7
8
  export declare const PROTOCOL_VERSION = 7;
8
9
  /**
@@ -45,6 +46,10 @@ export type ModelInfo = {
45
46
  /** The thinking levels of the model, from `off`. */
46
47
  readonly thinkingLevels: readonly ModelThinkingLevel[];
47
48
  };
49
+ /** Any JSON value. A request line is JSON, so `start` takes any input. */
50
+ export declare const Json: Type.TCyclic<{
51
+ Json: Type.TUnion<[Type.TNull, Type.TBoolean, Type.TNumber, Type.TString, Type.TArray<Type.TRef<"Json">>, Type.TRecord<"^.*$", Type.TRef<"Json">>]>;
52
+ }, "Json">;
48
53
  /** One request line. The caller is `PI_SESSION_ID`, or `cli` without one. */
49
54
  export type Request = {
50
55
  readonly type: "hello";
@@ -110,7 +115,8 @@ export type Request = {
110
115
  }
111
116
  /**
112
117
  * The viewer's change of the child's model or thinking level. It has no caller. A new model keeps the thinking level
113
- * when the model has it, and takes the nearest level that it has otherwise.
118
+ * when the model has it, and takes the nearest level that it has otherwise. The host refuses a level that the model
119
+ * does not have.
114
120
  */
115
121
  | {
116
122
  readonly type: "configure";
@@ -152,6 +158,90 @@ export type Request = {
152
158
  readonly type: "abort-task";
153
159
  readonly key: string;
154
160
  };
161
+ /**
162
+ * The check of a request line on the host. It is looser than `Request`, so the host answers a spawn without `cwd` and an
163
+ * unknown thinking level with its own errors.
164
+ */
165
+ export declare const RequestLine: Type.TUnion<[Type.TObject<{
166
+ type: Type.TLiteral<"hello">;
167
+ version: Type.TNumber;
168
+ }>, Type.TObject<{
169
+ type: Type.TLiteral<"spawn">;
170
+ requestId: Type.TString;
171
+ caller: Type.TString;
172
+ name: Type.TOptional<Type.TString>;
173
+ provider: Type.TString;
174
+ model: Type.TString;
175
+ thinking: Type.TOptional<Type.TString>;
176
+ callerThinking: Type.TString;
177
+ cwd: Type.TOptional<Type.TString>;
178
+ tools: Type.TOptional<Type.TImmutable<Type.TArray<Type.TString>>>;
179
+ prompt: Type.TString;
180
+ }>, Type.TObject<{
181
+ type: Type.TLiteral<"send">;
182
+ requestId: Type.TString;
183
+ caller: Type.TString;
184
+ handle: Type.TString;
185
+ message: Type.TString;
186
+ followUp: Type.TBoolean;
187
+ }>, Type.TObject<{
188
+ type: Type.TLiteral<"wait">;
189
+ handle: Type.TString;
190
+ timeout: Type.TNumber;
191
+ }>, Type.TObject<{
192
+ type: Type.TLiteral<"status">;
193
+ handle: Type.TString;
194
+ }>, Type.TObject<{
195
+ type: Type.TLiteral<"list">;
196
+ caller: Type.TString;
197
+ }>, Type.TObject<{
198
+ type: Type.TLiteral<"rename">;
199
+ caller: Type.TString;
200
+ handle: Type.TString;
201
+ name: Type.TString;
202
+ }>, Type.TObject<{
203
+ type: Type.TLiteral<"stop">;
204
+ caller: Type.TString;
205
+ handle: Type.TString;
206
+ }>, Type.TObject<{
207
+ type: Type.TLiteral<"watch">;
208
+ handle: Type.TString;
209
+ }>, Type.TObject<{
210
+ type: Type.TLiteral<"abort">;
211
+ handle: Type.TString;
212
+ }>, Type.TObject<{
213
+ type: Type.TLiteral<"models">;
214
+ }>, Type.TObject<{
215
+ type: Type.TLiteral<"configure">;
216
+ handle: Type.TString;
217
+ provider: Type.TOptional<Type.TString>;
218
+ model: Type.TOptional<Type.TString>;
219
+ thinking: Type.TOptional<Type.TString>;
220
+ }>, Type.TObject<{
221
+ type: Type.TLiteral<"input">;
222
+ requestId: Type.TString;
223
+ handle: Type.TString;
224
+ message: Type.TString;
225
+ followUp: Type.TBoolean;
226
+ }>, Type.TObject<{
227
+ type: Type.TLiteral<"start">;
228
+ kind: Type.TString;
229
+ input: Type.TCyclic<{
230
+ Json: Type.TUnion<[Type.TNull, Type.TBoolean, Type.TNumber, Type.TString, Type.TArray<Type.TRef<"Json">>, Type.TRecord<"^.*$", Type.TRef<"Json">>]>;
231
+ }, "Json">;
232
+ key: Type.TString;
233
+ version: Type.TOptional<Type.TNumber>;
234
+ }>, Type.TObject<{
235
+ type: Type.TLiteral<"task">;
236
+ key: Type.TString;
237
+ }>, Type.TObject<{
238
+ type: Type.TLiteral<"wait-task">;
239
+ key: Type.TString;
240
+ }>, Type.TObject<{
241
+ type: Type.TLiteral<"abort-task">;
242
+ key: Type.TString;
243
+ }>]>;
244
+ export type RequestLine = Static<typeof RequestLine>;
155
245
  /** The value of the reply to each request type. */
156
246
  export type Replies = {
157
247
  readonly hello: {
@@ -211,12 +301,13 @@ export type Reply = {
211
301
  * modules, or `cli.ts` in a checkout, which Node runs with type stripping.
212
302
  */
213
303
  export declare function cliPath(): string;
214
- export declare function statePaths(project: string): {
304
+ export type StatePaths = {
215
305
  dir: string;
216
306
  socket: string;
217
307
  log: string;
218
308
  };
219
- export declare function isValidRunName(value: unknown): value is string;
309
+ export declare function statePaths(project: string): StatePaths;
310
+ export declare function isValidRunName(value: string): boolean;
220
311
  /** The label of a child: its name, or its handle when it has no name. */
221
312
  export declare function childLabel(child: {
222
313
  readonly handle: string;
package/dist/protocol.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { Type } from "typebox";
4
5
  /** The version of the protocol between the CLI and the host. A client refuses a host with another version. */
5
6
  export const PROTOCOL_VERSION = 7;
6
7
  /**
@@ -8,6 +9,77 @@ export const PROTOCOL_VERSION = 7;
8
9
  * hex, so no child has it.
9
10
  */
10
11
  export const AGENT_HANDLE = "agent";
12
+ /** Any JSON value. A request line is JSON, so `start` takes any input. */
13
+ export const Json = Type.Cyclic({
14
+ Json: Type.Union([
15
+ Type.Null(),
16
+ Type.Boolean(),
17
+ Type.Number(),
18
+ Type.String(),
19
+ Type.Array(Type.Ref("Json")),
20
+ Type.Record(Type.String(), Type.Ref("Json")),
21
+ ]),
22
+ }, "Json");
23
+ /**
24
+ * 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
+ * unknown thinking level with its own errors.
26
+ */
27
+ export const RequestLine = Type.Union([
28
+ Type.Object({ type: Type.Literal("hello"), version: Type.Number() }),
29
+ Type.Object({
30
+ type: Type.Literal("spawn"),
31
+ requestId: Type.String(),
32
+ caller: Type.String(),
33
+ name: Type.Optional(Type.String()),
34
+ provider: Type.String(),
35
+ model: Type.String(),
36
+ thinking: Type.Optional(Type.String()),
37
+ callerThinking: Type.String(),
38
+ cwd: Type.Optional(Type.String()),
39
+ tools: Type.Optional(Type.Immutable(Type.Array(Type.String()))),
40
+ prompt: Type.String(),
41
+ }),
42
+ Type.Object({
43
+ type: Type.Literal("send"),
44
+ requestId: Type.String(),
45
+ caller: Type.String(),
46
+ handle: Type.String(),
47
+ message: Type.String(),
48
+ followUp: Type.Boolean(),
49
+ }),
50
+ Type.Object({ type: Type.Literal("wait"), handle: Type.String(), timeout: Type.Number() }),
51
+ Type.Object({ type: Type.Literal("status"), handle: Type.String() }),
52
+ Type.Object({ type: Type.Literal("list"), caller: Type.String() }),
53
+ Type.Object({ type: Type.Literal("rename"), caller: Type.String(), handle: Type.String(), name: Type.String() }),
54
+ 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") }),
58
+ Type.Object({
59
+ type: Type.Literal("configure"),
60
+ handle: Type.String(),
61
+ provider: Type.Optional(Type.String()),
62
+ model: Type.Optional(Type.String()),
63
+ thinking: Type.Optional(Type.String()),
64
+ }),
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
+ }),
72
+ Type.Object({
73
+ type: Type.Literal("start"),
74
+ kind: Type.String(),
75
+ input: Json,
76
+ key: Type.String(),
77
+ version: Type.Optional(Type.Number()),
78
+ }),
79
+ Type.Object({ type: Type.Literal("task"), key: Type.String() }),
80
+ Type.Object({ type: Type.Literal("wait-task"), key: Type.String() }),
81
+ Type.Object({ type: Type.Literal("abort-task"), key: Type.String() }),
82
+ ]);
11
83
  /** The files of the host of `project`: `$XDG_STATE_HOME/harness/<project>/`, default `~/.local/state/harness/<project>/`. */
12
84
  /**
13
85
  * The CLI of this install, which the host start and the Herdr tabs run with this Node: `cli.js` next to the built
@@ -21,12 +93,12 @@ export function statePaths(project) {
21
93
  return { dir, socket: join(dir, "host.sock"), log: join(dir, "host.log") };
22
94
  }
23
95
  export function isValidRunName(value) {
24
- return (typeof value === "string" &&
25
- value.length > 0 &&
26
- value.length <= 64 &&
27
- value.trim() === value &&
28
- // biome-ignore lint/suspicious/noControlCharactersInRegex: a name must not contain control characters.
29
- !/[\u0000-\u001f\u007f]/.test(value));
96
+ return (value.length > 0 && value.length <= 64 && value.trim() === value && !Array.from(value).some(isControlCharacter));
97
+ }
98
+ /** U+0000 to U+001F and U+007F. */
99
+ function isControlCharacter(character) {
100
+ const code = character.charCodeAt(0);
101
+ return code <= 0x1f || code === 0x7f;
30
102
  }
31
103
  /** The label of a child: its name, or its handle when it has no name. */
32
104
  export function childLabel(child) {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Registers `<packageDir>/pi` in Pi when `packageDir` is a top-level global npm install. First it runs `pi remove` for
3
+ * each Pi package of the `retiredPiPackages` field of the package's `package.json` that Pi's settings list. It writes
4
+ * its messages to stderr, and it does not throw for a Pi failure: it writes a warning with the command to run.
5
+ * It writes the stamp that `setupOnce` reads unless a `pi` command failed, so the next run tries again. Without `pi`
6
+ * on PATH it writes the stamp, so a user without Pi gets the hint once per version.
7
+ */
8
+ export declare function setup(packageDir: string): void;
9
+ /**
10
+ * `setup` once per version and path of the package: it does nothing when the stamp of `setup` exists. It never
11
+ * throws; an error is a warning.
12
+ */
13
+ export declare function setupOnce(packageDir: string): void;