@fitchmultz/pi-subagents 0.50.0 → 0.52.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 +63 -2
- package/README.md +36 -5
- package/dist/agent/child-runtime.js +80 -0
- package/dist/agent/contracts.js +15 -24
- package/dist/agent/host.js +3 -16
- package/dist/agent/index.js +212 -192
- 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 +350 -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/parking.js +253 -0
- package/dist/agent/process.js +228 -28
- package/dist/agent/receipts.js +92 -20
- package/dist/agent/runner.js +229 -126
- package/dist/agent/shell.js +2 -1
- package/dist/agent/storage.js +47 -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 +62 -43
- package/dist/pi-intercom/broker/client-last.d.ts +24 -0
- package/dist/pi-intercom/broker/client-last.js +42 -5
- package/dist/pi-intercom/broker/client-park.d.ts +9 -0
- package/dist/pi-intercom/broker/client-park.js +21 -0
- package/dist/pi-intercom/broker/client.d.ts +59 -0
- package/dist/pi-intercom/broker/client.js +55 -29
- 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 +30 -0
- package/dist/pi-intercom/broker/last.js +87 -40
- package/dist/pi-intercom/broker/parked.js +107 -0
- 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 +20 -1
- 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 +71 -20
- 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 +22 -37
- package/dist/pi-intercom/index.js +41 -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/parking.js +12 -0
- 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 +5 -3
- package/docs/development.md +42 -3
- package/docs/intercom-bridge.md +9 -4
- package/docs/intercom.md +228 -7
- package/docs/performance.md +118 -14
- package/docs/reference.md +168 -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/child-runtime.ts +105 -0
- package/src/agent/contracts.ts +18 -34
- package/src/agent/host.ts +39 -20
- package/src/agent/index.ts +234 -207
- 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 +409 -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/parking.ts +341 -0
- package/src/agent/process.ts +280 -37
- package/src/agent/receipts.ts +114 -24
- package/src/agent/runner.ts +227 -149
- package/src/agent/shell.ts +2 -1
- package/src/agent/storage.ts +44 -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 +64 -53
- package/src/pi-intercom/broker/client-last.ts +65 -12
- package/src/pi-intercom/broker/client-park.ts +23 -0
- package/src/pi-intercom/broker/client.ts +63 -45
- 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 +131 -43
- package/src/pi-intercom/broker/parked.ts +130 -0
- 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 +27 -2
- package/src/pi-intercom/connection.ts +103 -39
- 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 +32 -49
- package/src/pi-intercom/index.ts +44 -0
- package/src/pi-intercom/last-message.ts +11 -20
- 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/parking.ts +22 -0
- package/src/shared/post-exit-stdio-guard.ts +165 -7
- package/src/shared/tui-action.ts +39 -0
- package/tsconfig.client.json +8 -0
- package/tsconfig.json +1 -0
- package/src/shared/stream-json.d.ts +0 -10
package/src/agent/runner.ts
CHANGED
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import type {
|
|
5
|
-
AgentSession,
|
|
6
|
-
CreateAgentSessionRuntimeFactory,
|
|
7
|
-
ExtensionAPI,
|
|
8
|
-
ExtensionContext,
|
|
9
|
-
} from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
4
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
type Command,
|
|
15
|
-
type Launch,
|
|
16
|
-
type Result,
|
|
17
|
-
} from "./contracts.ts";
|
|
18
|
-
import { launchAt, read, runDirectory, write } from "./storage.ts";
|
|
19
|
-
import { alive, ownIdentity, shellCommand } from "./process.ts";
|
|
5
|
+
import { commandSchema, type Command, type Launch, type Result } from "./contracts.ts";
|
|
6
|
+
import { launchAt, missing, read, runDirectory, write } from "./storage.ts";
|
|
7
|
+
import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.ts";
|
|
20
8
|
import { errorText, loadSDK } from "./host.ts";
|
|
9
|
+
import { Parking, type Runtime } from "./parking.ts";
|
|
21
10
|
import { childUsage } from "./usage.ts";
|
|
22
|
-
import { initializeCwd
|
|
11
|
+
import { initializeCwd } from "./cwd.ts";
|
|
12
|
+
import { childRuntime } from "./child-runtime.ts";
|
|
13
|
+
import { ActorObservation, type ObservationStats } from "./observation.ts";
|
|
14
|
+
import { DirectoryWatch, Wake } from "./wake.ts";
|
|
23
15
|
|
|
16
|
+
function commandIdentity(file: string): string | undefined {
|
|
17
|
+
try {
|
|
18
|
+
const stat = fs.statSync(file, { bigint: true });
|
|
19
|
+
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (missing(error)) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
24
27
|
function completionStatus(
|
|
25
28
|
stopping: boolean,
|
|
26
29
|
message: AssistantMessage | undefined,
|
|
@@ -34,32 +37,24 @@ function completionStatus(
|
|
|
34
37
|
}
|
|
35
38
|
return "completed";
|
|
36
39
|
}
|
|
37
|
-
function guard(pi: ExtensionAPI, directory: string, token: string): void {
|
|
38
|
-
pi.on("tool_call", (event) => {
|
|
39
|
-
if (event.toolName === "delegate" || event.toolName === "agent_runs") {
|
|
40
|
-
return { block: true, reason: "Only a main session may delegate or control children." };
|
|
41
|
-
}
|
|
42
|
-
const owner = pi.getAllTools().find((tool) => tool.name === "bash")?.sourceInfo;
|
|
43
|
-
if (
|
|
44
|
-
event.toolName === "bash" &&
|
|
45
|
-
typeof event.input.command === "string" &&
|
|
46
|
-
owner &&
|
|
47
|
-
(owner.source === "builtin" || localCwdOwner(owner))
|
|
48
|
-
) {
|
|
49
|
-
// Preserve the selected local owner's cwd/env and leave remote/custom tools alone.
|
|
50
|
-
const input = event.input;
|
|
51
|
-
input.command = shellCommand(event.input.command, directory, token);
|
|
52
|
-
}
|
|
53
|
-
return;
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
40
|
class Actor {
|
|
57
41
|
private active?: Promise<void>;
|
|
58
42
|
private stopping = false;
|
|
43
|
+
private stopDeadline?: NodeJS.Timeout;
|
|
44
|
+
private nextParentCheck = 0;
|
|
45
|
+
private parking = false;
|
|
46
|
+
private readonly parent = new ParentCheck();
|
|
47
|
+
// Raised when durable commands may need admission: change hints and stop.
|
|
48
|
+
private readonly wake = new Wake();
|
|
49
|
+
// A deferred run waits for an idle session, which has no command-file event.
|
|
50
|
+
private retry = false;
|
|
59
51
|
private current?: Launch;
|
|
60
52
|
private readonly errors: string[] = [];
|
|
61
53
|
private readonly started = new Set<string>();
|
|
54
|
+
// Actor-local validation metadata only; never retain command prompts or receipt authority.
|
|
55
|
+
private readonly validated = new Map<string, { identity: string; id: string }>();
|
|
62
56
|
private session?: AgentSession;
|
|
57
|
+
private observation?: ActorObservation;
|
|
63
58
|
private context?: { pi: ExtensionAPI; ctx: ExtensionContext };
|
|
64
59
|
private readonly base: string;
|
|
65
60
|
private readonly initial: Launch;
|
|
@@ -134,69 +129,38 @@ class Actor {
|
|
|
134
129
|
.join("\n") ?? ""
|
|
135
130
|
);
|
|
136
131
|
}
|
|
137
|
-
private createRuntime(
|
|
138
|
-
sdk: Awaited<ReturnType<typeof loadSDK>>,
|
|
139
|
-
): CreateAgentSessionRuntimeFactory {
|
|
140
|
-
return async ({ sessionManager }) => {
|
|
141
|
-
const launch = this.initial;
|
|
142
|
-
if (!fs.existsSync(launch.sessionFile)) {
|
|
143
|
-
throw new Error("Missing saved native session; refusing to create another.");
|
|
144
|
-
}
|
|
145
|
-
const settingsManager = sdk.SettingsManager.create(launch.cwd, launch.agentDir, {
|
|
146
|
-
projectTrusted: launch.trusted,
|
|
147
|
-
});
|
|
148
|
-
const services = await sdk.createAgentSessionServices({
|
|
149
|
-
cwd: launch.cwd,
|
|
150
|
-
agentDir: launch.agentDir,
|
|
151
|
-
settingsManager,
|
|
152
|
-
resourceLoaderOptions: {
|
|
153
|
-
additionalExtensionPaths: [launch.entry],
|
|
154
|
-
extensionFactories: [
|
|
155
|
-
{
|
|
156
|
-
name: "codemode",
|
|
157
|
-
builtin: true,
|
|
158
|
-
factory: sdk.createCodemodeExtension({ mode: "on" }),
|
|
159
|
-
},
|
|
160
|
-
{ name: "tool_search", builtin: true, factory: sdk.createToolSearchExtension() },
|
|
161
|
-
{ name: "mcp", builtin: true, factory: sdk.createMcpExtension() },
|
|
162
|
-
(pi) => {
|
|
163
|
-
guard(pi, this.dir, this.token);
|
|
164
|
-
pi.on("session_start", (_event, ctx) => {
|
|
165
|
-
this.context = { pi, ctx };
|
|
166
|
-
});
|
|
167
|
-
},
|
|
168
|
-
],
|
|
169
|
-
},
|
|
170
|
-
});
|
|
171
|
-
const diagnostics = services.resourceLoader.getExtensions().errors;
|
|
172
|
-
if (diagnostics.length > 0) {
|
|
173
|
-
throw new Error(`Child extension load failed: ${JSON.stringify(diagnostics)}`);
|
|
174
|
-
}
|
|
175
|
-
const model = services.modelRuntime.getModel(launch.provider, launch.model);
|
|
176
|
-
if (!model) {
|
|
177
|
-
throw new Error(
|
|
178
|
-
`Saved model ${launch.provider}/${launch.model} is unavailable; no fallback was used.`,
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
return {
|
|
182
|
-
...(await sdk.createAgentSessionFromServices({
|
|
183
|
-
services,
|
|
184
|
-
sessionManager,
|
|
185
|
-
model,
|
|
186
|
-
thinkingLevel: launch.thinking,
|
|
187
|
-
})),
|
|
188
|
-
services,
|
|
189
|
-
diagnostics: services.diagnostics,
|
|
190
|
-
};
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
132
|
private readonly stop = (): void => {
|
|
194
|
-
|
|
133
|
+
if (this.stopping) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
195
136
|
this.stopping = true;
|
|
137
|
+
this.wake.raise();
|
|
138
|
+
write(path.join(this.dir, `stopping-${this.token}.json`), { at: Date.now() });
|
|
139
|
+
write(path.join(this.dir, "stop-acknowledged.json"), { at: Date.now(), id: this.current?.id });
|
|
196
140
|
this.session?.abort().catch((error: unknown) => {
|
|
197
141
|
this.errors.push(errorText(error));
|
|
198
142
|
});
|
|
143
|
+
this.stopShells("SIGTERM");
|
|
144
|
+
// Parent death leaves no parent-side escalation timer to stop an uncooperative tool.
|
|
145
|
+
this.stopDeadline = setTimeout(() => {
|
|
146
|
+
this.stopShells("SIGKILL");
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}, 3000);
|
|
149
|
+
this.stopDeadline.unref();
|
|
199
150
|
};
|
|
151
|
+
private stopShells(signal: NodeJS.Signals): void {
|
|
152
|
+
try {
|
|
153
|
+
for (const identity of shells(this.base, this.initial, this.token)) {
|
|
154
|
+
try {
|
|
155
|
+
signalProcess(identity, signal);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
this.errors.push(errorText(error));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch (error) {
|
|
161
|
+
this.errors.push(errorText(error));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
200
164
|
private run(launch: Launch): void {
|
|
201
165
|
const session = this.session;
|
|
202
166
|
if (!session) {
|
|
@@ -204,6 +168,7 @@ class Actor {
|
|
|
204
168
|
}
|
|
205
169
|
this.current = launch;
|
|
206
170
|
const baseline = new Set(session.sessionManager.getEntries().map((entry) => entry.id));
|
|
171
|
+
this.observation?.working(true);
|
|
207
172
|
this.active = (async () => {
|
|
208
173
|
try {
|
|
209
174
|
if (
|
|
@@ -221,6 +186,7 @@ class Actor {
|
|
|
221
186
|
} finally {
|
|
222
187
|
this.active = undefined;
|
|
223
188
|
this.current = undefined;
|
|
189
|
+
this.observation?.working(false);
|
|
224
190
|
}
|
|
225
191
|
})();
|
|
226
192
|
}
|
|
@@ -256,6 +222,7 @@ class Actor {
|
|
|
256
222
|
command.action === "run" &&
|
|
257
223
|
(this.stopping || this.active !== undefined || !session.isIdle)
|
|
258
224
|
) {
|
|
225
|
+
this.retry = true;
|
|
259
226
|
return;
|
|
260
227
|
}
|
|
261
228
|
this.started.add(command.id);
|
|
@@ -306,6 +273,24 @@ class Actor {
|
|
|
306
273
|
timestamp: Date.now(),
|
|
307
274
|
});
|
|
308
275
|
}
|
|
276
|
+
private pendingCommand(file: string): Command | undefined {
|
|
277
|
+
const identity = commandIdentity(file);
|
|
278
|
+
const cached = this.validated.get(file);
|
|
279
|
+
if (
|
|
280
|
+
identity !== undefined &&
|
|
281
|
+
cached?.identity === identity &&
|
|
282
|
+
fs.existsSync(path.join(this.dir, "admissions", `${cached.id}.json`))
|
|
283
|
+
) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
this.validated.delete(file);
|
|
287
|
+
const command = read(file, commandSchema);
|
|
288
|
+
// A replacement during validation must not inherit the old body's validated identity.
|
|
289
|
+
if (command && identity !== undefined && commandIdentity(file) === identity) {
|
|
290
|
+
this.validated.set(file, { identity, id: command.id });
|
|
291
|
+
}
|
|
292
|
+
return command;
|
|
293
|
+
}
|
|
309
294
|
private pending(): Command[] {
|
|
310
295
|
const dir = path.join(this.dir, "commands");
|
|
311
296
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -313,23 +298,79 @@ class Actor {
|
|
|
313
298
|
.readdirSync(dir)
|
|
314
299
|
.filter((file) => file.endsWith(".json"))
|
|
315
300
|
.flatMap((file) => {
|
|
316
|
-
const command =
|
|
301
|
+
const command = this.pendingCommand(path.join(dir, file));
|
|
317
302
|
return command ? [command] : [];
|
|
318
303
|
})
|
|
319
304
|
.sort((a, b) => a.createdAt - b.createdAt);
|
|
320
305
|
}
|
|
321
306
|
private shouldExit(): boolean {
|
|
322
|
-
|
|
323
|
-
|
|
307
|
+
const now = performance.now();
|
|
308
|
+
if (!this.stopping && now >= this.nextParentCheck) {
|
|
309
|
+
this.nextParentCheck = now + 1000;
|
|
310
|
+
// One observation at a time; a slow one keeps the regular schedule.
|
|
311
|
+
const file = path.join(this.base, "attachment.json");
|
|
312
|
+
this.parent.check(file, this.stop).catch((error: unknown) => {
|
|
313
|
+
this.errors.push(errorText(error));
|
|
314
|
+
});
|
|
324
315
|
}
|
|
325
|
-
|
|
326
|
-
|
|
316
|
+
return this.stopping && this.active === undefined && (this.session?.isIdle ?? true);
|
|
317
|
+
}
|
|
318
|
+
/** Durable command readiness, not elapsed time, defines admission. */
|
|
319
|
+
private async admitCommands(runtime: Runtime, parked: Parking): Promise<void> {
|
|
320
|
+
// Queued commands create this directory; otherwise pending() does and the next pass arms.
|
|
321
|
+
const directory = path.join(this.dir, "commands");
|
|
322
|
+
const commands = new DirectoryWatch(directory, () => true, this.wake.raise);
|
|
323
|
+
try {
|
|
324
|
+
while (!this.shouldExit()) {
|
|
325
|
+
// Each liveness tick also rechecks the directory for change hints the OS dropped.
|
|
326
|
+
const watch = commands.ensure();
|
|
327
|
+
if ((this.wake.take() || this.retry || watch !== "watching") && !this.stopping) {
|
|
328
|
+
this.retry = false;
|
|
329
|
+
this.pending().forEach((command) => this.accept(command));
|
|
330
|
+
}
|
|
331
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
332
|
+
if (await this.park(runtime, parked)) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const polling = this.retry || this.stopping || watch === "unwatched";
|
|
336
|
+
const tick = Math.max(1, this.nextParentCheck - performance.now());
|
|
337
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
338
|
+
await this.wake.sleep(polling ? 100 : tick);
|
|
339
|
+
}
|
|
340
|
+
} finally {
|
|
341
|
+
// A verdict settling after the loop ends must not act.
|
|
342
|
+
this.parent.cancel();
|
|
343
|
+
commands.close();
|
|
327
344
|
}
|
|
328
|
-
|
|
329
|
-
|
|
345
|
+
}
|
|
346
|
+
/** Parks a final-idle actor; the parent/stop owner stays live while preparation awaits. */
|
|
347
|
+
private async park(runtime: Runtime, parked: Parking): Promise<boolean> {
|
|
348
|
+
const owner = new AbortController();
|
|
349
|
+
const watch = setInterval(() => {
|
|
350
|
+
if (this.shouldExit() || this.stopping) {
|
|
351
|
+
owner.abort();
|
|
352
|
+
}
|
|
353
|
+
}, 100);
|
|
354
|
+
try {
|
|
355
|
+
this.parking = await parked.park(runtime, {
|
|
356
|
+
signal: owner.signal,
|
|
357
|
+
pi: this.context?.pi,
|
|
358
|
+
busy: this.stopping || this.active !== undefined || this.observation?.watched === true,
|
|
359
|
+
// Raced commands and viewers keep this actor resident; the loop admits after release.
|
|
360
|
+
quiet: () => {
|
|
361
|
+
const queued = this.pending().some((command) => this.commandLaunch(command));
|
|
362
|
+
if (queued) {
|
|
363
|
+
this.wake.raise();
|
|
364
|
+
}
|
|
365
|
+
const idle = this.active === undefined && !this.stopping;
|
|
366
|
+
return !queued && idle && this.observation?.watched !== true;
|
|
367
|
+
},
|
|
368
|
+
errors: this.errors,
|
|
369
|
+
});
|
|
370
|
+
} finally {
|
|
371
|
+
clearInterval(watch);
|
|
330
372
|
}
|
|
331
|
-
|
|
332
|
-
return attachment === undefined || !attachment.attached || !alive(attachment);
|
|
373
|
+
return this.parking;
|
|
333
374
|
}
|
|
334
375
|
private failure(error: unknown): void {
|
|
335
376
|
const launch = this.current ?? this.initial;
|
|
@@ -347,64 +388,101 @@ class Actor {
|
|
|
347
388
|
}
|
|
348
389
|
process.exitCode = 1;
|
|
349
390
|
}
|
|
391
|
+
private async start(sdk: Awaited<ReturnType<typeof loadSDK>>, parked: Parking): Promise<Runtime> {
|
|
392
|
+
const sessionManager = parked.open();
|
|
393
|
+
const factory = childRuntime(sdk, this.initial, {
|
|
394
|
+
directory: this.dir,
|
|
395
|
+
token: this.token,
|
|
396
|
+
saved: parked.saved,
|
|
397
|
+
started: (pi, ctx) => {
|
|
398
|
+
this.context = { pi, ctx };
|
|
399
|
+
},
|
|
400
|
+
tool: (event) => this.observation?.tool(event),
|
|
401
|
+
});
|
|
402
|
+
const runtime = await sdk.createAgentSessionRuntime(factory, {
|
|
403
|
+
cwd: this.initial.cwd,
|
|
404
|
+
agentDir: this.initial.agentDir,
|
|
405
|
+
sessionManager,
|
|
406
|
+
});
|
|
407
|
+
try {
|
|
408
|
+
await this.ready(runtime, parked);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
// A runtime that never became ready is still disposed, so the failed actor exits.
|
|
411
|
+
await runtime.dispose();
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
return runtime;
|
|
415
|
+
}
|
|
416
|
+
private async ready(runtime: Runtime, parked: Parking): Promise<void> {
|
|
417
|
+
this.session = runtime.session;
|
|
418
|
+
await this.session.bindExtensions({
|
|
419
|
+
mode: "print",
|
|
420
|
+
onError: (error) => {
|
|
421
|
+
this.errors.push(error.error);
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
if (!this.context) {
|
|
425
|
+
throw new Error("Child extension context is unavailable.");
|
|
426
|
+
}
|
|
427
|
+
initializeCwd(this.context.pi, this.context.ctx, this.initial.cwd, this.dir);
|
|
428
|
+
if (this.initial.name !== undefined && !parked.saved) {
|
|
429
|
+
this.session.sessionManager.appendSessionInfo(this.initial.name);
|
|
430
|
+
}
|
|
431
|
+
// Optional live view, bound before readiness; its faults never change native execution.
|
|
432
|
+
this.observation = await ActorObservation.open(this.session, {
|
|
433
|
+
token: this.token,
|
|
434
|
+
rootId: this.initial.rootId,
|
|
435
|
+
sessionId: this.session.sessionManager.getSessionId(),
|
|
436
|
+
});
|
|
437
|
+
write(path.join(this.dir, "actor.lock", "process.json"), {
|
|
438
|
+
...ownIdentity(),
|
|
439
|
+
token: this.token,
|
|
440
|
+
});
|
|
441
|
+
write(path.join(this.dir, "ready.json"), {
|
|
442
|
+
pid: process.pid,
|
|
443
|
+
token: this.token,
|
|
444
|
+
sessionId: this.session.sessionManager.getSessionId(),
|
|
445
|
+
at: Date.now(),
|
|
446
|
+
});
|
|
447
|
+
parked.adopt();
|
|
448
|
+
}
|
|
350
449
|
async serve(): Promise<void> {
|
|
351
450
|
const sdk = await loadSDK(this.initial.sdk);
|
|
352
|
-
|
|
451
|
+
const parked = new Parking(this.base, this.initial, this.token, sdk);
|
|
452
|
+
let runtime: Runtime | undefined;
|
|
353
453
|
try {
|
|
354
|
-
runtime = await
|
|
355
|
-
cwd: this.initial.cwd,
|
|
356
|
-
agentDir: this.initial.agentDir,
|
|
357
|
-
sessionManager: sdk.SessionManager.open(
|
|
358
|
-
this.initial.sessionFile,
|
|
359
|
-
undefined,
|
|
360
|
-
this.initial.cwd,
|
|
361
|
-
),
|
|
362
|
-
});
|
|
363
|
-
this.session = runtime.session;
|
|
364
|
-
await this.session.bindExtensions({
|
|
365
|
-
mode: "print",
|
|
366
|
-
onError: (error) => {
|
|
367
|
-
this.errors.push(error.error);
|
|
368
|
-
},
|
|
369
|
-
});
|
|
370
|
-
if (!this.context) {
|
|
371
|
-
throw new Error("Child extension context is unavailable.");
|
|
372
|
-
}
|
|
373
|
-
initializeCwd(this.context.pi, this.context.ctx, this.initial.cwd, this.dir);
|
|
374
|
-
if (this.initial.name !== undefined) {
|
|
375
|
-
this.session.sessionManager.appendSessionInfo(this.initial.name);
|
|
376
|
-
}
|
|
377
|
-
write(path.join(this.dir, "actor.lock", "process.json"), {
|
|
378
|
-
...ownIdentity(),
|
|
379
|
-
token: this.token,
|
|
380
|
-
});
|
|
381
|
-
write(path.join(this.dir, "ready.json"), {
|
|
382
|
-
pid: process.pid,
|
|
383
|
-
token: this.token,
|
|
384
|
-
sessionId: this.session.sessionManager.getSessionId(),
|
|
385
|
-
at: Date.now(),
|
|
386
|
-
});
|
|
454
|
+
runtime = await this.start(sdk, parked);
|
|
387
455
|
process.on("SIGTERM", this.stop);
|
|
388
456
|
process.on("SIGINT", this.stop);
|
|
389
|
-
|
|
390
|
-
this.pending().forEach((command) => this.accept(command));
|
|
391
|
-
// Durable command readiness, not elapsed time, defines admission.
|
|
392
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
393
|
-
await delay(100);
|
|
394
|
-
} while (!this.shouldExit());
|
|
457
|
+
await this.admitCommands(runtime, parked);
|
|
395
458
|
await this.active;
|
|
396
459
|
} catch (error) {
|
|
397
460
|
this.failure(error);
|
|
398
461
|
} finally {
|
|
399
|
-
|
|
400
|
-
|
|
462
|
+
await this.finish(runtime);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
private async finish(runtime: Runtime | undefined): Promise<void> {
|
|
466
|
+
process.off("SIGTERM", this.stop);
|
|
467
|
+
process.off("SIGINT", this.stop);
|
|
468
|
+
const observation: ObservationStats | undefined = this.observation?.close();
|
|
469
|
+
if (!this.parking) {
|
|
401
470
|
await runtime?.dispose();
|
|
402
|
-
write(path.join(this.dir, `shutdown-${this.token}.json`), {
|
|
403
|
-
at: Date.now(),
|
|
404
|
-
errors: this.errors,
|
|
405
|
-
});
|
|
406
|
-
// Keep the process claim until a launcher proves this exact actor is gone.
|
|
407
471
|
}
|
|
472
|
+
if (this.stopping) {
|
|
473
|
+
this.stopShells("SIGKILL");
|
|
474
|
+
}
|
|
475
|
+
clearTimeout(this.stopDeadline);
|
|
476
|
+
write(path.join(this.dir, `shutdown-${this.token}.json`), {
|
|
477
|
+
at: Date.now(),
|
|
478
|
+
errors: this.errors,
|
|
479
|
+
...(observation === undefined ? {} : { observation }),
|
|
480
|
+
});
|
|
481
|
+
if (this.parking) {
|
|
482
|
+
// Parked authority requires actual exit; no leftover extension handle may keep it alive.
|
|
483
|
+
process.exit();
|
|
484
|
+
}
|
|
485
|
+
// Keep the process claim until a launcher proves this exact actor is gone.
|
|
408
486
|
}
|
|
409
487
|
}
|
|
410
488
|
const base = process.argv.at(2);
|
package/src/agent/shell.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
|
-
import { readChildProcessIdentity } from "../shared/post-exit-stdio-guard.ts";
|
|
5
|
+
import { readChildProcessIdentity, readProcessClock } from "../shared/post-exit-stdio-guard.ts";
|
|
6
6
|
import { read, write } from "./storage.ts";
|
|
7
7
|
import { processSchema } from "./contracts.ts";
|
|
8
8
|
import { verifyProcess } from "./process.ts";
|
|
@@ -50,6 +50,7 @@ write(
|
|
|
50
50
|
pid,
|
|
51
51
|
identity,
|
|
52
52
|
token,
|
|
53
|
+
clock: readProcessClock(),
|
|
53
54
|
},
|
|
54
55
|
true,
|
|
55
56
|
);
|
package/src/agent/storage.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Projection } from "../shared/json-projection.ts";
|
|
|
6
6
|
import { Assert } from "./typebox.ts";
|
|
7
7
|
import type { Static, TSchema } from "typebox";
|
|
8
8
|
import { launchSchema, successorSchema, type Launch, type Command } from "./contracts.ts";
|
|
9
|
+
import { hasErrorCode } from "../shared/unknown.ts";
|
|
9
10
|
|
|
10
11
|
export function missing(error: unknown): boolean {
|
|
11
12
|
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
@@ -25,28 +26,43 @@ export function read<T extends TSchema>(
|
|
|
25
26
|
throw error;
|
|
26
27
|
}
|
|
27
28
|
Assert(schema, value);
|
|
28
|
-
// TypeBox's generic Static<T> is intentionally the asserted schema's type.
|
|
29
|
-
// oxlint-disable-next-line typescript/no-unsafe-return
|
|
30
29
|
return value;
|
|
31
30
|
}
|
|
31
|
+
/** Publish complete bytes without replacing a winner, even where Android denies hard links. */
|
|
32
|
+
function publishExclusive(temp: string, file: string): boolean {
|
|
33
|
+
try {
|
|
34
|
+
fs.linkSync(temp, file);
|
|
35
|
+
return false;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (!["EACCES", "EPERM", "ENOTSUP"].some((code) => hasErrorCode(error, code))) {
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
// The immutable, private backing file must outlive its atomic no-clobber symlink.
|
|
41
|
+
fs.symlinkSync(path.basename(temp), file);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
32
45
|
export function write(file: string, value: unknown, exclusive = false): void {
|
|
33
46
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
34
|
-
const temp = `${file}.${randomUUID()}
|
|
47
|
+
const temp = `${file}.${randomUUID()}.${exclusive ? "data" : "tmp"}`;
|
|
35
48
|
const fd = fs.openSync(temp, "wx", 0o600);
|
|
49
|
+
let retained = false;
|
|
36
50
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
try {
|
|
52
|
+
fs.writeFileSync(fd, `${JSON.stringify(value)}\n`);
|
|
53
|
+
fs.fsyncSync(fd);
|
|
54
|
+
} finally {
|
|
55
|
+
fs.closeSync(fd);
|
|
56
|
+
}
|
|
43
57
|
if (exclusive) {
|
|
44
|
-
|
|
58
|
+
retained = publishExclusive(temp, file);
|
|
45
59
|
} else {
|
|
46
60
|
fs.renameSync(temp, file);
|
|
47
61
|
}
|
|
48
62
|
} finally {
|
|
49
|
-
|
|
63
|
+
if (!retained) {
|
|
64
|
+
fs.rmSync(temp, { force: true });
|
|
65
|
+
}
|
|
50
66
|
}
|
|
51
67
|
}
|
|
52
68
|
export function safeId(id: string): string {
|
|
@@ -68,6 +84,23 @@ export function launchAt(base: string, id: string, owner: string): Launch {
|
|
|
68
84
|
}
|
|
69
85
|
return launch;
|
|
70
86
|
}
|
|
87
|
+
/** Claims the single successor of a saved run; undefined when another caller already has. */
|
|
88
|
+
export function successor(base: string, launch: Launch, prompt: string): Launch | undefined {
|
|
89
|
+
const id = randomUUID();
|
|
90
|
+
const next: Launch = { ...launch, id, predecessor: launch.id, prompt, createdAt: Date.now() };
|
|
91
|
+
fs.mkdirSync(runDirectory(base, id), { mode: 0o700 });
|
|
92
|
+
write(path.join(runDirectory(base, id), "launch.json"), next, true);
|
|
93
|
+
try {
|
|
94
|
+
write(path.join(runDirectory(base, launch.id), "successor.json"), { id }, true);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
fs.rmSync(runDirectory(base, id), { recursive: true });
|
|
97
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
return next;
|
|
103
|
+
}
|
|
71
104
|
export function latest(base: string, initial: Launch): Launch {
|
|
72
105
|
const seen = new Set<string>();
|
|
73
106
|
let launch = initial;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Static } from "typebox";
|
|
2
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
3
|
+
import { Type } from "./typebox.ts";
|
|
4
|
+
|
|
5
|
+
// Main-session tool parameters only; the detached runner must not load pi-ai values.
|
|
6
|
+
export const delegateSchema = Type.Object(
|
|
7
|
+
{
|
|
8
|
+
prompt: Type.String({ minLength: 1 }),
|
|
9
|
+
name: Type.Optional(Type.String({ minLength: 1 })),
|
|
10
|
+
cwd: Type.Optional(Type.String({ minLength: 1 })),
|
|
11
|
+
model: Type.Optional(
|
|
12
|
+
Type.String({
|
|
13
|
+
minLength: 1,
|
|
14
|
+
description:
|
|
15
|
+
"Exact provider/model ID (:thinking and any case need host support); no fuzzy match or fallback.",
|
|
16
|
+
}),
|
|
17
|
+
),
|
|
18
|
+
context: Type.Optional(StringEnum(["fresh", "fork"] as const)),
|
|
19
|
+
},
|
|
20
|
+
{ additionalProperties: false },
|
|
21
|
+
);
|
|
22
|
+
export type DelegateInput = Static<typeof delegateSchema>;
|
|
23
|
+
export const runsSchema = Type.Object(
|
|
24
|
+
{
|
|
25
|
+
action: StringEnum(["list", "inspect", "continue", "stop"] as const),
|
|
26
|
+
id: Type.Optional(Type.String()),
|
|
27
|
+
prompt: Type.Optional(Type.String({ minLength: 1 })),
|
|
28
|
+
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
29
|
+
length: Type.Optional(Type.Integer({ minimum: 4, maximum: 262144 })),
|
|
30
|
+
},
|
|
31
|
+
{ additionalProperties: false },
|
|
32
|
+
);
|
|
33
|
+
export type RunsInput = Static<typeof runsSchema>;
|
package/src/agent/typebox.ts
CHANGED
|
@@ -3,15 +3,20 @@ import * as path from "node:path";
|
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import type * as NativeTypebox from "typebox";
|
|
5
5
|
import type * as NativeValue from "typebox/value";
|
|
6
|
-
import { packageRoot } from "./host.ts";
|
|
6
|
+
import { isChild, packageRoot } from "./host.ts";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
//
|
|
8
|
+
// Pi aliases these imports to its own instance (including bundled virtual modules).
|
|
9
|
+
// Only the detached runner needs absolute imports from the selected host package.
|
|
10
|
+
const require = isChild() ? createRequire(path.join(packageRoot(), "dist/index.js")) : undefined;
|
|
10
11
|
// oxlint-disable-next-line typescript/no-unsafe-assignment
|
|
11
|
-
const typebox: typeof NativeTypebox =
|
|
12
|
+
const typebox: typeof NativeTypebox =
|
|
13
|
+
require === undefined
|
|
14
|
+
? await import("typebox")
|
|
15
|
+
: await import(pathToFileURL(require.resolve("typebox")).href);
|
|
12
16
|
// oxlint-disable-next-line typescript/no-unsafe-assignment
|
|
13
|
-
const value: typeof NativeValue =
|
|
14
|
-
|
|
15
|
-
)
|
|
17
|
+
const value: typeof NativeValue =
|
|
18
|
+
require === undefined
|
|
19
|
+
? await import("typebox/value")
|
|
20
|
+
: await import(pathToFileURL(require.resolve("typebox/value")).href);
|
|
16
21
|
export const { Type } = typebox;
|
|
17
22
|
export const Assert: typeof NativeValue.Assert = value.Assert;
|