@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/README.md CHANGED
@@ -13,25 +13,28 @@ with `/login` in `pi`, or set the API key of the provider, such as `ANTHROPIC_AP
13
13
  npm install -g @vzlentin/harness
14
14
  ```
15
15
 
16
- This gives you the `harness` command.
17
-
18
- ### Pi
19
-
20
- To also get the `subagent` tool, the `/subagent` command and the subagents widget in Pi:
16
+ This works with npm 11 and npm 12. It gives you the `harness` command, and a setup runs `pi install` on the `pi/`
17
+ folder of the package, which adds the `subagent` tool, the `/subagent` command and the subagents widget to Pi. Pi and
18
+ the `harness` command use the same host. npm 12 runs no install script by default, so there the first `harness` command
19
+ does the setup. When `pi` is not on your `PATH`, or the step fails, the setup prints the command to run later:
21
20
 
22
21
  ```sh
23
- pi install npm:@vzlentin/pi-subagent
22
+ pi install "$(realpath "$(npm root -g)/@vzlentin/harness/pi")"
24
23
  ```
25
24
 
26
- You can add it now or later. Pi and the `harness` command use the same host.
25
+ When you install with `--prefix`, give the same `--prefix` to `npm root -g`.
26
+
27
+ ### Move from 0.1.0
28
+
29
+ 0.1.0 had the extension in the separate Pi package `@vzlentin/pi-subagent`. Update as below: the setup removes that
30
+ package from Pi (`pi remove npm:@vzlentin/pi-subagent`), since it conflicts with the new extension.
27
31
 
28
32
  ### Update and remove
29
33
 
30
34
  ```sh
31
- npm update -g @vzlentin/harness
32
- pi update # when you installed @vzlentin/pi-subagent
35
+ npm install -g @vzlentin/harness@latest
36
+ pi remove "$(realpath "$(npm root -g)/@vzlentin/harness/pi")" # first: npm runs no script on uninstall
33
37
  npm uninstall -g @vzlentin/harness
34
- pi remove npm:@vzlentin/pi-subagent
35
38
  ```
36
39
 
37
40
  ## Use
@@ -52,12 +55,13 @@ harness main # talk to the agent of the main project
52
55
 
53
56
  ```sh
54
57
  npm ci
55
- npm run check # Biome, TypeScript, Vitest
58
+ npm run check # Biome, oxlint, TypeScript, Vitest
56
59
  XDG_STATE_HOME=/tmp/harness-dev node src/cli.ts list
57
60
  ```
58
61
 
59
62
  `node src/cli.ts` runs the CLI of this checkout. Give it its own `XDG_STATE_HOME`, so its hosts do not share sockets and
60
63
  stores with the hosts of the installed `harness`.
61
64
 
62
- The checkout runs from its TypeScript source. `npm publish` builds `dist/` first, and the package contains only
63
- `dist/`. The Pi plugin is the package in `pi-subagent/`, published from that directory with `npm publish`.
65
+ The checkout runs from its TypeScript source. `npm publish` builds `dist/` first, and the package contains `dist/`,
66
+ the Pi extension in `pi/` and its `postinstall` script. `npm run check` builds `dist/` too, before the type check. The extension imports `../dist/`, so to try the checkout's
67
+ extension in Pi, run `npm run build`, then `pi install ./pi`, and build again after each change in `src/`.
package/dist/answer.js CHANGED
@@ -4,6 +4,8 @@
4
4
  */
5
5
  import { copyJson } from "@earendil-works/chord";
6
6
  import { Type, validateToolArguments } from "@earendil-works/pi-ai";
