@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/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 = (
|
|
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((
|
|
91
|
+
const entries = await loadEntries(stateDir, agentDir, settings, place, warn).catch((cause) => {
|
|
88
92
|
lock.close();
|
|
89
|
-
throw
|
|
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((
|
|
100
|
+
const agent = () => (agentSetUp ??= setUpAgent(base.root, models, settings, project, cwd).catch((cause) => {
|
|
97
101
|
agentSetUp = undefined;
|
|
98
|
-
throw
|
|
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 =
|
|
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 (
|
|
211
|
+
}).catch(async (cause) => {
|
|
208
212
|
await entries.close();
|
|
209
213
|
await harness.close(context);
|
|
210
214
|
lock.close();
|
|
211
|
-
throw
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
401
|
-
if (answer.cause === "queued")
|
|
402
|
-
|
|
403
|
-
|
|
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 =
|
|
567
|
+
const text = Value.Check(Type.String(), detail)
|
|
483
568
|
? detail
|
|
484
|
-
:
|
|
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
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
31
|
-
if (!NAME.test(name) || name === MAIN_PROJECT ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/protocol.d.ts
CHANGED
|
@@ -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
|
|
304
|
+
export type StatePaths = {
|
|
215
305
|
dir: string;
|
|
216
306
|
socket: string;
|
|
217
307
|
log: string;
|
|
218
308
|
};
|
|
219
|
-
export declare function
|
|
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 (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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) {
|
package/dist/setup.d.ts
ADDED
|
@@ -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;
|