@fitchmultz/pi-subagents 0.52.2 → 0.52.3
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 +14 -0
- package/dist/agent/child-runtime.js +4 -4
- package/dist/agent/host.js +13 -0
- package/dist/agent/index.js +4 -1
- package/dist/agent/observe-card.js +3 -3
- package/dist/agent/observe.js +30 -26
- package/dist/agent/parking.js +15 -1
- package/dist/agent/runner.js +4 -2
- package/dist/agent/usage.js +92 -47
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/bridge-mailbox.js +18 -7
- package/dist/pi-intercom/bridge-wake.js +7 -9
- package/dist/pi-intercom/inbound-journal.js +4 -3
- package/dist/pi-intercom/inbound-record.js +12 -0
- package/dist/pi-intercom/ui/session-format.js +15 -6
- package/docs/performance.md +104 -7
- package/package.json +1 -1
- package/scripts/native-package-smoke.mjs +12 -3
- package/src/agent/child-runtime.ts +4 -6
- package/src/agent/host.ts +15 -0
- package/src/agent/index.ts +4 -1
- package/src/agent/observe-card.ts +3 -3
- package/src/agent/observe.ts +39 -37
- package/src/agent/parking.ts +14 -2
- package/src/agent/runner.ts +4 -2
- package/src/agent/usage.ts +116 -60
- package/src/pi-intercom/bridge-mailbox.ts +17 -7
- package/src/pi-intercom/bridge-wake.ts +7 -8
- package/src/pi-intercom/inbound-journal.ts +9 -3
- package/src/pi-intercom/inbound-record.ts +12 -0
- package/src/pi-intercom/ui/session-format.ts +16 -7
package/docs/performance.md
CHANGED
|
@@ -70,13 +70,18 @@ The final version loads that module only when a human opens `/agents`, then
|
|
|
70
70
|
revalidates the current session owner. These small samples overlap the baseline
|
|
71
71
|
ranges and do not establish a generalized speedup or latency guarantee.
|
|
72
72
|
|
|
73
|
-
Interactive mains
|
|
74
|
-
per
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
73
|
+
Interactive mains process at most 50 unfinished or newly discovered metadata rows
|
|
74
|
+
per scan. Immutable launches and finished rows are cached; finished history does
|
|
75
|
+
not consume that budget. Liveness is checked once per active conversation per
|
|
76
|
+
scan, not for completed idle actors. While work is live or rows are pending, a
|
|
77
|
+
one-second interval schedules scans; relevant filesystem and native events can
|
|
78
|
+
also trigger them.
|
|
79
|
+
|
|
80
|
+
The open viewer indexes its native journal incrementally, reading at most 256 KiB
|
|
81
|
+
per indexing refresh. Ordinary scheduled display updates are spaced by 250 ms.
|
|
82
|
+
The viewer mounts up to 24 history cards and hydrates text in 32,768-code-point
|
|
83
|
+
pages. These are display mechanics, not truncation of the native journal. Children
|
|
84
|
+
and print/RPC sessions do not install the observer.
|
|
80
85
|
|
|
81
86
|
### TypeBox host-instance startup (#234)
|
|
82
87
|
|
|
@@ -186,6 +191,98 @@ an end-to-end model, TUI or fleet CPU speedup claim. The native `park-shells` te
|
|
|
186
191
|
separately exercises repeated checks, witness death, missing and replaced
|
|
187
192
|
publications, and continuation on official Pi and the maintained fork.
|
|
188
193
|
|
|
194
|
+
### Resident child usage accounting
|
|
195
|
+
|
|
196
|
+
A child records each run's usage from its native journal. A resident child now
|
|
197
|
+
keeps the IDs and billing fields of the records it validated and parses only
|
|
198
|
+
records appended after its previous LF boundary. A cold actor, a restored parked
|
|
199
|
+
child and the parent's pending-accounting repair still parse from byte 0 once.
|
|
200
|
+
|
|
201
|
+
On macOS arm64 with Node 24.22.1, a read-only replay over one real 152.5 MB child
|
|
202
|
+
journal with 11 saved runs compared release 0.52.2 (`1a088c6`) with this change,
|
|
203
|
+
using the same inherited baseline and terminal for each run. Both returned
|
|
204
|
+
identical contributions (1,129 in total), matching every saved result. The fresh
|
|
205
|
+
reader read 1,068,938,072 bytes across the 11 runs; one resident index read
|
|
206
|
+
152,966,603 bytes. The replay assumes one actor stayed resident for every run; a
|
|
207
|
+
restart or restore between runs pays one full read. No latency change is claimed.
|
|
208
|
+
|
|
209
|
+
The native `usage` test runs a second continuation in the resident actor on
|
|
210
|
+
official Pi and the maintained fork. It read 773 bytes of a 41,605-byte official
|
|
211
|
+
journal and 773 bytes of a 42,439-byte fork journal. Release 0.52.2 read the whole
|
|
212
|
+
journal from byte 0 in the same test on both hosts.
|
|
213
|
+
|
|
214
|
+
### Host SDK reuse on first delegation
|
|
215
|
+
|
|
216
|
+
In a production-only install, Pi's loader gives the extension the running host's
|
|
217
|
+
SDK. The extension now keeps that module when its factory runs. The SDK adapter
|
|
218
|
+
returns it for the selected Pi package when the module's `getPackageDir()` resolves
|
|
219
|
+
to that package and `PI_PACKAGE_DIR` is unset. The first delegation, `/agents` and
|
|
220
|
+
parked-history restore therefore no longer import a second, unbundled copy of the
|
|
221
|
+
same SDK from disk. Before, each parent process paid that load once, and later
|
|
222
|
+
calls reused the second copy. The adapter still imports the selected package's
|
|
223
|
+
`dist/index.js` everywhere else. That covers a set `PI_PACKAGE_DIR`, which replaces
|
|
224
|
+
every copy's `getPackageDir()` answer, and a package-local SDK copy, such as a full
|
|
225
|
+
development install or one removed by a later refresh. Detached children also keep
|
|
226
|
+
the import.
|
|
227
|
+
|
|
228
|
+
On macOS arm64 with Node v24.22.1, five alternating before/after pairs per host
|
|
229
|
+
and scenario ran the real bundled print-mode CLI. The package was installed through
|
|
230
|
+
`settings.packages` in a private HOME and agent directory. Each run used offline
|
|
231
|
+
mode, disabled telemetry, `PI_TIMING=1` and a local faux provider, with no network
|
|
232
|
+
or paid model calls. The hosts were the maintained fork at `581b5c18` (bundled CLI
|
|
233
|
+
SHA-256 `f3960fd2e951ac0a47725af534f55b28122553aa56d143ad546dace094fc1d77`) and
|
|
234
|
+
official Pi 1.4.0 (`e79626f2dd6f94aa45d30f3fa63cd84319a6eefcd150b353cfaf274366926774`).
|
|
235
|
+
Before was the packed 0.52.2 tree at `1a088c6`; after was the same tree plus this
|
|
236
|
+
change. The machine was busy, with load averages of 17–39 at the start and end of
|
|
237
|
+
each set, so ranges are wide.
|
|
238
|
+
|
|
239
|
+
| Metric (median; observed range) | Fork before | Fork after | Official before | Official after |
|
|
240
|
+
| ----------------------------------- | ------------------- | ------------------- | ------------------- | ------------------- |
|
|
241
|
+
| First `delegate` call, ms | 1410 (672–1682) | 115 (93–176) | 1874 (796–6190) | 104 (48–2339) |
|
|
242
|
+
| Parent RSS growth in that call, MiB | 66.3 (62.6–67.5) | 0.4 (0.4–0.5) | 84.5 (83.5–85.5) | 0.4 (0.4–0.5) |
|
|
243
|
+
| `/agents` viewer module load, ms | 361 (345–439) | 35 (27–37) | 523 (510–583) | 28 (25–30) |
|
|
244
|
+
| RSS growth in that load, MiB | 65.5 (65.2–66.0) | 1.9 (1.5–2.3) | 82.8 (82.4–84.5) | 4.5 (4.3–5.2) |
|
|
245
|
+
| Extension import, `PI_TIMING`, ms | 370 (217–465) | 395 (88–556) | 94 (75–193) | 100 (85–143) |
|
|
246
|
+
| RSS at `session_start`, MiB | 162.5 (145.6–170.4) | 163.3 (153.4–170.2) | 170.2 (169.7–171.2) | 171.4 (168.9–173.0) |
|
|
247
|
+
|
|
248
|
+
The `delegate` time runs from the parent's tool start to its receipt; RSS growth is
|
|
249
|
+
the parent's `process.memoryUsage().rss` difference across the same window. The
|
|
250
|
+
table combines the startup and `/agents` sets of one campaign (40 runs) with the
|
|
251
|
+
delegate set of a later campaign (20 runs). That delegate harness held the parent's
|
|
252
|
+
turn until Pi's once-per-second completion scan could deliver the child's notice.
|
|
253
|
+
All 60 runs exited 0 with zero errors. Every child finished, its completion resumed
|
|
254
|
+
the parent, and every `/agents` load returned the viewer.
|
|
255
|
+
|
|
256
|
+
Two earlier delegate sets did not hold the parent's turn, so the print run could end
|
|
257
|
+
before that scan. That happened to one official run with the change in each set. The
|
|
258
|
+
first harness counted it as an error without recording which check failed; its child
|
|
259
|
+
finished. In the second, the completion did not resume the parent before exit. Their
|
|
260
|
+
delegate timings remain valid because the window ends at the receipt. The second set
|
|
261
|
+
gave medians of 554 to 46 ms on the fork and 1735 to 98 ms on official Pi, with the
|
|
262
|
+
same memory pattern. Across it and the table's set, the change was faster in all 10
|
|
263
|
+
fork pairs and in 9 of 10 official pairs. In the exception, during a load spike, the
|
|
264
|
+
after run took 2339 ms against 1874 ms before. Official latency is therefore noisy,
|
|
265
|
+
and its magnitude is inconclusive under this load; the memory result held in every
|
|
266
|
+
pair. Child startup time and RSS showed no consistent change, because children keep
|
|
267
|
+
the absolute import. Startup showed no measurable difference: Jiti's debug log lists
|
|
268
|
+
the same native, transpiled and virtual loads, plus one more lookup in its existing
|
|
269
|
+
virtual SDK table.
|
|
270
|
+
|
|
271
|
+
A CPU profile of one fork pair splits the 678 ms before window into Node internals
|
|
272
|
+
(336 ms), native work without a script location (203 ms) and second-copy package code
|
|
273
|
+
(115 ms). Node's module-loading code accounts for 251 ms of those internals, led by
|
|
274
|
+
source-text module compilation (109 ms). File opens, stats, reads and closes account
|
|
275
|
+
for 152 ms of the native work. The 93 ms after window contains no second-copy code.
|
|
276
|
+
Single fallback probes still imported from disk with `PI_PACKAGE_DIR` set (69 MiB
|
|
277
|
+
growth during delegation). With a full development install under the fork host, they
|
|
278
|
+
loaded the exact fork package (55 MiB), not the checkout's official copy.
|
|
279
|
+
|
|
280
|
+
The package smoke's bundled RPC CLI checks both production-only layouts. There, the
|
|
281
|
+
adapter must return the host's `SessionManager`, the class delegation uses to create
|
|
282
|
+
the child session. The baseline adapter returned a separate class on both hosts. This
|
|
283
|
+
is a first-call and memory measurement on one machine. It is not a cold-cache,
|
|
284
|
+
interactive TUI, model-latency or fleet claim.
|
|
285
|
+
|
|
189
286
|
## Necessary costs
|
|
190
287
|
|
|
191
288
|
- Native Pi owns context loading and model execution. The extension cannot remove
|
package/package.json
CHANGED
|
@@ -416,7 +416,9 @@ try {
|
|
|
416
416
|
import { writeFileSync } from "node:fs";
|
|
417
417
|
import { Type } from "typebox";
|
|
418
418
|
import { Assert } from "typebox/value";
|
|
419
|
+
import * as PiSDK from "@earendil-works/pi-coding-agent";
|
|
419
420
|
import { Type as AgentType, Assert as AgentAssert } from ${JSON.stringify(pathToFileURL(join(packageRoot, "dist/agent/typebox.js")).href)};
|
|
421
|
+
import { loadSDK, packageRoot as selectedRoot } from ${JSON.stringify(pathToFileURL(join(packageRoot, "dist/agent/host.js")).href)};
|
|
420
422
|
export default function(pi) {
|
|
421
423
|
// Native development graphs can resolve local peers before Jiti runs.
|
|
422
424
|
// Production-only installs must instead reuse the bundled host's virtual modules.
|
|
@@ -424,8 +426,11 @@ export default function(pi) {
|
|
|
424
426
|
assert.equal(AgentType, Type, "extension schemas reuse Pi's TypeBox instance");
|
|
425
427
|
assert.equal(AgentAssert, Assert, "extension validation reuses Pi's TypeBox instance");
|
|
426
428
|
}
|
|
427
|
-
pi.on("session_start", (_event, ctx) => {
|
|
428
|
-
|
|
429
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
430
|
+
// Delegation, /agents and parked history load the SDK through this adapter. Jiti wraps
|
|
431
|
+
// each importer's namespace, so compare the session class delegation constructs.
|
|
432
|
+
const sdkReused = (await loadSDK(selectedRoot())).SessionManager === PiSDK.SessionManager;
|
|
433
|
+
writeFileSync(${JSON.stringify(marker)}, JSON.stringify({ tools: pi.getActiveTools(), sdkReused }));
|
|
429
434
|
ctx.shutdown();
|
|
430
435
|
});
|
|
431
436
|
}`,
|
|
@@ -468,7 +473,11 @@ export default function(pi) {
|
|
|
468
473
|
"",
|
|
469
474
|
);
|
|
470
475
|
assert.doesNotMatch(output, /Failed to load extension|ERR_INTERNAL_ASSERTION|Extension error/);
|
|
471
|
-
|
|
476
|
+
const cli = JSON.parse(readFileSync(marker, "utf8"));
|
|
477
|
+
assert.deepEqual(cli.tools.sort(), expectedTools);
|
|
478
|
+
if (!existsSync(join(packageRoot, "node_modules/@earendil-works/pi-coding-agent"))) {
|
|
479
|
+
assert.equal(cli.sdkReused, true, "production-only installs reuse Pi's loaded SDK");
|
|
480
|
+
}
|
|
472
481
|
} finally {
|
|
473
482
|
await cliProcesses.stop();
|
|
474
483
|
}
|
|
@@ -15,13 +15,11 @@ function guard(pi: ExtensionAPI, directory: string, token: string): void {
|
|
|
15
15
|
if (event.toolName === "delegate" || event.toolName === "agent_runs") {
|
|
16
16
|
return { block: true, reason: "Only a main session may delegate or control children." };
|
|
17
17
|
}
|
|
18
|
+
if (event.toolName !== "bash" || typeof event.input.command !== "string") {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
18
21
|
const owner = pi.getAllTools().find((tool) => tool.name === "bash")?.sourceInfo;
|
|
19
|
-
if (
|
|
20
|
-
event.toolName === "bash" &&
|
|
21
|
-
typeof event.input.command === "string" &&
|
|
22
|
-
owner &&
|
|
23
|
-
(owner.source === "builtin" || localCwdOwner(owner))
|
|
24
|
-
) {
|
|
22
|
+
if (owner && (owner.source === "builtin" || localCwdOwner(owner))) {
|
|
25
23
|
// Preserve the selected local owner's cwd/env and leave remote/custom tools alone.
|
|
26
24
|
const input = event.input;
|
|
27
25
|
input.command = shellCommand(event.input.command, directory, token);
|
package/src/agent/host.ts
CHANGED
|
@@ -85,7 +85,22 @@ export function packageRoot(): string {
|
|
|
85
85
|
}
|
|
86
86
|
return path.dirname(manifest);
|
|
87
87
|
}
|
|
88
|
+
// Pi's resolution of this extension's SDK import: the running host in production installs,
|
|
89
|
+
// possibly a package-local copy in development checkouts.
|
|
90
|
+
let resolvedSDK: HostSDK | undefined;
|
|
91
|
+
export function rememberSDK(sdk: HostSDK): void {
|
|
92
|
+
resolvedSDK = sdk;
|
|
93
|
+
}
|
|
88
94
|
export async function loadSDK(root: string): Promise<HostSDK> {
|
|
95
|
+
// Reuse it only as the selected root. PI_PACKAGE_DIR replaces getPackageDir()'s own
|
|
96
|
+
// location, so only an unset override proves that identity.
|
|
97
|
+
if (
|
|
98
|
+
resolvedSDK !== undefined &&
|
|
99
|
+
(process.env.PI_PACKAGE_DIR ?? "") === "" &&
|
|
100
|
+
fs.realpathSync(resolvedSDK.getPackageDir()) === fs.realpathSync(root)
|
|
101
|
+
) {
|
|
102
|
+
return resolvedSDK;
|
|
103
|
+
}
|
|
89
104
|
// Selected host SDK, not a second package-local runtime hidden behind Jiti aliases.
|
|
90
105
|
// oxlint-disable-next-line typescript/no-unsafe-assignment
|
|
91
106
|
const sdk: HostSDK = await import(pathToFileURL(path.join(root, "dist/index.js")).href);
|
package/src/agent/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import * as loadedSDK from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import type {
|
|
5
6
|
ExtensionAPI,
|
|
6
7
|
ExtensionContext,
|
|
@@ -16,7 +17,7 @@ import {
|
|
|
16
17
|
signalProcess,
|
|
17
18
|
stopChildren,
|
|
18
19
|
} from "./process.ts";
|
|
19
|
-
import { isChild, errorText } from "./host.ts";
|
|
20
|
+
import { isChild, errorText, rememberSDK } from "./host.ts";
|
|
20
21
|
import { serveParked } from "./parking.ts";
|
|
21
22
|
import {
|
|
22
23
|
resultSchema,
|
|
@@ -509,5 +510,7 @@ class AgentRuntime {
|
|
|
509
510
|
}
|
|
510
511
|
}
|
|
511
512
|
export default function agents(pi: ExtensionAPI): void {
|
|
513
|
+
// Delegation, /agents and parked history then avoid importing a second SDK graph.
|
|
514
|
+
rememberSDK(loadedSDK);
|
|
512
515
|
new AgentRuntime(pi).register();
|
|
513
516
|
}
|
|
@@ -188,7 +188,7 @@ export class Card implements Component {
|
|
|
188
188
|
if (this.arguments !== value.args) {
|
|
189
189
|
this.tool.updateArgs(projected(value.args) ?? {});
|
|
190
190
|
this.arguments = value.args;
|
|
191
|
-
this.
|
|
191
|
+
this.cache = undefined;
|
|
192
192
|
}
|
|
193
193
|
}
|
|
194
194
|
updateTool(value: ViewedTool, options: CardOptions, preparing: boolean): void {
|
|
@@ -207,7 +207,7 @@ export class Card implements Component {
|
|
|
207
207
|
this.phase = phase;
|
|
208
208
|
this.value = source.result;
|
|
209
209
|
this.ended = ended;
|
|
210
|
-
this.
|
|
210
|
+
this.cache = undefined;
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
213
|
private preview(value: unknown, isError: boolean, partial: boolean): void {
|
|
@@ -245,7 +245,7 @@ export class Card implements Component {
|
|
|
245
245
|
this.status?.setText(message.ended ? "Awaiting journal publication" : "In progress");
|
|
246
246
|
this.blocks = message.blocks.slice();
|
|
247
247
|
this.ended = message.ended;
|
|
248
|
-
this.
|
|
248
|
+
this.cache = undefined;
|
|
249
249
|
}
|
|
250
250
|
invalidate(): void {
|
|
251
251
|
this.cache = undefined;
|
package/src/agent/observe.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type { ObserverRow } from "./observe-chrome.ts";
|
|
|
21
21
|
const STATUS_KEY = "agent-observer";
|
|
22
22
|
type Roots = Map<string, ReturnType<typeof actor>>;
|
|
23
23
|
type Commit = (live: ReadonlyMap<string, boolean>) => void;
|
|
24
|
+
type WidgetSnapshot = { status: string; rows: readonly ObserverRow[]; more: number; error: string };
|
|
24
25
|
|
|
25
26
|
class Observer {
|
|
26
27
|
private ctx?: ExtensionContext;
|
|
@@ -36,7 +37,7 @@ class Observer {
|
|
|
36
37
|
private watcher?: fs.FSWatcher;
|
|
37
38
|
private widgetVisible = false;
|
|
38
39
|
private widgetSignature = "";
|
|
39
|
-
private widgetRender?: () => void;
|
|
40
|
+
private widgetRender?: (snapshot: WidgetSnapshot) => void;
|
|
40
41
|
private readonly launches = new Map<string, Launch>();
|
|
41
42
|
private readonly finished = new Set<string>();
|
|
42
43
|
private pending: string[] = [];
|
|
@@ -185,6 +186,7 @@ class Observer {
|
|
|
185
186
|
const scanning = this.update()
|
|
186
187
|
.catch((error: unknown) => {
|
|
187
188
|
this.error = plainText(errorText(error));
|
|
189
|
+
this.widget();
|
|
188
190
|
})
|
|
189
191
|
.finally(() => {
|
|
190
192
|
if (this.scanning !== scanning) {
|
|
@@ -278,23 +280,24 @@ class Observer {
|
|
|
278
280
|
this.widgetSignature = "";
|
|
279
281
|
return;
|
|
280
282
|
}
|
|
281
|
-
const
|
|
283
|
+
const visible = rows.slice(0, 4);
|
|
284
|
+
const more = rows.length - visible.length;
|
|
285
|
+
const snapshot = { status: this.status(), rows: visible, more, error: this.error };
|
|
286
|
+
const signature = JSON.stringify(snapshot);
|
|
282
287
|
if (signature === this.widgetSignature) {
|
|
283
288
|
return;
|
|
284
289
|
}
|
|
285
290
|
this.widgetSignature = signature;
|
|
286
291
|
if (this.widgetVisible) {
|
|
287
|
-
this.widgetRender?.();
|
|
292
|
+
this.widgetRender?.(snapshot);
|
|
288
293
|
return;
|
|
289
294
|
}
|
|
290
295
|
this.widgetVisible = true;
|
|
291
296
|
ctx.ui.setWidget(
|
|
292
297
|
STATUS_KEY,
|
|
293
298
|
(tui, theme) => {
|
|
294
|
-
this.widgetRender = () => {
|
|
295
|
-
tui.requestRender();
|
|
296
|
-
};
|
|
297
299
|
const content = new Container();
|
|
300
|
+
let shown: WidgetSnapshot = snapshot;
|
|
298
301
|
const open = (id: string) => {
|
|
299
302
|
if (ctx !== this.ctx) {
|
|
300
303
|
return;
|
|
@@ -303,39 +306,38 @@ class Observer {
|
|
|
303
306
|
ctx.ui.notify(errorText(error), "error");
|
|
304
307
|
});
|
|
305
308
|
};
|
|
309
|
+
const build = () => {
|
|
310
|
+
content.clear();
|
|
311
|
+
content.addChild(actionText(theme.fg("accent", shown.status), () => open("")));
|
|
312
|
+
for (const row of shown.rows) {
|
|
313
|
+
const glyph = row.live ? "●" : "○";
|
|
314
|
+
const color = row.live ? "accent" : "warning";
|
|
315
|
+
content.addChild(
|
|
316
|
+
actionText(
|
|
317
|
+
`${theme.fg(color, glyph)} ${theme.bold(row.name)} · ${row.activity ?? row.status}`,
|
|
318
|
+
() => open(row.id),
|
|
319
|
+
),
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (shown.more > 0) {
|
|
323
|
+
content.addChild(
|
|
324
|
+
actionText(theme.fg("dim", `+${shown.more} more · /agents`), () => open("")),
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
if (shown.error.length > 0) {
|
|
328
|
+
content.addChild(new TruncatedText(theme.fg("warning", shown.error), 0, 0));
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
build();
|
|
332
|
+
this.widgetRender = (next) => {
|
|
333
|
+
shown = next;
|
|
334
|
+
build();
|
|
335
|
+
tui.requestRender();
|
|
336
|
+
};
|
|
306
337
|
return {
|
|
307
|
-
render: (width) =>
|
|
308
|
-
const strip = this.strip();
|
|
309
|
-
const visible = strip.slice(0, 4);
|
|
310
|
-
content.clear();
|
|
311
|
-
content.addChild(actionText(theme.fg("accent", this.status()), () => open("")));
|
|
312
|
-
for (const row of visible) {
|
|
313
|
-
const glyph = row.live ? "●" : "○";
|
|
314
|
-
const color = row.live ? "accent" : "warning";
|
|
315
|
-
content.addChild(
|
|
316
|
-
actionText(
|
|
317
|
-
`${theme.fg(color, glyph)} ${theme.bold(row.name)} · ${row.activity ?? row.status}`,
|
|
318
|
-
() => open(row.id),
|
|
319
|
-
),
|
|
320
|
-
);
|
|
321
|
-
}
|
|
322
|
-
if (strip.length > visible.length) {
|
|
323
|
-
content.addChild(
|
|
324
|
-
actionText(
|
|
325
|
-
theme.fg("dim", `+${strip.length - visible.length} more · /agents`),
|
|
326
|
-
() => open(""),
|
|
327
|
-
),
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
if (this.error.length > 0) {
|
|
331
|
-
content.addChild(new TruncatedText(theme.fg("warning", this.error), 0, 0));
|
|
332
|
-
}
|
|
333
|
-
return content.render(width);
|
|
334
|
-
},
|
|
338
|
+
render: (width) => content.render(width),
|
|
335
339
|
handleMouse: (event) => content.handleMouse(event),
|
|
336
|
-
invalidate:
|
|
337
|
-
// Render reads current rows, expansion and theme on every frame.
|
|
338
|
-
},
|
|
340
|
+
invalidate: build,
|
|
339
341
|
};
|
|
340
342
|
},
|
|
341
343
|
{ placement: "aboveEditor" },
|
package/src/agent/parking.ts
CHANGED
|
@@ -85,8 +85,20 @@ async function cut(
|
|
|
85
85
|
return undefined;
|
|
86
86
|
}
|
|
87
87
|
const PARK_AFTER_MS = 5 * 60_000;
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
/** SHA-256 of every state byte through one reused chunk, like the native state reader. */
|
|
89
|
+
function digest(file: string): string {
|
|
90
|
+
const hash = createHash("sha256");
|
|
91
|
+
const fd = fs.openSync(file, "r");
|
|
92
|
+
try {
|
|
93
|
+
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
94
|
+
for (let count = fs.readSync(fd, chunk); count > 0; count = fs.readSync(fd, chunk)) {
|
|
95
|
+
hash.update(chunk.subarray(0, count));
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
fs.closeSync(fd);
|
|
99
|
+
}
|
|
100
|
+
return hash.digest("hex");
|
|
101
|
+
}
|
|
90
102
|
|
|
91
103
|
/** Parked authority names this session and exactly the saved generation's state bytes. */
|
|
92
104
|
function exact(
|
package/src/agent/runner.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { launchAt, missing, read, runDirectory, write } from "./storage.ts";
|
|
|
7
7
|
import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.ts";
|
|
8
8
|
import { errorText, loadSDK } from "./host.ts";
|
|
9
9
|
import { Parking, type Runtime } from "./parking.ts";
|
|
10
|
-
import {
|
|
10
|
+
import { UsageIndex } from "./usage.ts";
|
|
11
11
|
import { initializeCwd } from "./cwd.ts";
|
|
12
12
|
import { childRuntime } from "./child-runtime.ts";
|
|
13
13
|
import { ActorObservation, type ObservationStats } from "./observation.ts";
|
|
@@ -53,6 +53,8 @@ class Actor {
|
|
|
53
53
|
private readonly started = new Set<string>();
|
|
54
54
|
// Actor-local validation metadata only; never retain command prompts or receipt authority.
|
|
55
55
|
private readonly validated = new Map<string, { identity: string; id: string }>();
|
|
56
|
+
// Every run of this conversation shares one native journal; later runs read only its suffix.
|
|
57
|
+
private readonly accounting = new UsageIndex();
|
|
56
58
|
private session?: AgentSession;
|
|
57
59
|
private observation?: ActorObservation;
|
|
58
60
|
private context?: { pi: ExtensionAPI; ctx: ExtensionContext };
|
|
@@ -111,7 +113,7 @@ class Actor {
|
|
|
111
113
|
terminal?: string,
|
|
112
114
|
): Pick<Result, "contributions" | "accountingError" | "accountingPending"> {
|
|
113
115
|
try {
|
|
114
|
-
return { contributions:
|
|
116
|
+
return { contributions: this.accounting.read(launch, baseline, terminal) };
|
|
115
117
|
} catch (error) {
|
|
116
118
|
return {
|
|
117
119
|
accountingError: errorText(error),
|
package/src/agent/usage.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
-
import { journalStamp } from "../shared/native-journal.ts";
|
|
3
|
+
import { journalStamp, nativeAppend } from "../shared/native-journal.ts";
|
|
4
4
|
import { nativeProjection } from "../shared/journal-projections.ts";
|
|
5
5
|
import { scanJournal } from "../shared/journal-frames.ts";
|
|
6
|
-
import { isRecord } from "../shared/unknown.ts";
|
|
6
|
+
import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
|
|
7
7
|
import { Assert } from "./typebox.ts";
|
|
8
8
|
import {
|
|
9
9
|
contributionsSchema,
|
|
@@ -14,7 +14,8 @@ import {
|
|
|
14
14
|
} from "./contracts.ts";
|
|
15
15
|
import { read, runDirectory, write } from "./storage.ts";
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/** Billing evidence of one native entry. Missing required usage is an error only outside a baseline. */
|
|
18
|
+
function billing(entry: UnknownRecord): UnknownRecord | "missing" | undefined {
|
|
18
19
|
if (entry.checkpoint === true) {
|
|
19
20
|
return;
|
|
20
21
|
}
|
|
@@ -25,13 +26,13 @@ function billing(entry: Record<string, unknown>) {
|
|
|
25
26
|
) {
|
|
26
27
|
return;
|
|
27
28
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
throw new Error("Required native usage is unavailable.");
|
|
29
|
+
if (message.usage === undefined) {
|
|
30
|
+
return entry.type === "usage" || message.role === "assistant" ? "missing" : undefined;
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
const { role, provider, model, responseModel, usage } = message;
|
|
33
|
+
return { role, provider, model, responseModel, usage };
|
|
33
34
|
}
|
|
34
|
-
function attribution(message:
|
|
35
|
+
function attribution(message: UnknownRecord, launch: Launch) {
|
|
35
36
|
if (message.role === "toolResult") {
|
|
36
37
|
return { provider: launch.provider, model: launch.model };
|
|
37
38
|
}
|
|
@@ -44,63 +45,118 @@ function attribution(message: Record<string, unknown>, launch: Launch) {
|
|
|
44
45
|
model: typeof model === "string" ? model : launch.model,
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
48
|
+
interface Entry {
|
|
49
|
+
readonly id: string;
|
|
50
|
+
readonly billing: UnknownRecord | "missing" | undefined;
|
|
51
|
+
}
|
|
52
|
+
/** Accounting metadata of the validated LF-published journal records before `end`. */
|
|
53
|
+
interface Prefix {
|
|
54
|
+
stat: fs.BigIntStats;
|
|
55
|
+
end: number;
|
|
56
|
+
header?: UnknownRecord;
|
|
57
|
+
readonly entries: Entry[];
|
|
58
|
+
}
|
|
59
|
+
function identify(header: UnknownRecord, launch: Launch): void {
|
|
60
|
+
if (header.type !== "session" || header.id !== launch.sessionId) {
|
|
61
|
+
throw new Error("Foreign native usage journal identity.");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** An unchanged or native v3 appended journal keeps its prefix; any other change rebuilds it. */
|
|
65
|
+
function reusable(cached: Prefix | undefined, stat: fs.BigIntStats): Prefix {
|
|
66
|
+
return cached !== undefined &&
|
|
67
|
+
(journalStamp(cached.stat) === journalStamp(stat) ||
|
|
68
|
+
nativeAppend(cached.stat, stat, cached.header))
|
|
69
|
+
? cached
|
|
70
|
+
: { stat, end: 0, entries: [] };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Child usage of one physical native journal. A retained index parses only records after its last
|
|
74
|
+
* accepted LF boundary. Baselines, required usage and attribution apply to each read.
|
|
75
|
+
*/
|
|
76
|
+
export class UsageIndex {
|
|
77
|
+
private prefix?: Prefix;
|
|
78
|
+
read(launch: Launch, baseline: ReadonlySet<string>, terminal?: string): Contribution[] {
|
|
79
|
+
const values: unknown[] = [];
|
|
80
|
+
const reachedTerminal = new Error("Native terminal boundary reached.");
|
|
81
|
+
const charge = (entry: Entry): void => {
|
|
82
|
+
const billed = baseline.has(entry.id) ? undefined : entry.billing;
|
|
83
|
+
if (billed === "missing") {
|
|
84
|
+
throw new Error("Required native usage is unavailable.");
|
|
85
|
+
}
|
|
86
|
+
if (billed !== undefined) {
|
|
87
|
+
values.push({
|
|
88
|
+
id: `${launch.sessionId}:${entry.id}`,
|
|
89
|
+
...attribution(billed, launch),
|
|
90
|
+
usage: billed.usage,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (entry.id === terminal) {
|
|
94
|
+
throw reachedTerminal;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const cached = this.prefix;
|
|
98
|
+
// Only a completed read is retained.
|
|
99
|
+
this.prefix = undefined;
|
|
100
|
+
const fd = fs.openSync(launch.sessionFile, "r");
|
|
58
101
|
try {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
102
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
103
|
+
const prefix = reusable(cached, stat);
|
|
104
|
+
let ended = false;
|
|
105
|
+
try {
|
|
106
|
+
if (prefix.header !== undefined) {
|
|
107
|
+
identify(prefix.header, launch);
|
|
108
|
+
}
|
|
109
|
+
for (const entry of prefix.entries) {
|
|
110
|
+
charge(entry);
|
|
111
|
+
}
|
|
112
|
+
scanJournal(
|
|
113
|
+
fd,
|
|
114
|
+
nativeProjection,
|
|
115
|
+
({ value, end }) => {
|
|
116
|
+
if (prefix.header === undefined) {
|
|
117
|
+
identify(value, launch);
|
|
118
|
+
prefix.header = value;
|
|
119
|
+
prefix.end = end;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (typeof value.id !== "string" || typeof value.type !== "string") {
|
|
123
|
+
throw new Error("Missing native usage entry identity.");
|
|
66
124
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
if (value.id === terminal) {
|
|
82
|
-
observed.ended = true;
|
|
83
|
-
throw reachedTerminal;
|
|
84
|
-
}
|
|
85
|
-
},
|
|
86
|
-
{ policy: "live", requireNewline: true },
|
|
87
|
-
);
|
|
88
|
-
} catch (error) {
|
|
89
|
-
if (error !== reachedTerminal) {
|
|
90
|
-
throw error;
|
|
125
|
+
const entry = { id: value.id, billing: billing(value) };
|
|
126
|
+
prefix.entries.push(entry);
|
|
127
|
+
prefix.end = end;
|
|
128
|
+
charge(entry);
|
|
129
|
+
},
|
|
130
|
+
{ policy: "live", requireNewline: true, start: prefix.end },
|
|
131
|
+
);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error !== reachedTerminal) {
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
ended = true;
|
|
91
137
|
}
|
|
138
|
+
if (journalStamp(fs.fstatSync(fd, { bigint: true })) !== journalStamp(stat)) {
|
|
139
|
+
throw new Error("Native usage journal changed while reading.");
|
|
140
|
+
}
|
|
141
|
+
if (prefix.header === undefined || (terminal !== undefined && !ended)) {
|
|
142
|
+
throw new Error("Native terminal or session identity is not LF-published.");
|
|
143
|
+
}
|
|
144
|
+
Assert(contributionsSchema, values);
|
|
145
|
+
prefix.stat = stat;
|
|
146
|
+
this.prefix = prefix;
|
|
147
|
+
return values;
|
|
148
|
+
} finally {
|
|
149
|
+
fs.closeSync(fd);
|
|
92
150
|
}
|
|
93
|
-
if (journalStamp(fs.fstatSync(fd, { bigint: true })) !== stamp) {
|
|
94
|
-
throw new Error("Native usage journal changed while reading.");
|
|
95
|
-
}
|
|
96
|
-
} finally {
|
|
97
|
-
fs.closeSync(fd);
|
|
98
|
-
}
|
|
99
|
-
if (!observed.header || (terminal !== undefined && !observed.ended)) {
|
|
100
|
-
throw new Error("Native terminal or session identity is not LF-published.");
|
|
101
151
|
}
|
|
102
|
-
|
|
103
|
-
|
|
152
|
+
}
|
|
153
|
+
/** A fresh read for callers without an owner lifetime. */
|
|
154
|
+
export function childUsage(
|
|
155
|
+
launch: Launch,
|
|
156
|
+
baseline: ReadonlySet<string>,
|
|
157
|
+
terminal?: string,
|
|
158
|
+
): Contribution[] {
|
|
159
|
+
return new UsageIndex().read(launch, baseline, terminal);
|
|
104
160
|
}
|
|
105
161
|
export function readCanonicalResult(base: string, launch: Launch): Result | undefined {
|
|
106
162
|
const file = path.join(runDirectory(base, launch.id), "result.json");
|