7
+ import { Value } from "typebox/value";
8
+ import { Json } from "./protocol.js";
7
9
  /**
8
10
  * The user message that asks again for a `respond` call after an answer of a `schema` ask without one. A reminder is
9
11
  * known by its exact text, so a user prompt with the same text counts as one.
@@ -37,9 +39,12 @@ export function createAnswers(durable) {
37
39
  if (doc?.schema === undefined || requestId === undefined) {
38
40
  return { isError: true, output: [{ type: "text", text: "This turn asks for no structured answer." }] };
39
41
  }
42
+ if (!Value.Check(Json, args.answer)) {
43
+ return { isError: true, output: [{ type: "text", text: "The answer must be a JSON value." }] };
44
+ }
40
45
  let answer;
41
46
  try {
42
- answer = checkedAnswer(doc.schema, args, api.callId);
47
+ answer = checkedAnswer(doc.schema, args.answer, api.callId);
43
48
  }
44
49
  catch (error) {
45
50
  return {
@@ -103,7 +108,7 @@ export function createAnswers(durable) {
103
108
  if (!Array.isArray(tools)) {
104
109
  const remove = tools?.remove?.filter((tool) => tool !== name);
105
110
  const add = (tools?.add ?? []).filter((tool) => tool !== name);
106
- agent.modelTools = { ...(remove === undefined ? {} : { remove }), add: [...add, name] };
111
+ agent.modelTools = remove === undefined ? { add: [...add, name] } : { remove, add: [...add, name] };
107
112
  }
108
113
  else if (!tools.includes(name))
109
114
  tools.push(name);
@@ -134,9 +139,9 @@ function remindersAtEnd(messages) {
134
139
  if (message.role !== "user")
135
140
  continue;
136
141
  const { content } = message;
137
- const text = typeof content === "string"
138
- ? content
139
- : content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
142
+ const text = Array.isArray(content)
143
+ ? content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")
144
+ : content;
140
145
  if (text !== REMINDER)
141
146
  break;
142
147
  count++;
@@ -144,27 +149,27 @@ function remindersAtEnd(messages) {
144
149
  return count;
145
150
  }
146
151
  /**
147
- * The answer of `args` checked against `schema`, as `validateToolArguments` returns it. A string answer that does not
148
- * match is parsed as JSON, and an object or array is checked again, because a model can send it as its JSON text. Any
149
- * other string throws the first error.
152
+ * `answer` checked against `schema`, as `validateToolArguments` returns it. A string answer that does not match is
153
+ * parsed as JSON, and an object or array is checked again, because a model can send it as its JSON text. Any other
154
+ * string throws the first error.
150
155
  */
