@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/dist/agent/process.js
CHANGED
|
@@ -4,28 +4,105 @@ import { spawn } from "node:child_process";
|
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
-
import { readChildProcessIdentity, trySignalChildTree } from "../shared/post-exit-stdio-guard.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
7
|
+
import { isChildProcessReplaced, observeChildProcessReplaced, readChildProcessIdentity, readProcessClock, trySignalChildTree, } from "../shared/post-exit-stdio-guard.js";
|
|
8
|
+
import { agentObservationSocketPath } from "../pi-intercom/broker/paths.js";
|
|
9
|
+
import { hasErrorCode } from "../shared/unknown.js";
|
|
10
|
+
import { attachmentSchema, processSchema } from "./contracts.js";
|
|
11
|
+
import { conversation, enqueue, launchAt, list, read, write } from "./storage.js";
|
|
12
|
+
import { errorText } from "./host.js";
|
|
13
|
+
/** Owner records follow their process's birth; stat after read only moves the bound later. */
|
|
14
|
+
export function published(file, record) {
|
|
15
|
+
return record && { ...record, published: fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs };
|
|
16
|
+
}
|
|
17
|
+
/** Settles liveness without a birth lookup; undefined means compare the recorded birth. */
|
|
18
|
+
function probe(identity) {
|
|
11
19
|
try {
|
|
12
20
|
process.kill(identity.pid, 0);
|
|
13
21
|
}
|
|
14
22
|
catch (error) {
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
// EPERM still names a live PID, possibly another user's reuse; check its birth below.
|
|
24
|
+
if (!hasErrorCode(error, "EPERM")) {
|
|
25
|
+
return !hasErrorCode(error, "ESRCH");
|
|
17
26
|
}
|
|
18
|
-
return true;
|
|
19
27
|
}
|
|
20
|
-
|
|
21
|
-
|
|
28
|
+
return identity.identity === "unverified" ? true : undefined;
|
|
29
|
+
}
|
|
30
|
+
export function alive(identity) {
|
|
31
|
+
// Only a proven different birth disproves a live PID; ambiguous identities stay possibly owned.
|
|
32
|
+
return (probe(identity) ??
|
|
33
|
+
(readChildProcessIdentity(identity.pid) === identity.identity || !replaced(identity)));
|
|
34
|
+
}
|
|
35
|
+
/** Routine `alive` without waiting on `ps`; signals still use the synchronous proof. */
|
|
36
|
+
export async function observeAlive(identities) {
|
|
37
|
+
const verdicts = [];
|
|
38
|
+
for (const claim of identities) {
|
|
39
|
+
// One `ps` at a time: each spawn briefly holds the event loop, so concurrent spawns add up.
|
|
40
|
+
const { pid, identity, clock } = claim;
|
|
41
|
+
verdicts.push(probe(claim) ??
|
|
42
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
43
|
+
!(await observeChildProcessReplaced(pid, identity, claim.published, clock)));
|
|
44
|
+
}
|
|
45
|
+
return verdicts;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A child's routine parent check, one observation at a time. A dead parent detaches the child
|
|
49
|
+
* only while its check is current and the attachment generation is unchanged: a newer parent
|
|
50
|
+
* may adopt the child meanwhile.
|
|
51
|
+
*/
|
|
52
|
+
export class ParentCheck {
|
|
53
|
+
current;
|
|
54
|
+
/**
|
|
55
|
+
* Reads the attachment synchronously, so an unreadable one still fails the caller at once.
|
|
56
|
+
* Missing or released attachments detach immediately; a check in flight makes this a no-op.
|
|
57
|
+
*/
|
|
58
|
+
check(file, detached) {
|
|
59
|
+
if (this.current !== undefined) {
|
|
60
|
+
return Promise.resolve();
|
|
61
|
+
}
|
|
62
|
+
const attachment = published(file, read(file, attachmentSchema));
|
|
63
|
+
if (attachment === undefined || !attachment.attached) {
|
|
64
|
+
detached();
|
|
65
|
+
return Promise.resolve();
|
|
66
|
+
}
|
|
67
|
+
const check = {};
|
|
68
|
+
this.current = check;
|
|
69
|
+
const settle = async () => {
|
|
70
|
+
try {
|
|
71
|
+
const [live] = await observeAlive([attachment]);
|
|
72
|
+
if (this.current === check &&
|
|
73
|
+
!live &&
|
|
74
|
+
read(file, attachmentSchema)?.generation === attachment.generation) {
|
|
75
|
+
detached();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
if (this.current === check) {
|
|
80
|
+
this.current = undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
return settle();
|
|
85
|
+
}
|
|
86
|
+
/** A check settling later no longer acts. */
|
|
87
|
+
cancel() {
|
|
88
|
+
this.current = undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function replaced(identity) {
|
|
92
|
+
return (identity.identity !== "unverified" &&
|
|
93
|
+
isChildProcessReplaced(identity.pid, identity.identity, identity.published, identity.clock));
|
|
22
94
|
}
|
|
23
|
-
export function ownIdentity() {
|
|
24
|
-
// An ordinary main need not be a group leader. Ambiguous live PIDs fail closed.
|
|
25
|
-
return {
|
|
95
|
+
export function ownIdentity(pid = process.pid) {
|
|
96
|
+
// An ordinary main or managed launcher need not be a group leader. Ambiguous live PIDs fail closed.
|
|
97
|
+
return {
|
|
98
|
+
pid,
|
|
99
|
+
identity: readChildProcessIdentity(pid) ?? "unverified",
|
|
100
|
+
clock: readProcessClock(),
|
|
101
|
+
};
|
|
26
102
|
}
|
|
27
103
|
export function actor(base, launch) {
|
|
28
|
-
|
|
104
|
+
const file = path.join(conversation(base, launch), "actor.lock", "process.json");
|
|
105
|
+
return published(file, read(file, processSchema));
|
|
29
106
|
}
|
|
30
107
|
function childEnvironment(launch) {
|
|
31
108
|
const env = { ...process.env };
|
|
@@ -73,12 +150,14 @@ function spawnActor(base, launch, token) {
|
|
|
73
150
|
pid: child.pid,
|
|
74
151
|
identity: readChildProcessIdentity(child.pid) ?? "unverified",
|
|
75
152
|
token,
|
|
153
|
+
clock: readProcessClock(),
|
|
76
154
|
});
|
|
77
155
|
}
|
|
78
|
-
async function awaitStopping(dir, existing) {
|
|
156
|
+
async function awaitStopping(dir, existing, signal) {
|
|
79
157
|
if (existing && fs.existsSync(path.join(dir, `stopping-${existing.token}.json`))) {
|
|
80
158
|
const deadline = Date.now() + 5000;
|
|
81
159
|
while (alive(existing)) {
|
|
160
|
+
signal?.throwIfAborted();
|
|
82
161
|
if (Date.now() > deadline) {
|
|
83
162
|
throw new Error("Previous actor is still stopping; no replacement was launched.");
|
|
84
163
|
}
|
|
@@ -88,10 +167,11 @@ async function awaitStopping(dir, existing) {
|
|
|
88
167
|
}
|
|
89
168
|
}
|
|
90
169
|
}
|
|
91
|
-
export async function ensureActor(base, launch) {
|
|
170
|
+
export async function ensureActor(base, launch, signal) {
|
|
92
171
|
const dir = conversation(base, launch);
|
|
93
172
|
const lock = path.join(dir, "actor.lock");
|
|
94
|
-
await awaitStopping(dir, actor(base, launch));
|
|
173
|
+
await awaitStopping(dir, actor(base, launch), signal);
|
|
174
|
+
signal?.throwIfAborted();
|
|
95
175
|
// Another parallel tool may replace the claim while this invocation yields.
|
|
96
176
|
const existing = actor(base, launch);
|
|
97
177
|
if (existing && alive(existing)) {
|
|
@@ -101,6 +181,7 @@ export async function ensureActor(base, launch) {
|
|
|
101
181
|
if (shells(base, launch, existing.token).some(alive)) {
|
|
102
182
|
throw new Error("Owned native shell work remains live; stop it before continuing.");
|
|
103
183
|
}
|
|
184
|
+
removeObservationEndpoint(existing.token);
|
|
104
185
|
fs.rmSync(lock, { recursive: true });
|
|
105
186
|
}
|
|
106
187
|
try {
|
|
@@ -118,12 +199,25 @@ export async function ensureActor(base, launch) {
|
|
|
118
199
|
write(path.join(lock, "claim.json"), { ...ownIdentity(), token });
|
|
119
200
|
spawnActor(base, launch, token);
|
|
120
201
|
}
|
|
121
|
-
|
|
202
|
+
/** A crashed generation cannot unlink its endpoint; its exact token names the leftover. */
|
|
203
|
+
function removeObservationEndpoint(token) {
|
|
204
|
+
try {
|
|
205
|
+
const socketPath = agentObservationSocketPath(token);
|
|
206
|
+
if (fs.lstatSync(socketPath, { throwIfNoEntry: false })?.isSocket() === true) {
|
|
207
|
+
fs.rmSync(socketPath, { force: true });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// Observation leftovers are inert to a new token and must never block native work.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function verifyLiveness(identity) {
|
|
122
215
|
try {
|
|
123
|
-
process.kill(pid, 0);
|
|
216
|
+
process.kill(identity.pid, 0);
|
|
124
217
|
}
|
|
125
218
|
catch (error) {
|
|
126
|
-
|
|
219
|
+
// Another user's live PID is released only by the same proof as a signalable one.
|
|
220
|
+
if (hasErrorCode(error, "ESRCH") || (hasErrorCode(error, "EPERM") && replaced(identity))) {
|
|
127
221
|
return false;
|
|
128
222
|
}
|
|
129
223
|
throw new Error("Actor liveness is ambiguous; no stop was sent.", { cause: error });
|
|
@@ -131,13 +225,19 @@ function verifyLiveness(pid) {
|
|
|
131
225
|
return true;
|
|
132
226
|
}
|
|
133
227
|
export function verifyProcess(identity) {
|
|
134
|
-
if (!verifyLiveness(identity
|
|
228
|
+
if (!verifyLiveness(identity)) {
|
|
135
229
|
return false;
|
|
136
230
|
}
|
|
137
231
|
const actual = readChildProcessIdentity(identity.pid);
|
|
138
|
-
if (identity.identity !== "unverified" && actual
|
|
139
|
-
|
|
140
|
-
|
|
232
|
+
if (identity.identity !== "unverified" && actual !== identity.identity) {
|
|
233
|
+
if (actual === undefined && !verifyLiveness(identity)) {
|
|
234
|
+
// Native abort can reap a shell between the liveness probe and birth lookup.
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
// A different-birth process at the PID proves the recorded process is gone.
|
|
238
|
+
if (replaced(identity)) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
141
241
|
}
|
|
142
242
|
if (identity.identity === "unverified" || actual !== identity.identity) {
|
|
143
243
|
throw new Error("Cannot verify actor process identity; no signal sent.");
|
|
@@ -150,13 +250,86 @@ export function signalProcess(identity, signal) {
|
|
|
150
250
|
}
|
|
151
251
|
return trySignalChildTree({ pid: identity.pid, kill: (s) => process.kill(identity.pid, s) }, signal);
|
|
152
252
|
}
|
|
253
|
+
export function requestStop(base, launch, onError) {
|
|
254
|
+
const attempt = (operation) => {
|
|
255
|
+
try {
|
|
256
|
+
return operation();
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
if (onError === undefined) {
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
onError(error);
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
const identity = actor(base, launch);
|
|
267
|
+
const targets = identity ? [identity, ...shells(base, launch, identity.token)] : [];
|
|
268
|
+
// Interactive Stop validates the whole set; shutdown reports and skips only ambiguous targets.
|
|
269
|
+
const live = targets.filter((target) => attempt(() => verifyProcess(target)));
|
|
270
|
+
const command = identity && live.includes(identity)
|
|
271
|
+
? enqueue(base, launch, "stop", { actorToken: identity.token })
|
|
272
|
+
: undefined;
|
|
273
|
+
if (identity && live.length > 0) {
|
|
274
|
+
write(path.join(conversation(base, launch), `stopping-${identity.token}.json`), {
|
|
275
|
+
at: Date.now(),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
live.forEach((target) => {
|
|
279
|
+
attempt(() => signalProcess(target, "SIGTERM"));
|
|
280
|
+
});
|
|
281
|
+
return { targets: live, command };
|
|
282
|
+
}
|
|
283
|
+
export async function stopChildren(base, owner) {
|
|
284
|
+
const roots = new Set();
|
|
285
|
+
const targets = [];
|
|
286
|
+
for (const id of list(base)) {
|
|
287
|
+
try {
|
|
288
|
+
const launch = launchAt(base, id, owner);
|
|
289
|
+
if (!roots.has(launch.rootId)) {
|
|
290
|
+
roots.add(launch.rootId);
|
|
291
|
+
targets.push(...requestStop(base, launch, (error) => {
|
|
292
|
+
console.error(`Child shutdown ${id}: ${errorText(error)}`);
|
|
293
|
+
}).targets);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
console.error(`Child shutdown ${id}: ${errorText(error)}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
await waitForExit(targets, 3000);
|
|
301
|
+
for (const target of targets) {
|
|
302
|
+
try {
|
|
303
|
+
signalProcess(target, "SIGKILL");
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
console.error(`Child shutdown ${target.pid}: ${errorText(error)}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
await waitForExit(targets, 1000);
|
|
310
|
+
if (targets.some(alive)) {
|
|
311
|
+
console.error("Child shutdown could not confirm every owned process exit.");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
async function waitForExit(targets, timeout) {
|
|
315
|
+
const deadline = Date.now() + timeout;
|
|
316
|
+
while (Date.now() < deadline && targets.some(alive)) {
|
|
317
|
+
// Join actual process exit while native abort persists the interrupted turn.
|
|
318
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
319
|
+
await delay(25);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
153
322
|
export function shells(base, launch, token) {
|
|
154
323
|
const dir = path.join(conversation(base, launch), "shells", token);
|
|
155
324
|
if (!fs.existsSync(dir)) {
|
|
156
325
|
return [];
|
|
157
326
|
}
|
|
158
|
-
return fs
|
|
159
|
-
|
|
327
|
+
return fs
|
|
328
|
+
.readdirSync(dir)
|
|
329
|
+
.filter((name) => name.endsWith(".json"))
|
|
330
|
+
.map((name) => {
|
|
331
|
+
const file = path.join(dir, name);
|
|
332
|
+
const identity = published(file, read(file, processSchema));
|
|
160
333
|
if (!identity || identity.token !== token) {
|
|
161
334
|
throw new Error("Foreign or missing native shell identity.");
|
|
162
335
|
}
|
package/dist/agent/receipts.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import { scanJournal } from "../shared/journal-frames.js";
|
|
3
|
-
import { journalStamp } from "../shared/native-journal.js";
|
|
3
|
+
import { journalStamp, nativeAppend } from "../shared/native-journal.js";
|
|
4
|
+
import { isRecord } from "../shared/unknown.js";
|
|
4
5
|
const detailFields = new Set([
|
|
5
6
|
"owner",
|
|
6
7
|
"id",
|
|
@@ -30,7 +31,7 @@ const receiptProjection = (keys) => {
|
|
|
30
31
|
if (keys.length === 0) {
|
|
31
32
|
return true;
|
|
32
33
|
}
|
|
33
|
-
if (["type", "id", "customType"].includes(String(keys[0]))) {
|
|
34
|
+
if (["type", "id", "customType", "version"].includes(String(keys[0]))) {
|
|
34
35
|
return keys.length === 1 ? 4096 : false;
|
|
35
36
|
}
|
|
36
37
|
if (keys[0] !== "message") {
|
|
@@ -44,32 +45,103 @@ const receiptProjection = (keys) => {
|
|
|
44
45
|
}
|
|
45
46
|
return detailsProjection(keys.slice(1));
|
|
46
47
|
};
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
const indexes = new Map();
|
|
49
|
+
function toolCompletion(message) {
|
|
50
|
+
return isRecord(message) &&
|
|
51
|
+
message.role === "toolResult" &&
|
|
52
|
+
(message.toolName === "delegate" || message.toolName === "agent_runs") &&
|
|
53
|
+
isRecord(message.details) &&
|
|
54
|
+
message.details.receivedResult === true
|
|
55
|
+
? message.details
|
|
56
|
+
: undefined;
|
|
57
|
+
}
|
|
58
|
+
function completionDetails(entry) {
|
|
59
|
+
if (entry.type === "custom_message" && entry.customType === "agent-completion") {
|
|
60
|
+
return entry.details;
|
|
61
|
+
}
|
|
62
|
+
return entry.type === "message" ? toolCompletion(entry.message) : undefined;
|
|
63
|
+
}
|
|
64
|
+
function addEntry(journal, entry) {
|
|
65
|
+
const header = journal.entries.at(0);
|
|
66
|
+
const owner = header?.type === "session" ? header.id : undefined;
|
|
67
|
+
const details = typeof owner === "string" ? completionDetails(entry) : undefined;
|
|
68
|
+
if (isRecord(details) &&
|
|
69
|
+
details.owner === owner &&
|
|
70
|
+
details.completed === true &&
|
|
71
|
+
typeof details.id === "string" &&
|
|
72
|
+
!journal.completions.has(details.id)) {
|
|
73
|
+
journal.completions.set(details.id, typeof entry.id === "string" ? entry.id : undefined);
|
|
74
|
+
}
|
|
75
|
+
journal.entries.push(entry);
|
|
76
|
+
}
|
|
77
|
+
function prefix(stat, cached) {
|
|
78
|
+
return cached && nativeAppend(cached.stat, stat, cached.entries[0])
|
|
79
|
+
? cached
|
|
80
|
+
: { end: 0, entries: [], completions: new Map() };
|
|
81
|
+
}
|
|
82
|
+
function index(file, observed) {
|
|
83
|
+
const cached = indexes.get(file);
|
|
84
|
+
if (cached?.stamp === journalStamp(observed)) {
|
|
85
|
+
return cached;
|
|
51
86
|
}
|
|
52
87
|
const fd = fs.openSync(file, "r");
|
|
53
|
-
let first = true;
|
|
54
|
-
let owned = false;
|
|
55
|
-
let found;
|
|
56
88
|
try {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}, {
|
|
89
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
90
|
+
const stamp = journalStamp(stat);
|
|
91
|
+
if (cached?.stamp === stamp) {
|
|
92
|
+
return cached;
|
|
93
|
+
}
|
|
94
|
+
const retained = prefix(stat, cached);
|
|
95
|
+
const appended = [];
|
|
96
|
+
const end = scanJournal(fd, receiptProjection, ({ value }) => {
|
|
97
|
+
appended.push(value);
|
|
98
|
+
}, {
|
|
99
|
+
policy: "live",
|
|
100
|
+
requireNewline: true,
|
|
101
|
+
start: retained.end,
|
|
102
|
+
});
|
|
67
103
|
if (journalStamp(fs.fstatSync(fd, { bigint: true })) !== stamp) {
|
|
68
104
|
throw new Error("Native receipt journal changed while reading.");
|
|
69
105
|
}
|
|
70
|
-
|
|
106
|
+
for (const entry of appended) {
|
|
107
|
+
addEntry(retained, entry);
|
|
108
|
+
}
|
|
109
|
+
const result = { ...retained, stamp, end, stat };
|
|
110
|
+
indexes.delete(file);
|
|
111
|
+
indexes.set(file, result);
|
|
112
|
+
if (indexes.size > 8) {
|
|
113
|
+
const oldest = indexes.keys().next().value;
|
|
114
|
+
if (oldest !== undefined) {
|
|
115
|
+
indexes.delete(oldest);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
indexes.delete(file);
|
|
122
|
+
throw error;
|
|
71
123
|
}
|
|
72
124
|
finally {
|
|
73
125
|
fs.closeSync(fd);
|
|
74
126
|
}
|
|
75
127
|
}
|
|
128
|
+
/** Physical LF publication owns delivery, including when native memory is stale. */
|
|
129
|
+
function owned(file, owner) {
|
|
130
|
+
const stat = file === undefined ? undefined : fs.statSync(file, { bigint: true, throwIfNoEntry: false });
|
|
131
|
+
if (file === undefined || stat === undefined) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const journal = index(file, stat);
|
|
135
|
+
const header = journal.entries.at(0);
|
|
136
|
+
return header?.type === "session" && header.id === owner ? journal : undefined;
|
|
137
|
+
}
|
|
138
|
+
export function receiptId(file, owner, matches) {
|
|
139
|
+
const found = owned(file, owner)?.entries.find((entry) => entry.type !== "session" && matches(entry));
|
|
140
|
+
return typeof found?.id === "string" ? found.id : undefined;
|
|
141
|
+
}
|
|
142
|
+
/** Live read-only completion IDs by run; later appends extend this view.
|
|
143
|
+
* A matching entry without an ID is unpublished. Invalidations replace the view.
|
|
144
|
+
*/
|
|
145
|
+
export function completionReceipts(file, owner) {
|
|
146
|
+
return owned(file, owner)?.completions ?? new Map();
|
|
147
|
+
}
|
package/dist/agent/runner.js
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { alive, ownIdentity, shellCommand } from "./process.js";
|
|
3
|
+
import { commandSchema } from "./contracts.js";
|
|
4
|
+
import { launchAt, missing, read, runDirectory, write } from "./storage.js";
|
|
5
|
+
import { ownIdentity, ParentCheck, shellCommand, shells, signalProcess } from "./process.js";
|
|
7
6
|
import { errorText, loadSDK } from "./host.js";
|
|
8
7
|
import { childUsage } from "./usage.js";
|
|
9
8
|
import { initializeCwd, localCwdOwner } from "./cwd.js";
|
|
9
|
+
import { ActorObservation } from "./observation.js";
|
|
10
|
+
import { DirectoryWatch, Wake } from "./wake.js";
|
|
11
|
+
function commandIdentity(file) {
|
|
12
|
+
try {
|
|
13
|
+
const stat = fs.statSync(file, { bigint: true });
|
|
14
|
+
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (missing(error)) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
10
23
|
function completionStatus(stopping, message, error) {
|
|
11
24
|
if (stopping || message?.stopReason === "aborted") {
|
|
12
25
|
return "stopped";
|
|
@@ -36,10 +49,20 @@ function guard(pi, directory, token) {
|
|
|
36
49
|
class Actor {
|
|
37
50
|
active;
|
|
38
51
|
stopping = false;
|
|
52
|
+
stopDeadline;
|
|
53
|
+
nextParentCheck = 0;
|
|
54
|
+
parent = new ParentCheck();
|
|
55
|
+
// Raised when durable commands may need admission: change hints and stop.
|
|
56
|
+
wake = new Wake();
|
|
57
|
+
// A deferred run waits for an idle session, which has no command-file event.
|
|
58
|
+
retry = false;
|
|
39
59
|
current;
|
|
40
60
|
errors = [];
|
|
41
61
|
started = new Set();
|
|
62
|
+
// Actor-local validation metadata only; never retain command prompts or receipt authority.
|
|
63
|
+
validated = new Map();
|
|
42
64
|
session;
|
|
65
|
+
observation;
|
|
43
66
|
context;
|
|
44
67
|
base;
|
|
45
68
|
initial;
|
|
@@ -133,6 +156,11 @@ class Actor {
|
|
|
133
156
|
pi.on("session_start", (_event, ctx) => {
|
|
134
157
|
this.context = { pi, ctx };
|
|
135
158
|
});
|
|
159
|
+
// Sparse tool lifecycle keeps the live view's tools even while nobody watches.
|
|
160
|
+
const tool = (event) => this.observation?.tool(event);
|
|
161
|
+
pi.on("tool_execution_start", tool);
|
|
162
|
+
pi.on("tool_execution_update", tool);
|
|
163
|
+
pi.on("tool_execution_end", tool);
|
|
136
164
|
},
|
|
137
165
|
],
|
|
138
166
|
},
|
|
@@ -158,12 +186,39 @@ class Actor {
|
|
|
158
186
|
};
|
|
159
187
|
}
|
|
160
188
|
stop = () => {
|
|
161
|
-
|
|
189
|
+
if (this.stopping) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
162
192
|
this.stopping = true;
|
|
193
|
+
this.wake.raise();
|
|
194
|
+
write(path.join(this.dir, `stopping-${this.token}.json`), { at: Date.now() });
|
|
195
|
+
write(path.join(this.dir, "stop-acknowledged.json"), { at: Date.now(), id: this.current?.id });
|
|
163
196
|
this.session?.abort().catch((error) => {
|
|
164
197
|
this.errors.push(errorText(error));
|
|
165
198
|
});
|
|
199
|
+
this.stopShells("SIGTERM");
|
|
200
|
+
// Parent death leaves no parent-side escalation timer to stop an uncooperative tool.
|
|
201
|
+
this.stopDeadline = setTimeout(() => {
|
|
202
|
+
this.stopShells("SIGKILL");
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}, 3000);
|
|
205
|
+
this.stopDeadline.unref();
|
|
166
206
|
};
|
|
207
|
+
stopShells(signal) {
|
|
208
|
+
try {
|
|
209
|
+
for (const identity of shells(this.base, this.initial, this.token)) {
|
|
210
|
+
try {
|
|
211
|
+
signalProcess(identity, signal);
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
this.errors.push(errorText(error));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
this.errors.push(errorText(error));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
167
222
|
run(launch) {
|
|
168
223
|
const session = this.session;
|
|
169
224
|
if (!session) {
|
|
@@ -171,6 +226,7 @@ class Actor {
|
|
|
171
226
|
}
|
|
172
227
|
this.current = launch;
|
|
173
228
|
const baseline = new Set(session.sessionManager.getEntries().map((entry) => entry.id));
|
|
229
|
+
this.observation?.working(true);
|
|
174
230
|
this.active = (async () => {
|
|
175
231
|
try {
|
|
176
232
|
if (launch.provider !== this.initial.provider ||
|
|
@@ -188,6 +244,7 @@ class Actor {
|
|
|
188
244
|
finally {
|
|
189
245
|
this.active = undefined;
|
|
190
246
|
this.current = undefined;
|
|
247
|
+
this.observation?.working(false);
|
|
191
248
|
}
|
|
192
249
|
})();
|
|
193
250
|
}
|
|
@@ -219,6 +276,7 @@ class Actor {
|
|
|
219
276
|
}
|
|
220
277
|
if (command.action === "run" &&
|
|
221
278
|
(this.stopping || this.active !== undefined || !session.isIdle)) {
|
|
279
|
+
this.retry = true;
|
|
222
280
|
return;
|
|
223
281
|
}
|
|
224
282
|
this.started.add(command.id);
|
|
@@ -263,6 +321,22 @@ class Actor {
|
|
|
263
321
|
timestamp: Date.now(),
|
|
264
322
|
});
|
|
265
323
|
}
|
|
324
|
+
pendingCommand(file) {
|
|
325
|
+
const identity = commandIdentity(file);
|
|
326
|
+
const cached = this.validated.get(file);
|
|
327
|
+
if (identity !== undefined &&
|
|
328
|
+
cached?.identity === identity &&
|
|
329
|
+
fs.existsSync(path.join(this.dir, "admissions", `${cached.id}.json`))) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
this.validated.delete(file);
|
|
333
|
+
const command = read(file, commandSchema);
|
|
334
|
+
// A replacement during validation must not inherit the old body's validated identity.
|
|
335
|
+
if (command && identity !== undefined && commandIdentity(file) === identity) {
|
|
336
|
+
this.validated.set(file, { identity, id: command.id });
|
|
337
|
+
}
|
|
338
|
+
return command;
|
|
339
|
+
}
|
|
266
340
|
pending() {
|
|
267
341
|
const dir = path.join(this.dir, "commands");
|
|
268
342
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -270,23 +344,47 @@ class Actor {
|
|
|
270
344
|
.readdirSync(dir)
|
|
271
345
|
.filter((file) => file.endsWith(".json"))
|
|
272
346
|
.flatMap((file) => {
|
|
273
|
-
const command =
|
|
347
|
+
const command = this.pendingCommand(path.join(dir, file));
|
|
274
348
|
return command ? [command] : [];
|
|
275
349
|
})
|
|
276
350
|
.sort((a, b) => a.createdAt - b.createdAt);
|
|
277
351
|
}
|
|
278
352
|
shouldExit() {
|
|
279
|
-
|
|
280
|
-
|
|
353
|
+
const now = performance.now();
|
|
354
|
+
if (!this.stopping && now >= this.nextParentCheck) {
|
|
355
|
+
this.nextParentCheck = now + 1000;
|
|
356
|
+
// One observation at a time; a slow one keeps the regular schedule.
|
|
357
|
+
const file = path.join(this.base, "attachment.json");
|
|
358
|
+
this.parent.check(file, this.stop).catch((error) => {
|
|
359
|
+
this.errors.push(errorText(error));
|
|
360
|
+
});
|
|
281
361
|
}
|
|
282
|
-
|
|
283
|
-
|
|
362
|
+
return this.stopping && this.active === undefined && (this.session?.isIdle ?? true);
|
|
363
|
+
}
|
|
364
|
+
/** Durable command readiness, not elapsed time, defines admission. */
|
|
365
|
+
async admitCommands() {
|
|
366
|
+
// Queued commands create this directory; otherwise pending() does and the next pass arms.
|
|
367
|
+
const directory = path.join(this.dir, "commands");
|
|
368
|
+
const commands = new DirectoryWatch(directory, () => true, this.wake.raise);
|
|
369
|
+
try {
|
|
370
|
+
while (!this.shouldExit()) {
|
|
371
|
+
// Each liveness tick also rechecks the directory for change hints the OS dropped.
|
|
372
|
+
const watch = commands.ensure();
|
|
373
|
+
if ((this.wake.take() || this.retry || watch !== "watching") && !this.stopping) {
|
|
374
|
+
this.retry = false;
|
|
375
|
+
this.pending().forEach((command) => this.accept(command));
|
|
376
|
+
}
|
|
377
|
+
const polling = this.retry || this.stopping || watch === "unwatched";
|
|
378
|
+
const tick = Math.max(1, this.nextParentCheck - performance.now());
|
|
379
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
380
|
+
await this.wake.sleep(polling ? 100 : tick);
|
|
381
|
+
}
|
|
284
382
|
}
|
|
285
|
-
|
|
286
|
-
|
|
383
|
+
finally {
|
|
384
|
+
// A verdict settling after the loop ends must not act.
|
|
385
|
+
this.parent.cancel();
|
|
386
|
+
commands.close();
|
|
287
387
|
}
|
|
288
|
-
const attachment = read(path.join(this.base, "attachment.json"), attachmentSchema);
|
|
289
|
-
return attachment === undefined || !attachment.attached || !alive(attachment);
|
|
290
388
|
}
|
|
291
389
|
failure(error) {
|
|
292
390
|
const launch = this.current ?? this.initial;
|
|
@@ -327,6 +425,12 @@ class Actor {
|
|
|
327
425
|
if (this.initial.name !== undefined) {
|
|
328
426
|
this.session.sessionManager.appendSessionInfo(this.initial.name);
|
|
329
427
|
}
|
|
428
|
+
// Optional live view, bound before readiness; its faults never change native execution.
|
|
429
|
+
this.observation = await ActorObservation.open(this.session, {
|
|
430
|
+
token: this.token,
|
|
431
|
+
rootId: this.initial.rootId,
|
|
432
|
+
sessionId: this.session.sessionManager.getSessionId(),
|
|
433
|
+
});
|
|
330
434
|
write(path.join(this.dir, "actor.lock", "process.json"), {
|
|
331
435
|
...ownIdentity(),
|
|
332
436
|
token: this.token,
|
|
@@ -339,12 +443,7 @@ class Actor {
|
|
|
339
443
|
});
|
|
340
444
|
process.on("SIGTERM", this.stop);
|
|
341
445
|
process.on("SIGINT", this.stop);
|
|
342
|
-
|
|
343
|
-
this.pending().forEach((command) => this.accept(command));
|
|
344
|
-
// Durable command readiness, not elapsed time, defines admission.
|
|
345
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
346
|
-
await delay(100);
|
|
347
|
-
} while (!this.shouldExit());
|
|
446
|
+
await this.admitCommands();
|
|
348
447
|
await this.active;
|
|
349
448
|
}
|
|
350
449
|
catch (error) {
|
|
@@ -353,10 +452,16 @@ class Actor {
|
|
|
353
452
|
finally {
|
|
354
453
|
process.off("SIGTERM", this.stop);
|
|
355
454
|
process.off("SIGINT", this.stop);
|
|
455
|
+
const observation = this.observation?.close();
|
|
356
456
|
await runtime?.dispose();
|
|
457
|
+
if (this.stopping) {
|
|
458
|
+
this.stopShells("SIGKILL");
|
|
459
|
+
}
|
|
460
|
+
clearTimeout(this.stopDeadline);
|
|
357
461
|
write(path.join(this.dir, `shutdown-${this.token}.json`), {
|
|
358
462
|
at: Date.now(),
|
|
359
463
|
errors: this.errors,
|
|
464
|
+
...(observation === undefined ? {} : { observation }),
|
|
360
465
|
});
|
|
361
466
|
// Keep the process claim until a launcher proves this exact actor is gone.
|
|
362
467
|
}
|