@fitchmultz/pi-subagents 0.50.0 → 0.51.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/CHANGELOG.md +51 -2
- package/README.md +36 -5
- package/dist/agent/contracts.js +6 -24
- package/dist/agent/host.js +3 -16
- package/dist/agent/index.js +207 -173
- package/dist/agent/journal-tail.js +115 -156
- package/dist/agent/launch.js +20 -8
- package/dist/agent/legacy.js +65 -23
- package/dist/agent/notifications.js +138 -47
- package/dist/agent/observation-state.js +246 -0
- package/dist/agent/observation-view.js +258 -0
- package/dist/agent/observation.js +346 -0
- package/dist/agent/observe-card.js +243 -0
- package/dist/agent/observe-chrome.js +85 -0
- package/dist/agent/observe-client.js +285 -0
- package/dist/agent/observe-layout.js +65 -0
- package/dist/agent/observe-reading.js +84 -0
- package/dist/agent/observe-screen.js +400 -96
- package/dist/agent/observe-transcript.js +382 -0
- package/dist/agent/observe.js +250 -43
- package/dist/agent/process.js +198 -25
- package/dist/agent/receipts.js +92 -20
- package/dist/agent/runner.js +125 -20
- package/dist/agent/shell.js +2 -1
- package/dist/agent/storage.js +29 -12
- package/dist/agent/tool-schemas.js +20 -0
- package/dist/agent/typebox.js +10 -5
- package/dist/agent/usage.js +0 -115
- package/dist/agent/view.js +46 -16
- package/dist/agent/wake.js +99 -0
- package/dist/build-info.js +1 -1
- package/dist/intercom-client.d.ts +26 -0
- package/dist/intercom-client.js +45 -0
- package/dist/pi-intercom/agent-dir.d.ts +1 -0
- package/dist/pi-intercom/bridge-session.js +2 -1
- package/dist/pi-intercom/broker/broker.js +27 -21
- package/dist/pi-intercom/broker/client-last.d.ts +13 -0
- package/dist/pi-intercom/broker/client-last.js +17 -5
- package/dist/pi-intercom/broker/client.d.ts +60 -0
- package/dist/pi-intercom/broker/client.js +47 -16
- package/dist/pi-intercom/broker/connection.d.ts +29 -0
- package/dist/pi-intercom/broker/framing.d.ts +17 -0
- package/dist/pi-intercom/broker/framing.js +10 -7
- package/dist/pi-intercom/broker/guard.js +23 -0
- package/dist/pi-intercom/broker/last.d.ts +26 -0
- package/dist/pi-intercom/broker/last.js +57 -32
- package/dist/pi-intercom/broker/paths.d.ts +13 -0
- package/dist/pi-intercom/broker/paths.js +35 -12
- package/dist/pi-intercom/broker/pid.js +5 -1
- package/dist/pi-intercom/broker/server-connection.js +19 -0
- package/dist/pi-intercom/broker/snapshot-reader.d.ts +9 -0
- package/dist/pi-intercom/broker/snapshots.d.ts +29 -0
- package/dist/pi-intercom/broker/targets.d.ts +9 -0
- package/dist/pi-intercom/connection.js +10 -2
- package/dist/pi-intercom/identity.js +6 -5
- package/dist/pi-intercom/inbound-delivery.js +16 -60
- package/dist/pi-intercom/inbound-journal.js +83 -6
- package/dist/pi-intercom/inbound-record.js +18 -1
- package/dist/pi-intercom/inbound.js +12 -38
- package/dist/pi-intercom/index.js +3 -0
- package/dist/pi-intercom/last-message.js +4 -16
- package/dist/pi-intercom/lifecycle.js +88 -3
- package/dist/pi-intercom/outbound.js +5 -15
- package/dist/pi-intercom/reply-tracker.js +7 -6
- package/dist/pi-intercom/reply-wait.js +2 -2
- package/dist/pi-intercom/session-targets.d.ts +24 -0
- package/dist/pi-intercom/tool-registration.js +4 -1
- package/dist/pi-intercom/tool-results.js +5 -7
- package/dist/pi-intercom/tool-schemas.js +12 -26
- package/dist/pi-intercom/types.d.ts +212 -0
- package/dist/pi-intercom/types.js +54 -3
- package/dist/pi-intercom/validation.d.ts +12 -0
- package/dist/pi-intercom/validation.js +3 -0
- package/dist/shared/bounded-json.js +151 -0
- package/dist/shared/compact-cards.js +66 -0
- package/dist/shared/journal-frames.js +3 -6
- package/dist/shared/json-projection.js +267 -129
- package/dist/shared/native-journal.js +123 -56
- package/dist/shared/post-exit-stdio-guard.js +120 -7
- package/dist/shared/tui-action.js +29 -0
- package/dist/shared/unknown.d.ts +9 -0
- package/docs/code-quality.md +2 -0
- package/docs/development.md +40 -1
- package/docs/intercom-bridge.md +9 -4
- package/docs/intercom.md +226 -6
- package/docs/performance.md +87 -11
- package/docs/reference.md +157 -20
- package/docs/retired-history-maintenance.md +252 -0
- package/package.json +28 -15
- package/scripts/archive-retired-history.mjs +464 -0
- package/scripts/build.mjs +48 -28
- package/scripts/measure-size.mjs +1 -1
- package/scripts/native-package-smoke.mjs +153 -12
- package/scripts/package-smoke.mjs +80 -11
- package/scripts/prepare.mjs +2 -2
- package/scripts/real-pi-live.mjs +377 -0
- package/scripts/real-pi-processes.mjs +6 -1
- package/scripts/real-pi-smoke.mjs +6 -282
- package/scripts/run-tests.mjs +4 -2
- package/scripts/typescript.mjs +35 -0
- package/skills/pi-intercom/SKILL.md +2 -1
- package/skills/pi-subagents/SKILL.md +23 -5
- package/src/agent/contracts.ts +8 -34
- package/src/agent/host.ts +19 -20
- package/src/agent/index.ts +224 -190
- package/src/agent/journal-tail.ts +141 -153
- package/src/agent/launch.ts +33 -9
- package/src/agent/legacy.ts +85 -31
- package/src/agent/notifications.ts +192 -67
- package/src/agent/observation-state.ts +277 -0
- package/src/agent/observation-view.ts +267 -0
- package/src/agent/observation.ts +405 -0
- package/src/agent/observe-card.ts +304 -0
- package/src/agent/observe-chrome.ts +170 -0
- package/src/agent/observe-client.ts +318 -0
- package/src/agent/observe-layout.ts +79 -0
- package/src/agent/observe-reading.ts +97 -0
- package/src/agent/observe-screen.ts +443 -129
- package/src/agent/observe-transcript.ts +434 -0
- package/src/agent/observe.ts +304 -71
- package/src/agent/process.ts +240 -34
- package/src/agent/receipts.ts +114 -24
- package/src/agent/runner.ts +125 -27
- package/src/agent/shell.ts +2 -1
- package/src/agent/storage.ts +27 -11
- package/src/agent/tool-schemas.ts +33 -0
- package/src/agent/typebox.ts +12 -7
- package/src/agent/usage.ts +0 -130
- package/src/agent/view.ts +70 -20
- package/src/agent/wake.ts +107 -0
- package/src/intercom-client.ts +108 -0
- package/src/pi-intercom/bridge-session.ts +2 -2
- package/src/pi-intercom/broker/broker.ts +27 -23
- package/src/pi-intercom/broker/client-last.ts +25 -12
- package/src/pi-intercom/broker/client.ts +55 -33
- package/src/pi-intercom/broker/framing.ts +11 -8
- package/src/pi-intercom/broker/guard.ts +33 -0
- package/src/pi-intercom/broker/last.ts +86 -33
- package/src/pi-intercom/broker/paths.ts +39 -12
- package/src/pi-intercom/broker/pid.ts +6 -1
- package/src/pi-intercom/broker/server-connection.ts +25 -0
- package/src/pi-intercom/connection.ts +21 -10
- package/src/pi-intercom/identity.ts +8 -5
- package/src/pi-intercom/inbound-delivery.ts +20 -66
- package/src/pi-intercom/inbound-journal.ts +103 -7
- package/src/pi-intercom/inbound-record.ts +21 -4
- package/src/pi-intercom/inbound.ts +18 -49
- package/src/pi-intercom/index.ts +3 -0
- package/src/pi-intercom/last-message.ts +10 -19
- package/src/pi-intercom/lifecycle.ts +117 -14
- package/src/pi-intercom/outbound.ts +7 -16
- package/src/pi-intercom/reply-tracker.ts +8 -6
- package/src/pi-intercom/reply-wait.ts +3 -2
- package/src/pi-intercom/runtime-types.ts +0 -1
- package/src/pi-intercom/tool-registration.ts +4 -1
- package/src/pi-intercom/tool-results.ts +10 -9
- package/src/pi-intercom/tool-schemas.ts +12 -44
- package/src/pi-intercom/types.ts +115 -4
- package/src/pi-intercom/validation.ts +4 -0
- package/src/shared/bounded-json.ts +162 -0
- package/src/shared/compact-cards.ts +118 -0
- package/src/shared/journal-frames.ts +3 -19
- package/src/shared/json-projection.ts +267 -147
- package/src/shared/native-journal.ts +157 -75
- package/src/shared/post-exit-stdio-guard.ts +165 -7
- package/src/shared/tui-action.ts +39 -0
- package/tsconfig.client.json +8 -0
- package/tsconfig.json +1 -0
- package/src/shared/stream-json.d.ts +0 -10
package/src/agent/runner.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
4
3
|
import type {
|
|
5
4
|
AgentSession,
|
|
6
5
|
CreateAgentSessionRuntimeFactory,
|
|
@@ -8,19 +7,27 @@ import type {
|
|
|
8
7
|
ExtensionContext,
|
|
9
8
|
} from "@earendil-works/pi-coding-agent";
|
|
10
9
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
type Command,
|
|
15
|
-
type Launch,
|
|
16
|
-
type Result,
|
|
17
|
-
} from "./contracts.ts";
|
|
18
|
-
import { launchAt, read, runDirectory, write } from "./storage.ts";
|
|
19
|
-
import { alive, ownIdentity, shellCommand } from "./process.ts";
|
|
10
|
+
import { commandSchema, type Command, type Launch, type Result } from "./contracts.ts";
|
|
11
|
+
import { launchAt, missing, read, runDirectory, write } from "./storage.ts";
|
|
12
|
+
import { ownIdentity, ParentCheck, shellCommand, shells, signalProcess } from "./process.ts";
|
|
20
13
|
import { errorText, loadSDK } from "./host.ts";
|
|
21
14
|
import { childUsage } from "./usage.ts";
|
|
22
15
|
import { initializeCwd, localCwdOwner } from "./cwd.ts";
|
|
16
|
+
import { ActorObservation, type ObservationStats } from "./observation.ts";
|
|
17
|
+
import type { ToolEvent } from "./observation-state.ts";
|
|
18
|
+
import { DirectoryWatch, Wake } from "./wake.ts";
|
|
23
19
|
|
|
20
|
+
function commandIdentity(file: string): string | undefined {
|
|
21
|
+
try {
|
|
22
|
+
const stat = fs.statSync(file, { bigint: true });
|
|
23
|
+
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (missing(error)) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
24
31
|
function completionStatus(
|
|
25
32
|
stopping: boolean,
|
|
26
33
|
message: AssistantMessage | undefined,
|
|
@@ -56,10 +63,20 @@ function guard(pi: ExtensionAPI, directory: string, token: string): void {
|
|
|
56
63
|
class Actor {
|
|
57
64
|
private active?: Promise<void>;
|
|
58
65
|
private stopping = false;
|
|
66
|
+
private stopDeadline?: NodeJS.Timeout;
|
|
67
|
+
private nextParentCheck = 0;
|
|
68
|
+
private readonly parent = new ParentCheck();
|
|
69
|
+
// Raised when durable commands may need admission: change hints and stop.
|
|
70
|
+
private readonly wake = new Wake();
|
|
71
|
+
// A deferred run waits for an idle session, which has no command-file event.
|
|
72
|
+
private retry = false;
|
|
59
73
|
private current?: Launch;
|
|
60
74
|
private readonly errors: string[] = [];
|
|
61
75
|
private readonly started = new Set<string>();
|
|
76
|
+
// Actor-local validation metadata only; never retain command prompts or receipt authority.
|
|
77
|
+
private readonly validated = new Map<string, { identity: string; id: string }>();
|
|
62
78
|
private session?: AgentSession;
|
|
79
|
+
private observation?: ActorObservation;
|
|
63
80
|
private context?: { pi: ExtensionAPI; ctx: ExtensionContext };
|
|
64
81
|
private readonly base: string;
|
|
65
82
|
private readonly initial: Launch;
|
|
@@ -164,6 +181,11 @@ class Actor {
|
|
|
164
181
|
pi.on("session_start", (_event, ctx) => {
|
|
165
182
|
this.context = { pi, ctx };
|
|
166
183
|
});
|
|
184
|
+
// Sparse tool lifecycle keeps the live view's tools even while nobody watches.
|
|
185
|
+
const tool = (event: ToolEvent): void => this.observation?.tool(event);
|
|
186
|
+
pi.on("tool_execution_start", tool);
|
|
187
|
+
pi.on("tool_execution_update", tool);
|
|
188
|
+
pi.on("tool_execution_end", tool);
|
|
167
189
|
},
|
|
168
190
|
],
|
|
169
191
|
},
|
|
@@ -191,12 +213,37 @@ class Actor {
|
|
|
191
213
|
};
|
|
192
214
|
}
|
|
193
215
|
private readonly stop = (): void => {
|
|
194
|
-
|
|
216
|
+
if (this.stopping) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
195
219
|
this.stopping = true;
|
|
220
|
+
this.wake.raise();
|
|
221
|
+
write(path.join(this.dir, `stopping-${this.token}.json`), { at: Date.now() });
|
|
222
|
+
write(path.join(this.dir, "stop-acknowledged.json"), { at: Date.now(), id: this.current?.id });
|
|
196
223
|
this.session?.abort().catch((error: unknown) => {
|
|
197
224
|
this.errors.push(errorText(error));
|
|
198
225
|
});
|
|
226
|
+
this.stopShells("SIGTERM");
|
|
227
|
+
// Parent death leaves no parent-side escalation timer to stop an uncooperative tool.
|
|
228
|
+
this.stopDeadline = setTimeout(() => {
|
|
229
|
+
this.stopShells("SIGKILL");
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}, 3000);
|
|
232
|
+
this.stopDeadline.unref();
|
|
199
233
|
};
|
|
234
|
+
private stopShells(signal: NodeJS.Signals): void {
|
|
235
|
+
try {
|
|
236
|
+
for (const identity of shells(this.base, this.initial, this.token)) {
|
|
237
|
+
try {
|
|
238
|
+
signalProcess(identity, signal);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
this.errors.push(errorText(error));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
} catch (error) {
|
|
244
|
+
this.errors.push(errorText(error));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
200
247
|
private run(launch: Launch): void {
|
|
201
248
|
const session = this.session;
|
|
202
249
|
if (!session) {
|
|
@@ -204,6 +251,7 @@ class Actor {
|
|
|
204
251
|
}
|
|
205
252
|
this.current = launch;
|
|
206
253
|
const baseline = new Set(session.sessionManager.getEntries().map((entry) => entry.id));
|
|
254
|
+
this.observation?.working(true);
|
|
207
255
|
this.active = (async () => {
|
|
208
256
|
try {
|
|
209
257
|
if (
|
|
@@ -221,6 +269,7 @@ class Actor {
|
|
|
221
269
|
} finally {
|
|
222
270
|
this.active = undefined;
|
|
223
271
|
this.current = undefined;
|
|
272
|
+
this.observation?.working(false);
|
|
224
273
|
}
|
|
225
274
|
})();
|
|
226
275
|
}
|
|
@@ -256,6 +305,7 @@ class Actor {
|
|
|
256
305
|
command.action === "run" &&
|
|
257
306
|
(this.stopping || this.active !== undefined || !session.isIdle)
|
|
258
307
|
) {
|
|
308
|
+
this.retry = true;
|
|
259
309
|
return;
|
|
260
310
|
}
|
|
261
311
|
this.started.add(command.id);
|
|
@@ -306,6 +356,24 @@ class Actor {
|
|
|
306
356
|
timestamp: Date.now(),
|
|
307
357
|
});
|
|
308
358
|
}
|
|
359
|
+
private pendingCommand(file: string): Command | undefined {
|
|
360
|
+
const identity = commandIdentity(file);
|
|
361
|
+
const cached = this.validated.get(file);
|
|
362
|
+
if (
|
|
363
|
+
identity !== undefined &&
|
|
364
|
+
cached?.identity === identity &&
|
|
365
|
+
fs.existsSync(path.join(this.dir, "admissions", `${cached.id}.json`))
|
|
366
|
+
) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
this.validated.delete(file);
|
|
370
|
+
const command = read(file, commandSchema);
|
|
371
|
+
// A replacement during validation must not inherit the old body's validated identity.
|
|
372
|
+
if (command && identity !== undefined && commandIdentity(file) === identity) {
|
|
373
|
+
this.validated.set(file, { identity, id: command.id });
|
|
374
|
+
}
|
|
375
|
+
return command;
|
|
376
|
+
}
|
|
309
377
|
private pending(): Command[] {
|
|
310
378
|
const dir = path.join(this.dir, "commands");
|
|
311
379
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -313,23 +381,46 @@ class Actor {
|
|
|
313
381
|
.readdirSync(dir)
|
|
314
382
|
.filter((file) => file.endsWith(".json"))
|
|
315
383
|
.flatMap((file) => {
|
|
316
|
-
const command =
|
|
384
|
+
const command = this.pendingCommand(path.join(dir, file));
|
|
317
385
|
return command ? [command] : [];
|
|
318
386
|
})
|
|
319
387
|
.sort((a, b) => a.createdAt - b.createdAt);
|
|
320
388
|
}
|
|
321
389
|
private shouldExit(): boolean {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
390
|
+
const now = performance.now();
|
|
391
|
+
if (!this.stopping && now >= this.nextParentCheck) {
|
|
392
|
+
this.nextParentCheck = now + 1000;
|
|
393
|
+
// One observation at a time; a slow one keeps the regular schedule.
|
|
394
|
+
const file = path.join(this.base, "attachment.json");
|
|
395
|
+
this.parent.check(file, this.stop).catch((error: unknown) => {
|
|
396
|
+
this.errors.push(errorText(error));
|
|
397
|
+
});
|
|
327
398
|
}
|
|
328
|
-
|
|
329
|
-
|
|
399
|
+
return this.stopping && this.active === undefined && (this.session?.isIdle ?? true);
|
|
400
|
+
}
|
|
401
|
+
/** Durable command readiness, not elapsed time, defines admission. */
|
|
402
|
+
private async admitCommands(): Promise<void> {
|
|
403
|
+
// Queued commands create this directory; otherwise pending() does and the next pass arms.
|
|
404
|
+
const directory = path.join(this.dir, "commands");
|
|
405
|
+
const commands = new DirectoryWatch(directory, () => true, this.wake.raise);
|
|
406
|
+
try {
|
|
407
|
+
while (!this.shouldExit()) {
|
|
408
|
+
// Each liveness tick also rechecks the directory for change hints the OS dropped.
|
|
409
|
+
const watch = commands.ensure();
|
|
410
|
+
if ((this.wake.take() || this.retry || watch !== "watching") && !this.stopping) {
|
|
411
|
+
this.retry = false;
|
|
412
|
+
this.pending().forEach((command) => this.accept(command));
|
|
413
|
+
}
|
|
414
|
+
const polling = this.retry || this.stopping || watch === "unwatched";
|
|
415
|
+
const tick = Math.max(1, this.nextParentCheck - performance.now());
|
|
416
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
417
|
+
await this.wake.sleep(polling ? 100 : tick);
|
|
418
|
+
}
|
|
419
|
+
} finally {
|
|
420
|
+
// A verdict settling after the loop ends must not act.
|
|
421
|
+
this.parent.cancel();
|
|
422
|
+
commands.close();
|
|
330
423
|
}
|
|
331
|
-
const attachment = read(path.join(this.base, "attachment.json"), attachmentSchema);
|
|
332
|
-
return attachment === undefined || !attachment.attached || !alive(attachment);
|
|
333
424
|
}
|
|
334
425
|
private failure(error: unknown): void {
|
|
335
426
|
const launch = this.current ?? this.initial;
|
|
@@ -374,6 +465,12 @@ class Actor {
|
|
|
374
465
|
if (this.initial.name !== undefined) {
|
|
375
466
|
this.session.sessionManager.appendSessionInfo(this.initial.name);
|
|
376
467
|
}
|
|
468
|
+
// Optional live view, bound before readiness; its faults never change native execution.
|
|
469
|
+
this.observation = await ActorObservation.open(this.session, {
|
|
470
|
+
token: this.token,
|
|
471
|
+
rootId: this.initial.rootId,
|
|
472
|
+
sessionId: this.session.sessionManager.getSessionId(),
|
|
473
|
+
});
|
|
377
474
|
write(path.join(this.dir, "actor.lock", "process.json"), {
|
|
378
475
|
...ownIdentity(),
|
|
379
476
|
token: this.token,
|
|
@@ -386,22 +483,23 @@ class Actor {
|
|
|
386
483
|
});
|
|
387
484
|
process.on("SIGTERM", this.stop);
|
|
388
485
|
process.on("SIGINT", this.stop);
|
|
389
|
-
|
|
390
|
-
this.pending().forEach((command) => this.accept(command));
|
|
391
|
-
// Durable command readiness, not elapsed time, defines admission.
|
|
392
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
393
|
-
await delay(100);
|
|
394
|
-
} while (!this.shouldExit());
|
|
486
|
+
await this.admitCommands();
|
|
395
487
|
await this.active;
|
|
396
488
|
} catch (error) {
|
|
397
489
|
this.failure(error);
|
|
398
490
|
} finally {
|
|
399
491
|
process.off("SIGTERM", this.stop);
|
|
400
492
|
process.off("SIGINT", this.stop);
|
|
493
|
+
const observation: ObservationStats | undefined = this.observation?.close();
|
|
401
494
|
await runtime?.dispose();
|
|
495
|
+
if (this.stopping) {
|
|
496
|
+
this.stopShells("SIGKILL");
|
|
497
|
+
}
|
|
498
|
+
clearTimeout(this.stopDeadline);
|
|
402
499
|
write(path.join(this.dir, `shutdown-${this.token}.json`), {
|
|
403
500
|
at: Date.now(),
|
|
404
501
|
errors: this.errors,
|
|
502
|
+
...(observation === undefined ? {} : { observation }),
|
|
405
503
|
});
|
|
406
504
|
// Keep the process claim until a launcher proves this exact actor is gone.
|
|
407
505
|
}
|
package/src/agent/shell.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
|
-
import { readChildProcessIdentity } from "../shared/post-exit-stdio-guard.ts";
|
|
5
|
+
import { readChildProcessIdentity, readProcessClock } from "../shared/post-exit-stdio-guard.ts";
|
|
6
6
|
import { read, write } from "./storage.ts";
|
|
7
7
|
import { processSchema } from "./contracts.ts";
|
|
8
8
|
import { verifyProcess } from "./process.ts";
|
|
@@ -50,6 +50,7 @@ write(
|
|
|
50
50
|
pid,
|
|
51
51
|
identity,
|
|
52
52
|
token,
|
|
53
|
+
clock: readProcessClock(),
|
|
53
54
|
},
|
|
54
55
|
true,
|
|
55
56
|
);
|
package/src/agent/storage.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Projection } from "../shared/json-projection.ts";
|
|
|
6
6
|
import { Assert } from "./typebox.ts";
|
|
7
7
|
import type { Static, TSchema } from "typebox";
|
|
8
8
|
import { launchSchema, successorSchema, type Launch, type Command } from "./contracts.ts";
|
|
9
|
+
import { hasErrorCode } from "../shared/unknown.ts";
|
|
9
10
|
|
|
10
11
|
export function missing(error: unknown): boolean {
|
|
11
12
|
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
@@ -25,28 +26,43 @@ export function read<T extends TSchema>(
|
|
|
25
26
|
throw error;
|
|
26
27
|
}
|
|
27
28
|
Assert(schema, value);
|
|
28
|
-
// TypeBox's generic Static<T> is intentionally the asserted schema's type.
|
|
29
|
-
// oxlint-disable-next-line typescript/no-unsafe-return
|
|
30
29
|
return value;
|
|
31
30
|
}
|
|
31
|
+
/** Publish complete bytes without replacing a winner, even where Android denies hard links. */
|
|
32
|
+
function publishExclusive(temp: string, file: string): boolean {
|
|
33
|
+
try {
|
|
34
|
+
fs.linkSync(temp, file);
|
|
35
|
+
return false;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (!["EACCES", "EPERM", "ENOTSUP"].some((code) => hasErrorCode(error, code))) {
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
// The immutable, private backing file must outlive its atomic no-clobber symlink.
|
|
41
|
+
fs.symlinkSync(path.basename(temp), file);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
32
45
|
export function write(file: string, value: unknown, exclusive = false): void {
|
|
33
46
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
34
|
-
const temp = `${file}.${randomUUID()}
|
|
47
|
+
const temp = `${file}.${randomUUID()}.${exclusive ? "data" : "tmp"}`;
|
|
35
48
|
const fd = fs.openSync(temp, "wx", 0o600);
|
|
49
|
+
let retained = false;
|
|
36
50
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
try {
|
|
52
|
+
fs.writeFileSync(fd, `${JSON.stringify(value)}\n`);
|
|
53
|
+
fs.fsyncSync(fd);
|
|
54
|
+
} finally {
|
|
55
|
+
fs.closeSync(fd);
|
|
56
|
+
}
|
|
43
57
|
if (exclusive) {
|
|
44
|
-
|
|
58
|
+
retained = publishExclusive(temp, file);
|
|
45
59
|
} else {
|
|
46
60
|
fs.renameSync(temp, file);
|
|
47
61
|
}
|
|
48
62
|
} finally {
|
|
49
|
-
|
|
63
|
+
if (!retained) {
|
|
64
|
+
fs.rmSync(temp, { force: true });
|
|
65
|
+
}
|
|
50
66
|
}
|
|
51
67
|
}
|
|
52
68
|
export function safeId(id: string): string {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Static } from "typebox";
|
|
2
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
3
|
+
import { Type } from "./typebox.ts";
|
|
4
|
+
|
|
5
|
+
// Main-session tool parameters only; the detached runner must not load pi-ai values.
|
|
6
|
+
export const delegateSchema = Type.Object(
|
|
7
|
+
{
|
|
8
|
+
prompt: Type.String({ minLength: 1 }),
|
|
9
|
+
name: Type.Optional(Type.String({ minLength: 1 })),
|
|
10
|
+
cwd: Type.Optional(Type.String({ minLength: 1 })),
|
|
11
|
+
model: Type.Optional(
|
|
12
|
+
Type.String({
|
|
13
|
+
minLength: 1,
|
|
14
|
+
description:
|
|
15
|
+
"Exact provider/model ID (:thinking and any case need host support); no fuzzy match or fallback.",
|
|
16
|
+
}),
|
|
17
|
+
),
|
|
18
|
+
context: Type.Optional(StringEnum(["fresh", "fork"] as const)),
|
|
19
|
+
},
|
|
20
|
+
{ additionalProperties: false },
|
|
21
|
+
);
|
|
22
|
+
export type DelegateInput = Static<typeof delegateSchema>;
|
|
23
|
+
export const runsSchema = Type.Object(
|
|
24
|
+
{
|
|
25
|
+
action: StringEnum(["list", "inspect", "continue", "stop"] as const),
|
|
26
|
+
id: Type.Optional(Type.String()),
|
|
27
|
+
prompt: Type.Optional(Type.String({ minLength: 1 })),
|
|
28
|
+
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
29
|
+
length: Type.Optional(Type.Integer({ minimum: 4, maximum: 262144 })),
|
|
30
|
+
},
|
|
31
|
+
{ additionalProperties: false },
|
|
32
|
+
);
|
|
33
|
+
export type RunsInput = Static<typeof runsSchema>;
|
package/src/agent/typebox.ts
CHANGED
|
@@ -3,15 +3,20 @@ import * as path from "node:path";
|
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import type * as NativeTypebox from "typebox";
|
|
5
5
|
import type * as NativeValue from "typebox/value";
|
|
6
|
-
import { packageRoot } from "./host.ts";
|
|
6
|
+
import { isChild, packageRoot } from "./host.ts";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
//
|
|
8
|
+
// Pi aliases these imports to its own instance (including bundled virtual modules).
|
|
9
|
+
// Only the detached runner needs absolute imports from the selected host package.
|
|
10
|
+
const require = isChild() ? createRequire(path.join(packageRoot(), "dist/index.js")) : undefined;
|
|
10
11
|
// oxlint-disable-next-line typescript/no-unsafe-assignment
|
|
11
|
-
const typebox: typeof NativeTypebox =
|
|
12
|
+
const typebox: typeof NativeTypebox =
|
|
13
|
+
require === undefined
|
|
14
|
+
? await import("typebox")
|
|
15
|
+
: await import(pathToFileURL(require.resolve("typebox")).href);
|
|
12
16
|
// oxlint-disable-next-line typescript/no-unsafe-assignment
|
|
13
|
-
const value: typeof NativeValue =
|
|
14
|
-
|
|
15
|
-
)
|
|
17
|
+
const value: typeof NativeValue =
|
|
18
|
+
require === undefined
|
|
19
|
+
? await import("typebox/value")
|
|
20
|
+
: await import(pathToFileURL(require.resolve("typebox/value")).href);
|
|
16
21
|
export const { Type } = typebox;
|
|
17
22
|
export const Assert: typeof NativeValue.Assert = value.Assert;
|
package/src/agent/usage.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { Usage } from "@earendil-works/pi-ai";
|
|
3
1
|
import * as path from "node:path";
|
|
4
2
|
import * as fs from "node:fs";
|
|
5
3
|
import { journalStamp } from "../shared/native-journal.ts";
|
|
6
|
-
import { isDeepStrictEqual } from "node:util";
|
|
7
4
|
import { nativeProjection } from "../shared/journal-projections.ts";
|
|
8
5
|
import { scanJournal } from "../shared/journal-frames.ts";
|
|
9
|
-
import type { Projection } from "../shared/json-projection.ts";
|
|
10
6
|
import { isRecord } from "../shared/unknown.ts";
|
|
11
7
|
import { Assert } from "./typebox.ts";
|
|
12
8
|
import {
|
|
@@ -18,132 +14,6 @@ import {
|
|
|
18
14
|
} from "./contracts.ts";
|
|
19
15
|
import { read, runDirectory, write } from "./storage.ts";
|
|
20
16
|
|
|
21
|
-
export function sumUsage(values: readonly Contribution[]): Usage {
|
|
22
|
-
const total: Usage = {
|
|
23
|
-
input: 0,
|
|
24
|
-
output: 0,
|
|
25
|
-
cacheRead: 0,
|
|
26
|
-
cacheWrite: 0,
|
|
27
|
-
totalTokens: 0,
|
|
28
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
29
|
-
};
|
|
30
|
-
for (const { usage } of values) {
|
|
31
|
-
for (const key of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const) {
|
|
32
|
-
total[key] += usage[key];
|
|
33
|
-
}
|
|
34
|
-
for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) {
|
|
35
|
-
total.cost[key] += usage.cost[key];
|
|
36
|
-
}
|
|
37
|
-
for (const key of ["reasoning", "cacheWrite1h"] as const) {
|
|
38
|
-
if (usage[key] !== undefined) {
|
|
39
|
-
total[key] = (total[key] ?? 0) + usage[key];
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
return total;
|
|
44
|
-
}
|
|
45
|
-
function intents(details: unknown): Contribution[] | undefined {
|
|
46
|
-
if (!isRecord(details) || details.agentUsage === undefined) {
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
const values: unknown = details.agentUsage;
|
|
50
|
-
Assert(contributionsSchema, values);
|
|
51
|
-
return values;
|
|
52
|
-
}
|
|
53
|
-
function publishedContributions(entry: unknown): Contribution[] {
|
|
54
|
-
if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message)) {
|
|
55
|
-
return [];
|
|
56
|
-
}
|
|
57
|
-
const message = entry.message;
|
|
58
|
-
if (
|
|
59
|
-
message.role !== "toolResult" ||
|
|
60
|
-
(message.toolName !== "delegate" && message.toolName !== "agent_runs")
|
|
61
|
-
) {
|
|
62
|
-
return [];
|
|
63
|
-
}
|
|
64
|
-
const values = intents(message.details);
|
|
65
|
-
if (values === undefined || !isDeepStrictEqual(sumUsage(values), message.usage)) {
|
|
66
|
-
return [];
|
|
67
|
-
}
|
|
68
|
-
return values;
|
|
69
|
-
}
|
|
70
|
-
const receiptProjection: Projection = (keys) => {
|
|
71
|
-
if (keys.length === 0 || keys[0] === "type") {
|
|
72
|
-
return true;
|
|
73
|
-
}
|
|
74
|
-
if (keys[0] !== "message") {
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
if (keys.length === 1 || ["role", "toolName", "usage"].includes(String(keys[1]))) {
|
|
78
|
-
return true;
|
|
79
|
-
}
|
|
80
|
-
return keys[1] === "details" && (keys.length === 2 || keys[2] === "agentUsage");
|
|
81
|
-
};
|
|
82
|
-
function priorUsage(ctx: ExtensionContext): Map<string, Contribution> {
|
|
83
|
-
const values = new Map<string, Contribution>();
|
|
84
|
-
const file = ctx.sessionManager.getSessionFile();
|
|
85
|
-
if (file !== undefined) {
|
|
86
|
-
scanJournal(
|
|
87
|
-
file,
|
|
88
|
-
receiptProjection,
|
|
89
|
-
({ value: entry }) => {
|
|
90
|
-
for (const value of publishedContributions(entry)) {
|
|
91
|
-
values.set(value.id, value);
|
|
92
|
-
}
|
|
93
|
-
},
|
|
94
|
-
{ policy: "live", requireNewline: true },
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
for (const entry of ctx.sessionManager.getEntries()) {
|
|
98
|
-
for (const value of publishedContributions(entry)) {
|
|
99
|
-
values.set(value.id, value);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return values;
|
|
103
|
-
}
|
|
104
|
-
function uncharged(
|
|
105
|
-
values: readonly Contribution[],
|
|
106
|
-
prior: Map<string, Contribution>,
|
|
107
|
-
): Contribution[] {
|
|
108
|
-
const pending: Contribution[] = [];
|
|
109
|
-
for (const value of values) {
|
|
110
|
-
const earlier = prior.get(value.id);
|
|
111
|
-
if (earlier && !isDeepStrictEqual(earlier, value)) {
|
|
112
|
-
throw new Error(`Conflicting native usage contribution ${value.id}`);
|
|
113
|
-
}
|
|
114
|
-
if (!earlier) {
|
|
115
|
-
pending.push(value);
|
|
116
|
-
prior.set(value.id, value);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return pending;
|
|
120
|
-
}
|
|
121
|
-
export function registerUsage(pi: ExtensionAPI): void {
|
|
122
|
-
pi.on("message_end", (event, ctx) => {
|
|
123
|
-
const message = event.message;
|
|
124
|
-
if (
|
|
125
|
-
message.role !== "toolResult" ||
|
|
126
|
-
(message.toolName !== "delegate" && message.toolName !== "agent_runs")
|
|
127
|
-
) {
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
const values = intents(message.details);
|
|
131
|
-
if (values === undefined || !isRecord(message.details)) {
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
const pending = uncharged(values, priorUsage(ctx));
|
|
135
|
-
const { usage: _usage, ...rest } = message;
|
|
136
|
-
// Finalized native tool messages are serialized even for concurrent tools.
|
|
137
|
-
// Preparing or discarding a result cannot reserve a charge.
|
|
138
|
-
return {
|
|
139
|
-
message: {
|
|
140
|
-
...rest,
|
|
141
|
-
details: { ...message.details, agentUsage: pending },
|
|
142
|
-
...(pending.length > 0 ? { usage: sumUsage(pending) } : {}),
|
|
143
|
-
},
|
|
144
|
-
};
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
17
|
function billing(entry: Record<string, unknown>) {
|
|
148
18
|
if (entry.checkpoint === true) {
|
|
149
19
|
return;
|