151
- function checkedAnswer(schema, args, id) {
152
- const parameters = { type: "object", properties: { answer: schema }, required: ["answer"] };
153
- const check = (answer) => validateToolArguments({ name: "respond", description: "", parameters }, { type: "toolCall", id, name: "respond", arguments: { answer } }).answer;
156
+ function checkedAnswer(schema, answer, id) {
157
+ const parameters = Type.Object({ answer: Type.Unsafe(schema) });
158
+ const check = (value) => validateToolArguments({ name: "respond", description: "", parameters }, { type: "toolCall", id, name: "respond", arguments: { answer: value } }).answer;
154
159
  try {
155
- return check(args.answer);
160
+ return check(answer);
156
161
  }
157
162
  catch (error) {
158
- if (typeof args.answer !== "string")
163
+ if (!Value.Check(Type.String(), answer))
159
164
  throw error;
160
165
  let parsed;
161
166
  try {
162
- parsed = JSON.parse(args.answer);
167
+ parsed = JSON.parse(answer);
163
168
  }
164
169
  catch {
165
170
  throw error;
166
171
  }
167
- if (typeof parsed !== "object" || parsed === null)
172
+ if (!(parsed instanceof Object))
168
173
  throw error;
169
174
  return check(parsed);
170
175
  }
package/dist/chat.js CHANGED
@@ -3,15 +3,15 @@ import { Container, Spacer, Text } from "@earendil-works/pi-tui";
3
3
  import { color } from "./theme.js";
4
4
  import { liveOf, userText } from "./view-state.js";
5
5
  /** Pi's renderers of the tools that children have under the same names. */
6
- const RENDERERS = {
7
- read: createReadToolDefinition,
8
- bash: createBashToolDefinition,
9
- edit: createEditToolDefinition,
10
- write: createWriteToolDefinition,
11
- grep: createGrepToolDefinition,
12
- find: createFindToolDefinition,
13
- ls: createLsToolDefinition,
14
- };
6
+ const RENDERERS = new Map([
7
+ ["read", createReadToolDefinition],
8
+ ["bash", createBashToolDefinition],
9
+ ["edit", createEditToolDefinition],
10
+ ["write", createWriteToolDefinition],
11
+ ["grep", createGrepToolDefinition],
12
+ ["find", createFindToolDefinition],
13
+ ["ls", createLsToolDefinition],
14
+ ]);
15
15
  const DIAGNOSTICS = /^<harness>\n([\s\S]*)\n<\/harness>$/;
16
16
  /**
17
17
  * A tool result as Pi shows its own. pi-durable adds the diagnostics of a call for the model as a `<harness>` block, such
@@ -174,7 +174,7 @@ export class ChatView {
174
174
  renderer(toolName) {
175
175
  let definition = this.renderers.get(toolName);
176
176
  if (definition === undefined) {
177
- const create = RENDERERS[toolName];
177
+ const create = RENDERERS.get(toolName);
178
178
  if (create === undefined)
179
179
  return undefined;
180
180
  definition = create(this.cwd);
@@ -15,7 +15,7 @@ export declare const DEFAULT_DEPTH_LIMIT = 2;
15
15
  * callbacks return nothing, as a task's do, and read back through `tx` or `snapshot`.
16
16
  */
17
17
  export type Store = {
18
- commit(change: (tx: Tx) => Promise<undefined>, context: Context): Promise<unknown>;
18
+ commit(change: (tx: Tx) => Promise<undefined>, context: Context): Promise<void>;
19
19
  readonly snapshot: DocumentReader["snapshot"];
20
20
  conversation(id: ConversationId, context: Context): Promise<Conversation | undefined>;
21
21
  submission(id: SubmissionId, context: Context): Promise<Submission | undefined>;
@@ -101,7 +101,7 @@ export type Failure = {
101
101
  readonly status: "failed";
102
102
  readonly reason: "unanswered";
103
103
  readonly cause: string;
104
- readonly detail?: unknown;
104
+ readonly detail?: JsonValue | undefined;
105
105
  };
106
106
  /** Input to a child, at most once per request ID. */
107
107
  export type Input = Parameters<Conversation["submit"]>[0] & {
@@ -144,6 +144,8 @@ export type Child = {
144
144
  * `context` stops only the wait; the child runs until `close()`.
145
145
  */
146
146
  settled(context: Context, request?: string): Promise<Answer>;
147
+ /** The newest request ID admitted, which `settled` waits for by default, or null before the first one. */
148
+ lastRequest(context: Context): Promise<string | null>;
147
149
  state(context: Context): Promise<ChildState>;
148
150
  /** The spend of the child and everything under it, for reports. */
149
151
  usage(context: Context): Promise<UsageState>;
@@ -195,6 +197,11 @@ export type Turn = {
195
197
  state(context: Context): Promise<Exclude<ChildState, "closed">>;
196
198
  };
197
199
  export declare function createChildren(durable: typeof Durable): Children;
200
+ /** The model and the thinking level of a child. */
201
+ export type ResolvedModel = {
202
+ model: ModelRef;
203
+ thinkingLevel: ModelThinkingLevel;
204
+ };
198
205
  /**
199
206
  * The model and thinking level of `request`; a field left out comes from `fallback`. An explicit `thinking` that the
200
207
  * model does not have is refused. Without it, the level is `fallbackThinking`, else the fallback's, clamped to the
@@ -205,7 +212,4 @@ export declare function resolveModel(models: Models, fallback: Pick<Agent, "mode
205
212
  readonly model?: string | undefined;
206
213
  readonly thinking?: string | undefined;
207
214
  readonly fallbackThinking?: string | undefined;
208
- }): {
209
- model: ModelRef;
210
- thinkingLevel: ModelThinkingLevel;
211
- };
215
+ }): ResolvedModel;
package/dist/children.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { withCancel } from "@earendil-works/chord/context";
2
2
  import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai/models";
3
+ import { MODEL_THINKING_LEVELS } from "@earendil-works/pi-ai/providers/model-schema";
3
4
  import { complete, createAnswers } from "./answer.js";
4
5
  /** The deepest child by default: a child of a child of a root. */
5
6
  export const DEFAULT_DEPTH_LIMIT = 2;
@@ -47,19 +48,18 @@ export function createChildren(durable) {
47
48
  const extension = durable.defineExtension({ name: "pi-subagent-children", tasks: [Anchor] });
48
49
  const ownerState = (owner) => owner.kind === "detached"
49
50
  ? { kind: "detached" }
50
- : { kind: "task", taskId: owner.taskId, ...(owner.group === undefined ? {} : { group: owner.group }) };
51
+ : owner.group === undefined
52
+ ? { kind: "task", taskId: owner.taskId }
53
+ : { kind: "task", taskId: owner.taskId, group: owner.group };
51
54
  const sameOwner = (a, b) => {
52
55
  if (a.kind === "detached" || b.kind === "detached")
53
56
  return a.kind === b.kind;
54
57
  return a.group !== undefined || b.group !== undefined ? a.group === b.group : a.taskId === b.taskId;
55
58
  };
56
- const lineageOf = (doc) => ({
57
- key: doc.key,
58
- root: doc.root,
59
- ...(doc.parent === null ? {} : { parent: doc.parent }),
60
- depth: doc.depth,
61
- limit: doc.limit,
62
- });
59
+ const lineageOf = (doc) => {
60
+ const found = { key: doc.key, root: doc.root, depth: doc.depth, limit: doc.limit };
61
+ return doc.parent === null ? found : { ...found, parent: doc.parent };
62
+ };
63
63
  async function childDoc(store, id, context) {
64
64
  const doc = await store.snapshot(ChildDoc, id, context);
65
65
  return doc === undefined || doc.key === "" ? undefined : doc;
@@ -71,18 +71,21 @@ export function createChildren(durable) {
71
71
  }
72
72
  /** Reads through a commit, which returns nothing on a task's runtime. */
73
73
  async function read(place, reader, context) {
74
- let value;
74
+ let result;
75
75
  await place.store.commit(async (tx) => {
76
- value = await reader(tx);
76
+ result = { value: await reader(tx) };
77
77
  return undefined;
78
78
  }, context);
79
- return value;
79
+ if (result === undefined)
80
+ throw new Error("The commit did not run its change");
81
+ return result.value;
80
82
  }
81
83
  /** `id` and every child under it, parents first. */
82
84
  async function subtree(place, id, context) {
83
85
  const ids = [id];
84
- for (let i = 0; i < ids.length; i++) {
85
- const doc = await childDoc(place.store, ids[i], context);
86
+ // The loop also visits the IDs that it appends.
87
+ for (const member of ids) {
88
+ const doc = await childDoc(place.store, member, context);
86
89
  for (const child of doc?.children ?? [])
87
90
  ids.push(child);
88
91
  }
@@ -214,15 +217,18 @@ export function createChildren(durable) {
214
217
  send: turns.send,
215
218
  ask,
216
219
  settled,
220
+ lastRequest: async (context) => (await lastRequest(context)) ?? null,
217
221
  async state(context) {
218
222
  if ((await place.store.snapshot(ChildDoc, id, context))?.closed === true)
219
223
  return "closed";
220
224
  return turns.state(context);
221
225
  },
222
226
  async usage(context) {
223
- let sum = { models: {}, tools: {} };
227
+ const sum = { models: {}, tools: {} };
224
228
  for (const member of await subtree(place, id, context)) {
225
- sum = addNumbers(sum, await place.store.snapshot(durable.UsageDoc, member, context));
229
+ const usage = await place.store.snapshot(durable.UsageDoc, member, context);
230
+ if (usage !== undefined)
231
+ addUsageState(sum, usage);
226
232
  }
227
233
  return sum;
228
234
  },
@@ -291,13 +297,14 @@ export function createChildren(durable) {
291
297
  if (known.conversation !== 0)
292
298
  return undefined;
293
299
  const child = await tx.createConversation({ ownership: { kind: "task", taskId: ownerTask } });
294
- await durable.configure(tx, child.id, {
295
- model,
296
- thinkingLevel,
297
- ...(cwd === undefined ? {} : { cwd }),
298
- ...(tools === undefined ? {} : { tools }),
299
- ...(spec.instructions === undefined ? {} : { instructions: spec.instructions }),
300
- });
300
+ let change = { model, thinkingLevel };
301
+ if (cwd !== undefined)
302
+ change = { ...change, cwd };
303
+ if (tools !== undefined)
304
+ change = { ...change, tools };
305
+ if (spec.instructions !== undefined)
306
+ change = { ...change, instructions: spec.instructions };
307
+ await durable.configure(tx, child.id, change);
301
308
  // The child keeps the extensions of the owner's agent and selects the answer extension too.
302
309
  const agent = await tx.doc(durable.AgentDoc, child.id);
303
310
  const answerName = answers.extension.name;
@@ -362,29 +369,51 @@ export function resolveModel(models, fallback, request) {
362
369
  throw new Error(`Unknown model: ${provider}/${modelId}`);
363
370
  const levels = getSupportedThinkingLevels(model);
364
371
  if (request.thinking !== undefined) {
365
- if (!levels.includes(request.thinking)) {
372
+ const thinkingLevel = levels.find((level) => level === request.thinking);
373
+ if (thinkingLevel === undefined)
366
374
  throw new Error(`${provider}/${modelId} has no thinking level ${request.thinking}`);
367
- }
368
- return { model: { provider, modelId }, thinkingLevel: request.thinking };
375
+ return { model: { provider, modelId }, thinkingLevel };
369
376
  }
377
+ const inherited = request.fallbackThinking ?? fallback?.thinkingLevel ?? "off";
378
+ const known = MODEL_THINKING_LEVELS.find((level) => level === inherited);
370
379
  // `clampThinkingLevel` gives the model's first level for a name that it does not know.
371
- const inherited = (request.fallbackThinking ?? fallback?.thinkingLevel ?? "off");
372
- return { model: { provider, modelId }, thinkingLevel: clampThinkingLevel(model, inherited) };
380
+ return {
381
+ model: { provider, modelId },
382
+ thinkingLevel: known === undefined ? (levels[0] ?? "off") : clampThinkingLevel(model, known),
383
+ };
373
384
  }
374
385
  /** The conversation ID that `value` spells, as callers write it; nothing for another caller, such as a Pi session. */
375
386
  function conversationIdOf(value) {
376
- return /^[1-9][0-9]*$/.test(value) && Number.isSafeInteger(Number(value))
377
- ? Number(value)
378
- : undefined;
387
+ if (!/^[1-9][0-9]*$/.test(value) || !Number.isSafeInteger(Number(value)))
388
+ return undefined;
389
+ // SAFETY: a conversation ID is a positive safe integer, and the callers look up the conversation of this one.
390
+ return Number(value);
379
391
  }
380
- /** `a` with every number in `b` added at the same path; `b` undefined adds nothing. */
381
- function addNumbers(a, b) {
382
- if (typeof b === "number")
383
- return (typeof a === "number" ? a : 0) + b;
384
- if (b === null || typeof b !== "object")
385
- return a ?? b;
386
- const sum = { ...(a !== null && typeof a === "object" ? a : {}) };
387
- for (const [key, value] of Object.entries(b))
388
- sum[key] = addNumbers(sum[key], value);
389
- return sum;
392
+ /** Adds every counter of `state` to `sum`. A counter that one side leaves out counts as 0. */
393
+ function addUsageState(sum, state) {
394
+ for (const bucket of ["models", "tools"]) {
395
+ const totals = sum[bucket];
396
+ for (const [key, usage] of Object.entries(state[bucket])) {
397
+ const total = Object.hasOwn(totals, key) ? totals[key] : undefined;
398
+ if (total === undefined) {
399
+ // Define rather than assign: assigning a tool named `__proto__` would set the prototype.
400
+ Object.defineProperty(totals, key, { value: structuredClone(usage), enumerable: true, writable: true });
401
+ continue;
402
+ }
403
+ total.input += usage.input;
404
+ total.output += usage.output;
405
+ total.cacheRead += usage.cacheRead;
406
+ total.cacheWrite += usage.cacheWrite;
407
+ total.totalTokens += usage.totalTokens;
408
+ if (usage.cacheWrite1h !== undefined)
409
+ total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
410
+ if (usage.reasoning !== undefined)
411
+ total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
412
+ total.cost.input += usage.cost.input;
413
+ total.cost.output += usage.cost.output;
414
+ total.cost.cacheRead += usage.cost.cacheRead;
415
+ total.cost.cacheWrite += usage.cost.cacheWrite;
416
+ total.cost.total += usage.cost.total;
417
+ }
418
+ }
390
419
  }
package/dist/chrome.js CHANGED
@@ -3,6 +3,7 @@ import { calculateContextTokens } from "@earendil-works/pi-coding-agent";
3
3
  import { UsageDoc } from "@earendil-works/pi-durable";
4
4
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
5
  import { bold, color } from "./theme.js";
6
+ import { docOf } from "./view-state.js";
6
7
  /** One line, cut to the width, with `hints` at the right end: the first hints go when they do not all fit. */
7
8
  export class Line {
8
9
  text = "";
@@ -33,7 +34,7 @@ export function headerText(child, title) {
33
34
  * `contextWindow` is known and the child has answered once.
34
35
  */
35
36
  export function footerText(child, view, contextWindow) {
36
- const usage = totalUsage(view.docs[UsageDoc.definition.kind]);
37
+ const usage = totalUsage(docOf(view, UsageDoc));
37
38
  const used = lastUsage(view.entries);
38
39
  const context = contextWindow === undefined || contextWindow === 0 || used === undefined
39
40
  ? []
package/dist/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { randomUUID } from "node:crypto";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { addProject, checkProject, MAIN_PROJECT, projectDir, readProjects } from "./projects.js";
4
5
  import { AGENT_HANDLE, statePaths } from "./protocol.js";
6
+ import { setupOnce } from "./setup.js";
5
7
  import { runTool, sendToHost } from "./tool.js";
6
8
  function fail(message) {
7
9
  throw new Error(message);
@@ -209,6 +211,10 @@ async function main() {
209
211
  all.splice(0, 2);
210
212
  }
211
213
  const [command, ...args] = all;
214
+ // npm 12 runs no install script by default, so an installed CLI finishes the setup. A checkout runs `cli.ts`, and
215
+ // the host starts from a command that already did it.
216
+ if (import.meta.url.endsWith(".js") && command !== "host")
217
+ setupOnce(fileURLToPath(new URL("..", import.meta.url)));
212
218
  if (command === "create")
213
219
  return createCommand(args);
214
220
  if (command === "projects")
@@ -239,7 +245,7 @@ async function main() {
239
245
  });
240
246
  process.stdout.write(text);
241
247
  }
242
- main().catch((error) => {
243
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
248
+ main().catch((cause) => {
249
+ process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`);
244
250
  process.exitCode = 1;
245
251
  });
package/dist/client.d.ts CHANGED
@@ -22,8 +22,8 @@ export declare class Client {
22
22
  lines: AsyncIterable<WatchLine>;
23
23
  }>;
24
24
  close(): void;
25
- /** Rejects the pending requests with `reason` and closes the connection. The host then stops them. */
26
- abort(reason: unknown): void;
25
+ /** Rejects the pending requests with `cause` and closes the connection. The host then stops them. */
26
+ abort(cause: unknown): void;
27
27
  }
28
28
  /**
29
29
  * A connection to the host of the project of `HARNESS_PROJECT` or the cwd (see `resolveProject`), after its `hello`.
package/dist/client.js CHANGED
@@ -2,9 +2,17 @@ import { spawn } from "node:child_process";
2
2
  import { closeSync, mkdirSync, openSync } from "node:fs";
3
3
  import { createConnection } from "node:net";
4
4
  import { Readable } from "node:stream";
5
+ import { Type } from "typebox";
6
+ import { Value } from "typebox/value";
5
7
  import { MAIN_PROJECT, resolveProject } from "./projects.js";
6
8
  import { cliPath, onLines, PROTOCOL_VERSION, statePaths, } from "./protocol.js";
7
9
  const START_TIMEOUT_MS = 10_000;
10
+ /** A line from the host: a reply, or an event of the watch. The value of a reply and the event are not checked here. */
11
+ const HostLine = Type.Union([
12
+ Type.Object({ ok: Type.Literal(true), value: Type.Optional(Type.Unknown()) }),
13
+ Type.Object({ ok: Type.Literal(false), error: Type.String() }),
14
+ Type.Object({ event: Type.Unknown() }),
15
+ ]);
8
16
  /**
9
17
  * A connection to the host. Requests are answered in order, one reply line for each request line. After `watch`, the
10
18
  * event lines of the watch come between the replies.
@@ -23,6 +31,10 @@ export class Client {
23
31
  this.#socket = socket;
24
32
  onLines(socket, (line) => {
25
33
  const parsed = JSON.parse(line);
34
+ if (!Value.Check(HostLine, parsed)) {
35
+ this.#pending.shift()?.reject(new Error(`The harness host sent an invalid reply: ${line}`));
36
+ return;
37
+ }
26
38
  if ("event" in parsed) {
27
39
  this.#lines?.push(parsed.event);
28
40
  return;
@@ -41,10 +53,12 @@ export class Client {
41
53
  request(request) {
42
54
  if (this.#ended)
43
55
  return Promise.reject(new Error("The harness host closed the connection"));
44
- return new Promise((resolve, reject) => {
45
- this.#pending.push({ resolve: resolve, reject });
46
- this.#socket.write(`${JSON.stringify(request)}\n`);
47
- });
56
+ const reply = Promise.withResolvers();
57
+ this.#pending.push(reply);
58
+ this.#socket.write(`${JSON.stringify(request)}\n`);
59
+ // SAFETY: the host answers each request line in order with the value of `Replies` for its type, and `connect`
60
+ // refuses a host of another protocol version.
61
+ return reply.promise;
48
62
  }
49
63
  /**
50
64
  * Sends the `watch` request of `handle`. Its `lines` are the events of the watch, and end when the connection closes.
@@ -59,17 +73,17 @@ export class Client {
59
73
  close() {
60
74
  this.#socket.end();
61
75
  }
62
- /** Rejects the pending requests with `reason` and closes the connection. The host then stops them. */
63
- abort(reason) {
64
- this.#end(reason);
76
+ /** Rejects the pending requests with `cause` and closes the connection. The host then stops them. */
77
+ abort(cause) {
78
+ this.#end(cause);
65
79
  this.#socket.destroy();
66
80
  }
67
- #end(reason = new Error("The harness host closed the connection")) {
81
+ #end(cause = new Error("The harness host closed the connection")) {
68
82
  if (!this.#ended)
69
83
  this.#lines?.push(null);
70
84
  this.#ended = true;
71
85
  for (const waiter of this.#pending.splice(0))
72
- waiter.reject(reason);
86
+ waiter.reject(cause);
73
87
  }
74
88
  }
75
89
  /**
package/dist/entries.js CHANGED
@@ -5,6 +5,9 @@
5
5
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, isAbsolute, join } from "node:path";
8
+ import { Type } from "typebox";
9
+ import { Value } from "typebox/value";
10
+ const Entries = Type.Record(Type.String(), Type.String());
8
11
  /** The file of the entries: `$XDG_CONFIG_HOME/harness/entries.json`, default `~/.config/harness/entries.json`. */
9
12
  export function entriesPath() {
10
13
  return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "harness", "entries.json");
@@ -17,15 +20,12 @@ export function readEntries() {
17
20
  text = readFileSync(path, "utf8");
18
21
  }
19
22
  catch (error) {
20
- if (error.code === "ENOENT")
23
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
21
24
  return {};
22
25
  throw error;
23
26
  }
24
27
  const entries = JSON.parse(text);
25
- if (typeof entries !== "object" ||
26
- entries === null ||
27
- Array.isArray(entries) ||
28
- !Object.values(entries).every((module) => typeof module === "string" && isAbsolute(module))) {
28
+ if (!Value.Check(Entries, entries) || !Object.values(entries).every((module) => isAbsolute(module))) {
29
29
  throw new Error(`${path} is not an object of entry names to absolute module paths`);
30
30
  }
31
31
  return entries;
package/dist/herdr.d.ts CHANGED
@@ -1,14 +1,19 @@
1
1
  import { type ChildInfo } from "./protocol.ts";
2
+ export type ViewerTarget = {
3
+ title(child: ChildInfo): string;
4
+ project: string[];
5
+ resume: string[];
6
+ };
7
+ export type AgentReporter = {
8
+ report(busy: boolean): void;
9
+ release(): Promise<void>;
10
+ };
2
11
  /**
3
12
  * How the viewer of `handle` on `project` shows and resumes it: the title of its child, the `--project` words, and the
4
13
  * words after them that open it again. The project agent is `harness <project>`, with no `--project`; a child is
5
14
  * `harness --project <project> attach <handle>`.
6
15
  */
7
- export declare function viewerTarget(handle: string, project: string): {
8
- title(child: ChildInfo): string;
9
- project: string[];
10
- resume: string[];
11
- };
16
+ export declare function viewerTarget(handle: string, project: string): ViewerTarget;
12
17
  /**
13
18
  * The words of the command that runs the viewer of `handle`, with this Node and this package's CLI, so it does not
14
19
  * need the CLI on PATH. Herdr wants a plain command name as the first word of a resume command, so it starts with
@@ -30,7 +35,4 @@ export declare function openViewerTab(child: ChildInfo, env: NodeJS.ProcessEnv):
30
35
  * the resume command. Both methods do nothing outside Herdr.
31
36
  * The calls run one after another, so `--seq` reaches Herdr in order.
32
37
  */
33
- export declare function agentReporter(env: NodeJS.ProcessEnv, label: string, handle: string, project: string): {
34
- report(busy: boolean): void;
35
- release(): Promise<void>;
36
- };
38
+ export declare function agentReporter(env: NodeJS.ProcessEnv, label: string, handle: string, project: string): AgentReporter;
package/dist/herdr.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { Type } from "typebox";
3
+ import { Value } from "typebox/value";
2
4
  import { resolveProject } from "./projects.js";
3
5
  import { AGENT_HANDLE, childLabel, cliPath, runDisplayName } from "./protocol.js";
4
6
  const HERDR_SOURCE = "custom:harness";
7
+ /** The part of the output of `herdr tab create` that has the pane of the tab. */
8
+ const TabCreated = Type.Object({ result: Type.Object({ root_pane: Type.Object({ pane_id: Type.String() }) }) });
5
9
  /**
6
10
  * How the viewer of `handle` on `project` shows and resumes it: the title of its child, the `--project` words, and the
7
11
  * words after them that open it again. The project agent is `harness <project>`, with no `--project`; a child is
@@ -57,15 +61,16 @@ export async function openViewerTab(child, env) {
57
61
  ...(state ? ["--env", `XDG_STATE_HOME=${state}`] : []),
58
62
  ...(config ? ["--env", `XDG_CONFIG_HOME=${config}`] : []),
59
63
  ], env);
60
- let pane;
64
+ let output;
61
65
  try {
62
- pane = JSON.parse(created).result?.root_pane?.pane_id;
66
+ output = JSON.parse(created);
63
67
  }
64
68
  catch {
65
- pane = undefined;
69
+ output = undefined;
66
70
  }
67
- if (typeof pane !== "string")
71
+ if (!Value.Check(TabCreated, output))
68
72
  throw new Error(`herdr tab create gave no pane ID: ${created.trim()}`);
73
+ const pane = output.result.root_pane.pane_id;
69
74
  const viewer = viewerCommand(child.handle, project)
70
75
  .map((word) => `'${word.replaceAll("'", "'\\''")}'`)
71
76
  .join(" ");