@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 +17 -13
- package/dist/answer.js +20 -15
- package/dist/chat.js +10 -10
- package/dist/children.d.ts +10 -6
- package/dist/children.js +69 -40
- package/dist/chrome.js +2 -1
- package/dist/cli.js +8 -2
- package/dist/client.d.ts +2 -2
- package/dist/client.js +23 -9
- package/dist/entries.js +5 -5
- package/dist/herdr.d.ts +11 -9
- package/dist/herdr.js +9 -4
- package/dist/host.js +137 -41
- package/dist/projects.js +10 -7
- package/dist/protocol.d.ts +94 -3
- package/dist/protocol.js +78 -6
- package/dist/setup.d.ts +13 -0
- package/dist/setup.js +133 -0
- package/dist/tasks.js +4 -8
- package/dist/theme.js +1 -0
- package/dist/tool.d.ts +1 -1
- package/dist/tool.js +9 -5
- package/dist/view-state.d.ts +5 -2
- package/dist/view-state.js +9 -4
- package/dist/viewer.js +2 -2
- package/package.json +13 -4
- package/pi/index.ts +168 -0
- package/pi/package.json +14 -0
- package/scripts/postinstall.js +13 -0
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
|
-
|
|
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
|
|
22
|
+
pi install "$(realpath "$(npm root -g)/@vzlentin/harness/pi")"
|
|
24
23
|
```
|
|
25
24
|
|
|
26
|
-
|
|
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
|
|
32
|
-
pi
|
|
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
|
|
63
|
-
|
|
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 =
|
|
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 =
|
|
138
|
-
? content
|
|
139
|
-
: content
|
|
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
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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,
|
|
152
|
-
const parameters = {
|
|
153
|
-
const check = (
|
|
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(
|
|
160
|
+
return check(answer);
|
|
156
161
|
}
|
|
157
162
|
catch (error) {
|
|
158
|
-
if (
|
|
163
|
+
if (!Value.Check(Type.String(), answer))
|
|
159
164
|
throw error;
|
|
160
165
|
let parsed;
|
|
161
166
|
try {
|
|
162
|
-
parsed = JSON.parse(
|
|
167
|
+
parsed = JSON.parse(answer);
|
|
163
168
|
}
|
|
164
169
|
catch {
|
|
165
170
|
throw error;
|
|
166
171
|
}
|
|
167
|
-
if (
|
|
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
|
|
8
|
-
bash
|
|
9
|
-
edit
|
|
10
|
-
write
|
|
11
|
-
grep
|
|
12
|
-
find
|
|
13
|
-
ls
|
|
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
|
|
177
|
+
const create = RENDERERS.get(toolName);
|
|
178
178
|
if (create === undefined)
|
|
179
179
|
return undefined;
|
|
180
180
|
definition = create(this.cwd);
|
package/dist/children.d.ts
CHANGED
|
@@ -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<
|
|
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?:
|
|
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
|
-
:
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
74
|
+
let result;
|
|
75
75
|
await place.store.commit(async (tx) => {
|
|
76
|
-
|
|
76
|
+
result = { value: await reader(tx) };
|
|
77
77
|
return undefined;
|
|
78
78
|
}, context);
|
|
79
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
227
|
+
const sum = { models: {}, tools: {} };
|
|
224
228
|
for (const member of await subtree(place, id, context)) {
|
|
225
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
/**
|
|
381
|
-
function
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
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((
|
|
243
|
-
process.stderr.write(`${
|
|
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 `
|
|
26
|
-
abort(
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 `
|
|
63
|
-
abort(
|
|
64
|
-
this.#end(
|
|
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(
|
|
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(
|
|
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 (
|
|
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
|
|
64
|
+
let output;
|
|
61
65
|
try {
|
|
62
|
-
|
|
66
|
+
output = JSON.parse(created);
|
|
63
67
|
}
|
|
64
68
|
catch {
|
|
65
|
-
|
|
69
|
+
output = undefined;
|
|
66
70
|
}
|
|
67
|
-
if (
|
|
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(" ");
|