@fitchmultz/pi-subagents 0.52.1 → 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 +20 -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 +25 -5
- package/dist/agent/process.js +10 -3
- 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 +125 -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 +24 -8
- package/src/agent/process.ts +15 -4
- 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/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");
|
|
@@ -196,14 +196,24 @@ export class Mailbox {
|
|
|
196
196
|
}
|
|
197
197
|
ack(ids: readonly string[]): UnknownRecord {
|
|
198
198
|
const acked: string[] = [];
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
199
|
+
this.stored(() => {
|
|
200
|
+
// One FULL commit per ACK group; a rollback leaves every listed payload and intent pending.
|
|
201
|
+
this.db?.exec("BEGIN IMMEDIATE");
|
|
202
|
+
try {
|
|
203
|
+
for (const id of ids) {
|
|
204
|
+
const entry = this.get(id);
|
|
205
|
+
if (entry) {
|
|
206
|
+
this.notificationAccepted(id);
|
|
207
|
+
this.remove(id);
|
|
208
|
+
acked.push(id);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
this.db?.exec("COMMIT");
|
|
212
|
+
} catch (error) {
|
|
213
|
+
this.db?.exec("ROLLBACK");
|
|
214
|
+
throw error;
|
|
205
215
|
}
|
|
206
|
-
}
|
|
216
|
+
});
|
|
207
217
|
return { acked };
|
|
208
218
|
}
|
|
209
219
|
page(after: number, limit: number): InboxPage {
|
|
@@ -138,21 +138,20 @@ export class WakeDeliveries {
|
|
|
138
138
|
|
|
139
139
|
private pending(delivery: Delivery): PendingNotification | undefined {
|
|
140
140
|
let after = 0;
|
|
141
|
-
let next: PendingNotification | undefined;
|
|
142
141
|
let page;
|
|
143
142
|
do {
|
|
144
|
-
|
|
143
|
+
// Decode only the retired prefix and the oldest actionable entry, not the whole backlog.
|
|
144
|
+
page = delivery.mailbox.pendingNotifications(after, 1);
|
|
145
145
|
for (const entry of page.messages) {
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
delivery.mailbox.notificationAccepted(entry.message.id);
|
|
149
|
-
} else if (!next) {
|
|
150
|
-
next = entry;
|
|
146
|
+
if (actionable(entry)) {
|
|
147
|
+
return entry;
|
|
151
148
|
}
|
|
149
|
+
// Retire only notification intent. Payload/reply obligations require actual ACK/reply.
|
|
150
|
+
delivery.mailbox.notificationAccepted(entry.message.id);
|
|
152
151
|
}
|
|
153
152
|
after = Number(page.nextCursor);
|
|
154
153
|
} while (page.hasMore);
|
|
155
|
-
return
|
|
154
|
+
return undefined;
|
|
156
155
|
}
|
|
157
156
|
|
|
158
157
|
private groups(): Map<string, Candidate | undefined> {
|
|
@@ -8,7 +8,12 @@ import {
|
|
|
8
8
|
type InboundMessageEntry,
|
|
9
9
|
type PendingInboundMessage,
|
|
10
10
|
} from "./runtime-types.ts";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
checkpointMessageId,
|
|
13
|
+
isInboundCheckpoint,
|
|
14
|
+
publicationMetadata,
|
|
15
|
+
requestedDelivery,
|
|
16
|
+
} from "./inbound-record.ts";
|
|
12
17
|
import {
|
|
13
18
|
isReceiptQuery,
|
|
14
19
|
type NativeReceipt,
|
|
@@ -66,6 +71,7 @@ export class InboundJournal {
|
|
|
66
71
|
if (
|
|
67
72
|
item.type === "custom" &&
|
|
68
73
|
item.customType === INBOUND_CHECKPOINT_TYPE &&
|
|
74
|
+
checkpointMessageId(item.data) === query.messageId &&
|
|
69
75
|
isInboundCheckpoint(item.data) &&
|
|
70
76
|
item.data.sessionId === query.rootSessionId
|
|
71
77
|
) {
|
|
@@ -82,7 +88,7 @@ export class InboundJournal {
|
|
|
82
88
|
data: InboundCheckpoint,
|
|
83
89
|
): NativeReceipt | undefined {
|
|
84
90
|
if ("entry" in data) {
|
|
85
|
-
if (receipt
|
|
91
|
+
if (receipt) {
|
|
86
92
|
return receipt;
|
|
87
93
|
}
|
|
88
94
|
const { from, message, stage, receivedAt } = data.entry;
|
|
@@ -106,7 +112,7 @@ export class InboundJournal {
|
|
|
106
112
|
discarded: false,
|
|
107
113
|
};
|
|
108
114
|
}
|
|
109
|
-
if (!receipt
|
|
115
|
+
if (!receipt) {
|
|
110
116
|
return receipt;
|
|
111
117
|
}
|
|
112
118
|
return {
|
|
@@ -97,3 +97,15 @@ export function isInboundCheckpoint(
|
|
|
97
97
|
value.stage === "reply-retired")
|
|
98
98
|
);
|
|
99
99
|
}
|
|
100
|
+
/** Unvalidated: lets a receipt skip checkpoints for other messages before full validation. */
|
|
101
|
+
export function checkpointMessageId(value: unknown): unknown {
|
|
102
|
+
if (!isRecord(value)) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if ("entry" in value) {
|
|
106
|
+
return isRecord(value.entry) && isRecord(value.entry.message)
|
|
107
|
+
? value.entry.message.id
|
|
108
|
+
: undefined;
|
|
109
|
+
}
|
|
110
|
+
return value.messageId;
|
|
111
|
+
}
|
|
@@ -11,10 +11,19 @@ function localForkStartCommand(): string {
|
|
|
11
11
|
return `pi --name worker --extension ${shellQuote(path.join(PACKAGE_ROOT, "src", "index.ts"))}`;
|
|
12
12
|
}
|
|
13
13
|
function duplicateNames(sessions: readonly SessionInfo[]): ReadonlySet<string> {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
const seen = new Set<string>();
|
|
15
|
+
const duplicates = new Set<string>();
|
|
16
|
+
for (const session of sessions) {
|
|
17
|
+
const name = session.name?.toLowerCase();
|
|
18
|
+
if (name === undefined || name === "") {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (seen.has(name)) {
|
|
22
|
+
duplicates.add(name);
|
|
23
|
+
}
|
|
24
|
+
seen.add(name);
|
|
25
|
+
}
|
|
26
|
+
return duplicates;
|
|
18
27
|
}
|
|
19
28
|
export function formatDuration(seconds: number): string {
|
|
20
29
|
if (seconds < 60) {
|
|
@@ -79,7 +88,7 @@ function healthTags(session: SessionInfo, now: number): readonly string[] {
|
|
|
79
88
|
interface RowOptions {
|
|
80
89
|
readonly currentCwd: string;
|
|
81
90
|
readonly self: boolean;
|
|
82
|
-
readonly duplicates:
|
|
91
|
+
readonly duplicates: ReadonlySet<string>;
|
|
83
92
|
readonly allSessions: readonly SessionInfo[];
|
|
84
93
|
readonly now: number;
|
|
85
94
|
}
|
|
@@ -88,7 +97,7 @@ function row(session: SessionInfo, options: RowOptions): string {
|
|
|
88
97
|
const duplicate =
|
|
89
98
|
session.name !== undefined &&
|
|
90
99
|
session.name !== "" &&
|
|
91
|
-
options.duplicates(session.name.toLowerCase());
|
|
100
|
+
options.duplicates.has(session.name.toLowerCase());
|
|
92
101
|
let location: string | undefined;
|
|
93
102
|
if (options.self) {
|
|
94
103
|
location = "self";
|
|
@@ -132,7 +141,7 @@ export function formatSessionListSections(
|
|
|
132
141
|
const others = scoped.filter((session) => session.id !== id);
|
|
133
142
|
const options = {
|
|
134
143
|
currentCwd: current.cwd,
|
|
135
|
-
duplicates:
|
|
144
|
+
duplicates: duplicateNames(allSessions),
|
|
136
145
|
allSessions,
|
|
137
146
|
now: Date.now(),
|
|
138
147
|
};
|