@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
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import {
|
|
4
|
+
import { completionReceipts } from "./receipts.ts";
|
|
5
5
|
import { readOutputPage } from "../shared/output-page.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { journalStamp } from "../shared/native-journal.ts";
|
|
7
7
|
import type { Launch, Result } from "./contracts.ts";
|
|
8
8
|
import { launchAt, list, runDirectory } from "./storage.ts";
|
|
9
|
-
import { readResult } from "./usage.ts";
|
|
9
|
+
import { readCanonicalResult, readResult } from "./usage.ts";
|
|
10
10
|
|
|
11
11
|
function queuedAdmissions(): Set<string> {
|
|
12
12
|
const store = globalThis as Record<string, unknown>;
|
|
@@ -25,7 +25,7 @@ function queuedAdmissions(): Set<string> {
|
|
|
25
25
|
return queued;
|
|
26
26
|
}
|
|
27
27
|
const admissions = queuedAdmissions();
|
|
28
|
-
|
|
28
|
+
function hold(owner: string, id: string): boolean {
|
|
29
29
|
const key = `${owner}/${id}`;
|
|
30
30
|
if (admissions.has(key)) {
|
|
31
31
|
return false;
|
|
@@ -39,93 +39,218 @@ export function release(owner: string, id: string): void {
|
|
|
39
39
|
export function isHeld(owner: string, id: string): boolean {
|
|
40
40
|
return admissions.has(`${owner}/${id}`);
|
|
41
41
|
}
|
|
42
|
-
|
|
42
|
+
/** Returns whether any admission was released for reconciliation. */
|
|
43
|
+
export function settle(owner: string, pending: boolean): boolean {
|
|
43
44
|
if (pending) {
|
|
44
|
-
return;
|
|
45
|
+
return false;
|
|
45
46
|
}
|
|
47
|
+
let released = false;
|
|
46
48
|
for (const key of admissions) {
|
|
47
49
|
if (key.startsWith(`${owner}/`)) {
|
|
48
50
|
admissions.delete(key);
|
|
51
|
+
released = true;
|
|
49
52
|
}
|
|
50
53
|
}
|
|
54
|
+
return released;
|
|
51
55
|
}
|
|
52
|
-
function
|
|
53
|
-
return (
|
|
54
|
-
isRecord(value) &&
|
|
55
|
-
value.owner === launch.owner &&
|
|
56
|
-
value.id === launch.id &&
|
|
57
|
-
value.completed === true
|
|
58
|
-
);
|
|
56
|
+
export function published(ctx: ExtensionContext, launch: Launch): string | undefined {
|
|
57
|
+
return completionReceipts(ctx.sessionManager.getSessionFile(), launch.owner).get(launch.id);
|
|
59
58
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return (
|
|
65
|
-
(message.toolName === "delegate" || message.toolName === "agent_runs") &&
|
|
66
|
-
isRecord(message.details) &&
|
|
67
|
-
message.details.receivedResult === true &&
|
|
68
|
-
matches(message.details, launch)
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
function receipt(body: unknown, launch: Launch): boolean {
|
|
72
|
-
if (!isRecord(body)) {
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
if (body.type === "custom_message" && body.customType === "agent-completion") {
|
|
76
|
-
return matches(body.details, launch);
|
|
77
|
-
}
|
|
78
|
-
return body.type === "message" && toolReceipt(body.message, launch);
|
|
59
|
+
export interface Completion {
|
|
60
|
+
readonly owner: string;
|
|
61
|
+
readonly id: string;
|
|
62
|
+
readonly message: Parameters<ExtensionAPI["sendMessage"]>[0];
|
|
79
63
|
}
|
|
80
|
-
export function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
64
|
+
export function deliver(
|
|
65
|
+
pi: ExtensionAPI,
|
|
66
|
+
ctx: ExtensionContext,
|
|
67
|
+
notices: readonly Completion[],
|
|
68
|
+
): void {
|
|
69
|
+
const idle = ctx.isIdle();
|
|
70
|
+
for (const [index, notice] of notices.entries()) {
|
|
71
|
+
hold(notice.owner, notice.id);
|
|
72
|
+
try {
|
|
73
|
+
// Pi defers settlement-triggered runs separately. Publish the idle batch first,
|
|
74
|
+
// then trigger once so no unstarted wakeup can outlive its admission hold.
|
|
75
|
+
pi.sendMessage(notice.message, {
|
|
76
|
+
triggerTurn: !idle || index === notices.length - 1,
|
|
77
|
+
deliverAs: "steer",
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
release(notice.owner, notice.id);
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
84
|
}
|
|
85
|
-
function
|
|
85
|
+
function notification(base: string, launch: Launch, result: Result): Completion {
|
|
86
86
|
const file = path.join(runDirectory(base, launch.id), "output.txt");
|
|
87
87
|
const page = fs.existsSync(file) ? readOutputPage(file, { offset: 0, length: 32768 }) : undefined;
|
|
88
88
|
const more =
|
|
89
89
|
page?.nextOffset !== undefined
|
|
90
90
|
? `\n[More output: agent_runs inspect id=${launch.id} offset=${page.nextOffset}; ${page.size} bytes total.]`
|
|
91
91
|
: "";
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
},
|
|
92
|
+
return {
|
|
93
|
+
owner: launch.owner,
|
|
94
|
+
id: launch.id,
|
|
95
|
+
message: {
|
|
96
|
+
customType: "agent-completion",
|
|
97
|
+
display: true,
|
|
98
|
+
content: `Agent run ${launch.id}: ${result.status}. ${result.error ?? ""}\n${page?.text ?? ""}${more}\nNative journal: ${launch.sessionFile}`,
|
|
99
|
+
details: {
|
|
100
|
+
owner: launch.owner,
|
|
101
|
+
id: launch.id,
|
|
102
|
+
completed: true,
|
|
103
|
+
result,
|
|
104
|
+
outputFile: file,
|
|
105
|
+
page,
|
|
107
106
|
},
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
interface PendingJournal {
|
|
111
|
+
readonly file: string;
|
|
112
|
+
readonly stamp: string | undefined;
|
|
113
|
+
}
|
|
114
|
+
interface Loaded {
|
|
115
|
+
readonly launch: Launch;
|
|
116
|
+
readonly result: Result | undefined;
|
|
117
|
+
readonly journal?: PendingJournal;
|
|
118
|
+
}
|
|
119
|
+
interface Settled {
|
|
120
|
+
readonly launch: string;
|
|
121
|
+
readonly result: string;
|
|
122
|
+
readonly pending?: Loaded;
|
|
123
|
+
readonly endedAt: number;
|
|
124
|
+
readonly createdAt: number;
|
|
125
|
+
}
|
|
126
|
+
interface Completed {
|
|
127
|
+
readonly id: string;
|
|
128
|
+
readonly endedAt: number;
|
|
129
|
+
readonly createdAt: number;
|
|
130
|
+
readonly loaded?: Loaded;
|
|
131
|
+
}
|
|
132
|
+
// Validated settled runs by file stamp, so polling does not reparse unchanged history.
|
|
133
|
+
const settledRuns = new Map<string, Map<string, Settled>>();
|
|
134
|
+
function stamp(file: string): string | undefined {
|
|
135
|
+
const stat = fs.statSync(file, { bigint: true, throwIfNoEntry: false });
|
|
136
|
+
return stat === undefined ? undefined : journalStamp(stat);
|
|
137
|
+
}
|
|
138
|
+
function pendingJournal(file: string): PendingJournal | undefined {
|
|
139
|
+
try {
|
|
140
|
+
return { file, stamp: stamp(file) };
|
|
141
|
+
} catch {
|
|
142
|
+
// Unavailable metadata cannot validate a cached attempt or hide execution.
|
|
143
|
+
return undefined;
|
|
113
144
|
}
|
|
114
145
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
146
|
+
function unchangedPending(pending: Loaded | undefined): boolean {
|
|
147
|
+
if (pending === undefined) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
const journal = pending.journal;
|
|
151
|
+
if (journal === undefined) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
const current = pendingJournal(journal.file);
|
|
155
|
+
return current !== undefined && current.stamp === journal.stamp;
|
|
156
|
+
}
|
|
157
|
+
function load(base: string, id: string, owner: string): Loaded {
|
|
158
|
+
const launch = launchAt(base, id, owner);
|
|
159
|
+
const result = readCanonicalResult(base, launch);
|
|
160
|
+
if (result?.accountingPending === undefined) {
|
|
161
|
+
return { launch, result };
|
|
162
|
+
}
|
|
163
|
+
// Only pending runs need this pre-read stamp to observe concurrent publication.
|
|
164
|
+
// readResult revalidates canonical state afterward: a miss intentionally reads result.json twice.
|
|
165
|
+
const journal = pendingJournal(launch.sessionFile);
|
|
166
|
+
return { launch, result: readResult(base, launch), journal };
|
|
167
|
+
}
|
|
168
|
+
function cacheableResult(loaded: Loaded): boolean {
|
|
169
|
+
return loaded.result?.accountingPending === undefined || loaded.journal !== undefined;
|
|
170
|
+
}
|
|
171
|
+
interface Scan {
|
|
172
|
+
readonly base: string;
|
|
173
|
+
readonly owner: string;
|
|
174
|
+
readonly known: ReadonlyMap<string, Settled>;
|
|
175
|
+
readonly settled: Map<string, Settled>;
|
|
176
|
+
}
|
|
177
|
+
function completedRun({ base, owner, known, settled }: Scan, id: string): Completed | undefined {
|
|
178
|
+
const directory = runDirectory(base, id);
|
|
179
|
+
const launch = stamp(path.join(directory, "launch.json"));
|
|
180
|
+
const result = stamp(path.join(directory, "result.json"));
|
|
181
|
+
const prior = known.get(id);
|
|
182
|
+
if (
|
|
183
|
+
prior !== undefined &&
|
|
184
|
+
prior.launch === launch &&
|
|
185
|
+
prior.result === result &&
|
|
186
|
+
unchangedPending(prior.pending)
|
|
187
|
+
) {
|
|
188
|
+
settled.set(id, prior);
|
|
189
|
+
return { id, ...prior, loaded: prior.pending };
|
|
190
|
+
}
|
|
191
|
+
const loaded = load(base, id, owner);
|
|
192
|
+
if (loaded.result === undefined) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const order = { endedAt: loaded.result.endedAt, createdAt: loaded.launch.createdAt };
|
|
196
|
+
// Pending accounting retries on child changes, even after the completion receipt.
|
|
197
|
+
// Ordinary settled history retains its launch/result-only idle probes.
|
|
198
|
+
const pending = loaded.result.accountingPending === undefined ? undefined : loaded;
|
|
199
|
+
if (launch !== undefined && result !== undefined && cacheableResult(loaded)) {
|
|
200
|
+
settled.set(id, { launch, result, pending, ...order });
|
|
201
|
+
}
|
|
202
|
+
return { id, ...order, loaded };
|
|
203
|
+
}
|
|
204
|
+
function completed(base: string, owner: string, awaited: Set<string>): Completed[] {
|
|
205
|
+
const key = JSON.stringify([base, owner]);
|
|
206
|
+
const scan = {
|
|
207
|
+
base,
|
|
208
|
+
owner,
|
|
209
|
+
known: settledRuns.get(key) ?? new Map<string, Settled>(),
|
|
210
|
+
settled: new Map<string, Settled>(),
|
|
211
|
+
};
|
|
212
|
+
const runs = list(base).flatMap((id) => {
|
|
213
|
+
const run = completedRun(scan, id);
|
|
214
|
+
if (run === undefined) {
|
|
215
|
+
awaited.add(path.join(runDirectory(base, id), "result.json"));
|
|
216
|
+
} else if (run.loaded?.result?.accountingPending !== undefined) {
|
|
217
|
+
awaited.add(run.loaded.launch.sessionFile);
|
|
121
218
|
}
|
|
122
|
-
|
|
123
|
-
|
|
219
|
+
return run ?? [];
|
|
220
|
+
});
|
|
221
|
+
settledRuns.delete(key);
|
|
222
|
+
settledRuns.set(key, scan.settled);
|
|
223
|
+
if (settledRuns.size > 8) {
|
|
224
|
+
settledRuns.delete(settledRuns.keys().next().value ?? key);
|
|
225
|
+
}
|
|
226
|
+
return runs.sort((a, b) => {
|
|
227
|
+
if (a.endedAt !== b.endedAt) {
|
|
228
|
+
return a.endedAt - b.endedAt;
|
|
229
|
+
}
|
|
230
|
+
return a.createdAt !== b.createdAt ? a.createdAt - b.createdAt : a.id.localeCompare(b.id);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
export interface Reconciliation {
|
|
234
|
+
readonly notices: Completion[];
|
|
235
|
+
/** Canonical files whose publication can change what this owner must deliver. */
|
|
236
|
+
readonly awaited: ReadonlySet<string>;
|
|
237
|
+
}
|
|
238
|
+
export function notifications(ctx: ExtensionContext, base: string, owner: string): Reconciliation {
|
|
239
|
+
const notices: Completion[] = [];
|
|
240
|
+
const awaited = new Set<string>();
|
|
241
|
+
const runs = completed(base, owner, awaited);
|
|
242
|
+
const receipts =
|
|
243
|
+
runs.length === 0 ? undefined : completionReceipts(ctx.sessionManager.getSessionFile(), owner);
|
|
244
|
+
for (const run of runs) {
|
|
245
|
+
const key = `${owner}/${run.id}`;
|
|
246
|
+
if (receipts?.get(run.id) !== undefined) {
|
|
124
247
|
admissions.delete(key);
|
|
125
248
|
continue;
|
|
126
249
|
}
|
|
127
|
-
|
|
128
|
-
|
|
250
|
+
const loaded = admissions.has(key) ? undefined : (run.loaded ?? load(base, run.id, owner));
|
|
251
|
+
if (loaded?.result !== undefined) {
|
|
252
|
+
notices.push(notification(base, loaded.launch, loaded.result));
|
|
129
253
|
}
|
|
130
254
|
}
|
|
255
|
+
return { notices, awaited };
|
|
131
256
|
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentSession,
|
|
3
|
+
AgentSessionEvent,
|
|
4
|
+
SessionMessageEntry,
|
|
5
|
+
ToolExecutionEndEvent,
|
|
6
|
+
ToolExecutionStartEvent,
|
|
7
|
+
ToolExecutionUpdateEvent,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
10
|
+
import { boundedJson } from "../shared/bounded-json.ts";
|
|
11
|
+
|
|
12
|
+
const VALUE_BUDGET = { bytes: 64 * 1024, nodes: 4096, depth: 32 };
|
|
13
|
+
/** Longer native identifiers are left to the journal rather than keyed live. */
|
|
14
|
+
export const NAME_CHARS = 1024;
|
|
15
|
+
const RETAINED = { settlements: 128, tools: 256 };
|
|
16
|
+
|
|
17
|
+
type MessageEvent = Extract<
|
|
18
|
+
AgentSessionEvent,
|
|
19
|
+
{ type: "message_start" | "message_update" | "message_end" }
|
|
20
|
+
>;
|
|
21
|
+
export type ToolEvent = ToolExecutionStartEvent | ToolExecutionUpdateEvent | ToolExecutionEndEvent;
|
|
22
|
+
const WRITING = { text: "writing", thinking: "thinking", toolCall: "calling" } as const;
|
|
23
|
+
|
|
24
|
+
/** A live in-flight assistant message: a transient native reference, never a receipt. */
|
|
25
|
+
export interface LiveMessage {
|
|
26
|
+
readonly key: number;
|
|
27
|
+
message: AssistantMessage;
|
|
28
|
+
ended: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** A native message found in the session tree: `[seq, entry id, live message key, tool call id]`. */
|
|
31
|
+
export interface Settlement {
|
|
32
|
+
readonly seq: number;
|
|
33
|
+
readonly json: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class LiveTool {
|
|
37
|
+
partial?: unknown;
|
|
38
|
+
rev = 0;
|
|
39
|
+
ended?: { readonly isError: boolean; readonly result: unknown };
|
|
40
|
+
readonly id: string;
|
|
41
|
+
readonly name: string;
|
|
42
|
+
readonly args: unknown;
|
|
43
|
+
private argsJson?: string;
|
|
44
|
+
private partialJson?: { readonly rev: number; readonly json: string };
|
|
45
|
+
private resultJson?: string;
|
|
46
|
+
constructor(id: string, name: string, args: unknown) {
|
|
47
|
+
this.id = id;
|
|
48
|
+
this.name = name;
|
|
49
|
+
this.args = args;
|
|
50
|
+
}
|
|
51
|
+
/** Replaceable progress: only the latest reference is projected at the next flush. */
|
|
52
|
+
progress(value: unknown): void {
|
|
53
|
+
this.partial = value;
|
|
54
|
+
this.rev++;
|
|
55
|
+
}
|
|
56
|
+
projectedArgs(project: (value: unknown) => string): string {
|
|
57
|
+
this.argsJson ??= project(this.args);
|
|
58
|
+
return this.argsJson;
|
|
59
|
+
}
|
|
60
|
+
projectedPartial(project: (value: unknown) => string): string {
|
|
61
|
+
if (this.partialJson?.rev !== this.rev) {
|
|
62
|
+
this.partialJson = { rev: this.rev, json: project(this.partial) };
|
|
63
|
+
}
|
|
64
|
+
return this.partialJson.json;
|
|
65
|
+
}
|
|
66
|
+
projectedResult(project: (value: unknown) => string): string {
|
|
67
|
+
this.resultJson ??= project(this.ended?.result);
|
|
68
|
+
return this.resultJson;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Single-writer actor state. Sparse tool hooks always keep the tool table, because native state
|
|
74
|
+
* retains neither progress, unpublished parallel results nor nested calls. Token-rate message
|
|
75
|
+
* events arrive only while a detail viewer observes; attaching seeds from public native state.
|
|
76
|
+
*/
|
|
77
|
+
export class LiveState {
|
|
78
|
+
version = 0;
|
|
79
|
+
activityVersion = 0;
|
|
80
|
+
message?: LiveMessage;
|
|
81
|
+
readonly tools = new Map<string, LiveTool>();
|
|
82
|
+
readonly settlements: Settlement[] = [];
|
|
83
|
+
settled = 0;
|
|
84
|
+
/** Live items dropped by a retention bound or a branch change; viewers resynchronize. */
|
|
85
|
+
evicted = 0;
|
|
86
|
+
events = 0;
|
|
87
|
+
toolEvents = 0;
|
|
88
|
+
progressUpdates = 0;
|
|
89
|
+
projections = 0;
|
|
90
|
+
unavailable = 0;
|
|
91
|
+
private observing = false;
|
|
92
|
+
private nextKey = 0;
|
|
93
|
+
private queued = false;
|
|
94
|
+
private closed = false;
|
|
95
|
+
private watermark: string | null;
|
|
96
|
+
private readonly session: AgentSession;
|
|
97
|
+
private readonly changed: () => void;
|
|
98
|
+
private readonly failed: (error: unknown) => void;
|
|
99
|
+
constructor(session: AgentSession, changed: () => void, failed: (error: unknown) => void) {
|
|
100
|
+
this.session = session;
|
|
101
|
+
this.changed = changed;
|
|
102
|
+
this.failed = failed;
|
|
103
|
+
this.watermark = session.sessionManager.getLeafId();
|
|
104
|
+
}
|
|
105
|
+
/** Start or stop the transient message view; tools persist across viewers. */
|
|
106
|
+
observe(on: boolean): void {
|
|
107
|
+
this.resolve();
|
|
108
|
+
this.observing = on;
|
|
109
|
+
this.message = undefined;
|
|
110
|
+
this.settlements.length = 0;
|
|
111
|
+
if (on) {
|
|
112
|
+
const streaming = this.session.state.streamingMessage;
|
|
113
|
+
if (streaming?.role === "assistant") {
|
|
114
|
+
this.assistant("message_start", streaming);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
this.version++;
|
|
118
|
+
this.activityVersion++;
|
|
119
|
+
}
|
|
120
|
+
apply(event: AgentSessionEvent): void {
|
|
121
|
+
this.events++;
|
|
122
|
+
if (
|
|
123
|
+
event.type === "message_start" ||
|
|
124
|
+
event.type === "message_update" ||
|
|
125
|
+
event.type === "message_end"
|
|
126
|
+
) {
|
|
127
|
+
this.messageEvent(event);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
private messageEvent(event: MessageEvent): void {
|
|
131
|
+
if (event.message.role === "assistant") {
|
|
132
|
+
this.assistant(event.type, event.message);
|
|
133
|
+
}
|
|
134
|
+
if (event.type === "message_end" && !this.queued) {
|
|
135
|
+
this.queued = true;
|
|
136
|
+
// Native message_end listeners run just before the same job appends the entry.
|
|
137
|
+
queueMicrotask(this.settle);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
private assistant(type: MessageEvent["type"], message: AssistantMessage): void {
|
|
141
|
+
let live = this.message;
|
|
142
|
+
if (type === "message_start" || live === undefined) {
|
|
143
|
+
// A previous message still here was never found in the session tree.
|
|
144
|
+
this.evicted += live === undefined ? 0 : 1;
|
|
145
|
+
live = { key: ++this.nextKey, message, ended: false };
|
|
146
|
+
this.message = live;
|
|
147
|
+
}
|
|
148
|
+
live.message = message;
|
|
149
|
+
live.ended = type === "message_end";
|
|
150
|
+
this.version++;
|
|
151
|
+
}
|
|
152
|
+
tool(event: ToolEvent): void {
|
|
153
|
+
this.toolEvents++;
|
|
154
|
+
this.version++;
|
|
155
|
+
if (event.type === "tool_execution_start") {
|
|
156
|
+
this.resolve();
|
|
157
|
+
this.startTool(event.toolCallId, event.toolName, event.args);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const tool = this.tools.get(event.toolCallId);
|
|
161
|
+
if (event.type === "tool_execution_update") {
|
|
162
|
+
tool?.progress(event.partialResult);
|
|
163
|
+
this.progressUpdates++;
|
|
164
|
+
} else if (tool) {
|
|
165
|
+
tool.ended = { isError: event.isError, result: event.result };
|
|
166
|
+
this.activityVersion++;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
private startTool(id: string, name: string, args: unknown): void {
|
|
170
|
+
if (id.length > NAME_CHARS) {
|
|
171
|
+
this.unavailable++;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const oldest = this.tools.size >= RETAINED.tools ? this.tools.keys().next() : undefined;
|
|
175
|
+
if (oldest?.done === false) {
|
|
176
|
+
this.tools.delete(oldest.value);
|
|
177
|
+
this.evicted++;
|
|
178
|
+
}
|
|
179
|
+
this.tools.set(id, new LiveTool(id, name, args));
|
|
180
|
+
this.activityVersion++;
|
|
181
|
+
}
|
|
182
|
+
private readonly settle = (): void => {
|
|
183
|
+
this.queued = false;
|
|
184
|
+
try {
|
|
185
|
+
if (!this.closed && this.resolve()) {
|
|
186
|
+
this.changed();
|
|
187
|
+
}
|
|
188
|
+
} catch (error) {
|
|
189
|
+
this.failed(error);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* Hand native messages appended since the last walk over to their session entries. A live
|
|
194
|
+
* message without an entry is unresolved publication and stays live. Returns whether anything
|
|
195
|
+
* changed.
|
|
196
|
+
*/
|
|
197
|
+
resolve(): boolean {
|
|
198
|
+
const { appended, departed } = this.appended();
|
|
199
|
+
this.evicted += departed && this.observing ? 1 : 0;
|
|
200
|
+
for (const entry of appended) {
|
|
201
|
+
this.publish(entry, !departed && this.observing);
|
|
202
|
+
}
|
|
203
|
+
this.settlements.splice(0, Math.max(0, this.settlements.length - RETAINED.settlements));
|
|
204
|
+
const changed = appended.length > 0;
|
|
205
|
+
this.version += changed ? 1 : 0;
|
|
206
|
+
this.activityVersion += changed ? 1 : 0;
|
|
207
|
+
return changed;
|
|
208
|
+
}
|
|
209
|
+
/** Message entries since the previous walk, in journal order; each append is visited once. */
|
|
210
|
+
private appended(): { appended: SessionMessageEntry[]; departed: boolean } {
|
|
211
|
+
const manager = this.session.sessionManager;
|
|
212
|
+
const appended: SessionMessageEntry[] = [];
|
|
213
|
+
let entry = manager.getLeafEntry();
|
|
214
|
+
const leaf = entry?.id ?? null;
|
|
215
|
+
while (entry !== undefined && entry.id !== this.watermark) {
|
|
216
|
+
if (entry.type === "message") {
|
|
217
|
+
appended.push(entry);
|
|
218
|
+
}
|
|
219
|
+
entry = entry.parentId === null ? undefined : manager.getEntry(entry.parentId);
|
|
220
|
+
}
|
|
221
|
+
// A leaf off the previous branch walked to the root once; its entries are not new.
|
|
222
|
+
const departed = entry === undefined && this.watermark !== null;
|
|
223
|
+
this.watermark = leaf;
|
|
224
|
+
return { appended: appended.reverse(), departed };
|
|
225
|
+
}
|
|
226
|
+
private publish({ id, message }: SessionMessageEntry, settle: boolean): void {
|
|
227
|
+
const call = message.role === "toolResult" ? message.toolCallId : "";
|
|
228
|
+
const tool = call.length > 0 && call.length <= NAME_CHARS ? call : null;
|
|
229
|
+
// A published result also retires the calls it made through ctx.executeTool().
|
|
230
|
+
const retired = (key: string): boolean =>
|
|
231
|
+
tool !== null && (key === tool || key.startsWith(`${tool}/`));
|
|
232
|
+
for (const live of [...this.tools.keys()].filter(retired)) {
|
|
233
|
+
this.tools.delete(live);
|
|
234
|
+
}
|
|
235
|
+
const key = this.message?.message === message ? this.message.key : null;
|
|
236
|
+
this.message = key === null ? this.message : undefined;
|
|
237
|
+
if (settle) {
|
|
238
|
+
const seq = ++this.settled;
|
|
239
|
+
this.settlements.push({ seq, json: JSON.stringify([seq, id, key, tool]) });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
dispose(): void {
|
|
243
|
+
this.closed = true;
|
|
244
|
+
}
|
|
245
|
+
readonly project = (value: unknown): string => {
|
|
246
|
+
this.projections++;
|
|
247
|
+
const result = boundedJson(value, VALUE_BUDGET);
|
|
248
|
+
if ("json" in result) {
|
|
249
|
+
return `{"json":${result.json}}`;
|
|
250
|
+
}
|
|
251
|
+
this.unavailable++;
|
|
252
|
+
return `{"unavailable":"${result.unavailable}"}`;
|
|
253
|
+
};
|
|
254
|
+
/** Sampled from public native state, so compact peers never need token-rate events. */
|
|
255
|
+
activity(busy: boolean): string {
|
|
256
|
+
const { session } = this;
|
|
257
|
+
const running = [...this.tools.values()].filter((tool) => tool.ended === undefined);
|
|
258
|
+
const streaming = session.state.streamingMessage;
|
|
259
|
+
const content = streaming?.role === "assistant" ? streaming.content : undefined;
|
|
260
|
+
const last = content?.at(-1)?.type;
|
|
261
|
+
const phases: Array<[boolean, string]> = [
|
|
262
|
+
[running.length > 0, "tool"],
|
|
263
|
+
[content !== undefined, last === undefined ? "responding" : WRITING[last]],
|
|
264
|
+
[session.isCompacting, "compacting"],
|
|
265
|
+
[session.isRetrying, "retrying"],
|
|
266
|
+
[busy || session.isStreaming, "working"],
|
|
267
|
+
];
|
|
268
|
+
const phase = phases.find(([active]) => active)?.[1] ?? "idle";
|
|
269
|
+
return JSON.stringify({
|
|
270
|
+
phase,
|
|
271
|
+
tools: running.slice(0, 3).map((tool) => tool.name.slice(0, 64)),
|
|
272
|
+
running: running.length,
|
|
273
|
+
model: this.session.model?.id,
|
|
274
|
+
thinking: this.session.thinkingLevel,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|