@fitchmultz/pi-subagents 0.52.18 → 0.52.20
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 +50 -0
- package/README.md +4 -0
- package/dist/agent/host.js +12 -0
- package/dist/agent/index.js +70 -56
- package/dist/agent/launch.js +12 -3
- package/dist/agent/notifications.js +5 -5
- package/dist/agent/parking.js +26 -15
- package/dist/agent/runner.js +18 -16
- package/dist/agent/tool-schemas.js +2 -0
- package/dist/agent/usage.js +42 -3
- package/dist/agent/view.js +13 -13
- package/dist/agent/wake.js +17 -15
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/bridge-presence.js +30 -18
- package/dist/pi-intercom/bridge-wake.js +60 -37
- package/dist/pi-intercom/broker/broker.js +2 -2
- package/dist/pi-intercom/broker/client-last.js +6 -18
- package/dist/pi-intercom/broker/client.js +16 -50
- package/dist/pi-intercom/broker/pending-requests.d.ts +12 -0
- package/dist/pi-intercom/broker/pending-requests.js +26 -0
- package/dist/pi-intercom/broker/snapshot-reader.d.ts +3 -2
- package/dist/pi-intercom/broker/snapshot-reader.js +34 -15
- package/dist/pi-intercom/broker/snapshots.d.ts +25 -7
- package/dist/pi-intercom/broker/snapshots.js +56 -25
- package/dist/pi-intercom/broker/spawn-claim.js +89 -0
- package/dist/pi-intercom/broker/spawn.js +31 -78
- package/dist/pi-intercom/lifecycle.js +26 -26
- package/dist/shared/post-exit-stdio-guard.js +44 -37
- package/docs/development.md +25 -0
- package/docs/intercom.md +13 -0
- package/docs/reference.md +16 -5
- package/package.json +1 -1
- package/scripts/build.mjs +41 -14
- package/skills/pi-subagents/SKILL.md +8 -4
- package/src/agent/contracts.ts +17 -0
- package/src/agent/host.ts +19 -0
- package/src/agent/index.ts +91 -62
- package/src/agent/launch.ts +16 -3
- package/src/agent/notifications.ts +5 -5
- package/src/agent/parking.ts +39 -17
- package/src/agent/runner.ts +20 -21
- package/src/agent/tool-schemas.ts +2 -0
- package/src/agent/usage.ts +48 -3
- package/src/agent/view.ts +15 -13
- package/src/agent/wake.ts +19 -15
- package/src/pi-intercom/bridge-presence.ts +37 -18
- package/src/pi-intercom/bridge-wake.ts +70 -38
- package/src/pi-intercom/broker/broker.ts +2 -1
- package/src/pi-intercom/broker/client-last.ts +14 -25
- package/src/pi-intercom/broker/client.ts +36 -65
- package/src/pi-intercom/broker/pending-requests.ts +37 -0
- package/src/pi-intercom/broker/snapshot-reader.ts +44 -16
- package/src/pi-intercom/broker/snapshots.ts +82 -37
- package/src/pi-intercom/broker/spawn-claim.ts +98 -0
- package/src/pi-intercom/broker/spawn.ts +42 -82
- package/src/pi-intercom/lifecycle.ts +38 -27
- package/src/shared/post-exit-stdio-guard.ts +48 -40
|
@@ -280,12 +280,13 @@ export function attachChildProcessLifecycle(child) {
|
|
|
280
280
|
return new OwnedChildLifecycle(child);
|
|
281
281
|
}
|
|
282
282
|
function attachPostExitStdioGuard(child) {
|
|
283
|
-
let
|
|
283
|
+
let state = { kind: "waiting" };
|
|
284
284
|
let stdoutEnded = false;
|
|
285
285
|
let stderrEnded = false;
|
|
286
|
-
let idleTimer;
|
|
287
|
-
let hardTimer;
|
|
288
286
|
const destroyUnendedStdio = () => {
|
|
287
|
+
if (state.kind !== "draining") {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
289
290
|
if (!stdoutEnded) {
|
|
290
291
|
try {
|
|
291
292
|
child.stdout?.destroy();
|
|
@@ -302,51 +303,57 @@ function attachPostExitStdioGuard(child) {
|
|
|
302
303
|
// The process has exited; failed pipe destruction must not prevent other cleanup.
|
|
303
304
|
}
|
|
304
305
|
}
|
|
305
|
-
|
|
306
|
-
const clearTimers = () => {
|
|
307
|
-
if (idleTimer) {
|
|
308
|
-
clearTimeout(idleTimer);
|
|
309
|
-
idleTimer = undefined;
|
|
310
|
-
}
|
|
311
|
-
if (hardTimer) {
|
|
312
|
-
clearTimeout(hardTimer);
|
|
313
|
-
hardTimer = undefined;
|
|
314
|
-
}
|
|
306
|
+
cleanup();
|
|
315
307
|
};
|
|
316
308
|
const armIdleTimer = () => {
|
|
317
|
-
if (
|
|
309
|
+
if (state.kind !== "draining") {
|
|
318
310
|
return;
|
|
319
311
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
idleTimer = setTimeout(destroyUnendedStdio, 2000);
|
|
324
|
-
idleTimer.unref();
|
|
312
|
+
clearTimeout(state.idle);
|
|
313
|
+
state.idle = setTimeout(destroyUnendedStdio, 2000);
|
|
314
|
+
state.idle.unref();
|
|
325
315
|
};
|
|
326
|
-
|
|
327
|
-
child.stderr?.on("data", armIdleTimer);
|
|
328
|
-
child.stdout?.on("end", () => {
|
|
316
|
+
const stdoutEnd = () => {
|
|
329
317
|
stdoutEnded = true;
|
|
330
318
|
if (stderrEnded) {
|
|
331
|
-
|
|
319
|
+
cleanup();
|
|
332
320
|
}
|
|
333
|
-
}
|
|
334
|
-
|
|
321
|
+
};
|
|
322
|
+
const stderrEnd = () => {
|
|
335
323
|
stderrEnded = true;
|
|
336
324
|
if (stdoutEnded) {
|
|
337
|
-
|
|
325
|
+
cleanup();
|
|
338
326
|
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
armIdleTimer();
|
|
343
|
-
if (hardTimer) {
|
|
327
|
+
};
|
|
328
|
+
const exited = () => {
|
|
329
|
+
if (state.kind !== "waiting") {
|
|
344
330
|
return;
|
|
345
331
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
332
|
+
const hard = setTimeout(destroyUnendedStdio, 8000);
|
|
333
|
+
hard.unref();
|
|
334
|
+
state = { kind: "draining", idle: undefined, hard };
|
|
335
|
+
armIdleTimer();
|
|
336
|
+
};
|
|
337
|
+
function cleanup() {
|
|
338
|
+
if (state.kind === "draining") {
|
|
339
|
+
clearTimeout(state.idle);
|
|
340
|
+
clearTimeout(state.hard);
|
|
341
|
+
}
|
|
342
|
+
state = { kind: "closed" };
|
|
343
|
+
child.stdout?.off("data", armIdleTimer);
|
|
344
|
+
child.stderr?.off("data", armIdleTimer);
|
|
345
|
+
child.stdout?.off("end", stdoutEnd);
|
|
346
|
+
child.stderr?.off("end", stderrEnd);
|
|
347
|
+
child.off("exit", exited);
|
|
348
|
+
child.off("close", cleanup);
|
|
349
|
+
child.off("error", cleanup);
|
|
350
|
+
}
|
|
351
|
+
child.stdout?.on("data", armIdleTimer);
|
|
352
|
+
child.stderr?.on("data", armIdleTimer);
|
|
353
|
+
child.stdout?.on("end", stdoutEnd);
|
|
354
|
+
child.stderr?.on("end", stderrEnd);
|
|
355
|
+
child.on("exit", exited);
|
|
356
|
+
child.on("close", cleanup);
|
|
357
|
+
child.on("error", cleanup);
|
|
358
|
+
return cleanup;
|
|
352
359
|
}
|
package/docs/development.md
CHANGED
|
@@ -23,6 +23,12 @@ Run narrow owner-boundary tests locally through `scripts/run-tests.mjs`. Full
|
|
|
23
23
|
`npm run ci` belongs in hosted CI and includes package/install smokes and the
|
|
24
24
|
complete suites. Keep the strict Oxlint/Oxfmt rules; see [code quality](code-quality.md).
|
|
25
25
|
|
|
26
|
+
Builds compile into process-owned staging before replacing `dist`. If a publisher
|
|
27
|
+
exits after retiring the previous build, later builds preserve its complete
|
|
28
|
+
`.previous` tree until a new complete build publishes. A failed build reports
|
|
29
|
+
the retained recovery paths. Recovery never promotes an older backup into a
|
|
30
|
+
concurrent publisher's retirement gap or reaps live or uncertain owners.
|
|
31
|
+
|
|
26
32
|
## Native Android/Termux
|
|
27
33
|
|
|
28
34
|
Use app-private storage, not Android's shared `/sdcard` filesystem. No Termux app
|
|
@@ -80,6 +86,25 @@ npm and attached to the matching GitHub release.
|
|
|
80
86
|
|
|
81
87
|
## Isolation and safety
|
|
82
88
|
|
|
89
|
+
Parent attachment keeps one binding through detached, waiting, attaching and
|
|
90
|
+
attached phases. The existing shutdown signal closes the owner. Managed
|
|
91
|
+
readiness can attach only its own still-current waiting binding; shutdown joins
|
|
92
|
+
admitted publication before releasing the matching generation.
|
|
93
|
+
|
|
94
|
+
Parking returns resident, exit-with-parked-authority or exit-after-failed-save.
|
|
95
|
+
Both exit outcomes have disposed the native runtime and must exit the actor;
|
|
96
|
+
neither permits a second ordinary disposal. Restore tracks unopened, opening
|
|
97
|
+
and adopted phases, keeping the exact saved cut with the phase that owns it.
|
|
98
|
+
|
|
99
|
+
Directory watches distinguish unarmed, armed-to-one-inode and polling-only
|
|
100
|
+
availability. Missing paths can be armed later; unsupported or failed native
|
|
101
|
+
watches keep the existing polling fallback. Each coalesced Wake has one waiting
|
|
102
|
+
owner loop.
|
|
103
|
+
|
|
104
|
+
Smoke child cleanup treats leader exit and pipe closure separately. Pipe draining
|
|
105
|
+
has waiting, draining and closed phases. Closing it cancels its deadlines and
|
|
106
|
+
detaches its listeners, so later events cannot restart cleanup.
|
|
107
|
+
|
|
83
108
|
Every test/smoke broker uses a short, explicit `PI_INTERCOM_RUNTIME_DIR` owned by
|
|
84
109
|
the fixture. Do not touch `/tmp/pi-intercom-<uid>` to test namespace behavior on a
|
|
85
110
|
workstation with active sessions. Tests for the default path inspect it without
|
package/docs/intercom.md
CHANGED
|
@@ -341,6 +341,9 @@ tool to give up the turn.
|
|
|
341
341
|
|
|
342
342
|
Shutdown joins admitted inbound work before detaching its native owner. Stale
|
|
343
343
|
runtime generations must not checkpoint or deliver into a replacement session.
|
|
344
|
+
Context state is inactive, live with its session and incarnation, or invalidated
|
|
345
|
+
while transport cleanup retains the session ID. Clearing cleanup drops that ID;
|
|
346
|
+
transport connection and native tool activity remain separate from context validity.
|
|
344
347
|
The native tests own these contracts; neither a timer nor a source-string test is
|
|
345
348
|
publication evidence.
|
|
346
349
|
|
|
@@ -396,4 +399,14 @@ records include native boot/namespace/start identity to reject proven PID reuse;
|
|
|
396
399
|
ambiguous legacy numeric PIDs remain blocking. A process is never killed merely
|
|
397
400
|
to recover broker startup.
|
|
398
401
|
|
|
402
|
+
Startup distinguishes absent, live and uncertain ownership. An unreadable or
|
|
403
|
+
invalid PID record is uncertain, not permission to launch another broker.
|
|
404
|
+
A startup claim stays owned while its process is live, regardless of age.
|
|
405
|
+
Only a proven exited or replaced owner permits reclaiming it. Linux claims carry
|
|
406
|
+
the same native process identity as broker PID records. Legacy or incomparable
|
|
407
|
+
identities remain blocking; a timestamp alone cannot prove PID reuse.
|
|
408
|
+
Each starter keeps the claim's
|
|
409
|
+
file descriptor open and releases only the same device/inode, leaving a
|
|
410
|
+
replacement claim untouched.
|
|
411
|
+
|
|
399
412
|
Remote authenticated mail is separate: see [bridge setup](intercom-bridge.md).
|
package/docs/reference.md
CHANGED
|
@@ -12,6 +12,7 @@ delegate({
|
|
|
12
12
|
name: "optional-readable-name",
|
|
13
13
|
cwd: "/optional/working/directory",
|
|
14
14
|
model: "provider/model",
|
|
15
|
+
thinking: "low",
|
|
15
16
|
context: "fresh",
|
|
16
17
|
});
|
|
17
18
|
```
|
|
@@ -21,19 +22,27 @@ canned profile. `name` is an optional display name, not a unique identity or rol
|
|
|
21
22
|
Omitted cwd/model inherit the invoking session's effective selection. A supplied
|
|
22
23
|
model must be available; there is no hidden alternate-model retry catalog.
|
|
23
24
|
|
|
25
|
+
`thinking` accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`.
|
|
26
|
+
It overrides parent effort for this child without changing the parent. It works
|
|
27
|
+
with an omitted model or a literal model ID on every supported host, including
|
|
28
|
+
official Pi 1.5.0. Omission preserves inherited effort. Pi applies its native
|
|
29
|
+
model-capability clamping; the launch receipt records the requested value, not
|
|
30
|
+
proof of the child's effective effort.
|
|
31
|
+
|
|
24
32
|
On hosts exporting Pi's native `resolveModelReference`, `model` also accepts
|
|
25
33
|
`provider/model:thinking` (for example `provider/model:low` or `provider/model:off`).
|
|
26
|
-
The explicit suffix overrides inherited thinking for that child only.
|
|
34
|
+
The explicit suffix overrides inherited thinking for that child only. If both
|
|
35
|
+
`thinking` and a suffix specify effort, they must match or the launch fails. Literal
|
|
27
36
|
model IDs take precedence, including IDs containing colons or additional slashes;
|
|
28
37
|
unknown IDs and invalid suffixes fail without fuzzy matching or another provider.
|
|
29
38
|
Native resolution follows Pi's case-insensitive ID matching. Pi still applies the
|
|
30
39
|
selected model's native thinking capabilities.
|
|
31
40
|
|
|
32
41
|
Official Pi, including 1.5.0, does not export that resolver. Older fork runtimes also
|
|
33
|
-
lack the export. Those hosts retain
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
lack the export. Those hosts retain case-sensitive literal-ID overrides and
|
|
43
|
+
support the `thinking` field. Suffix requests fail with an upgrade message rather
|
|
44
|
+
than being parsed by an extension-side replacement. Suffix support requires a
|
|
45
|
+
host with the public resolver export, not merely a matching version number.
|
|
37
46
|
|
|
38
47
|
Fresh context is the default. Fork uses the native conversation, including
|
|
39
48
|
historical tool calls and results; restricting a child's authority does not erase
|
|
@@ -58,6 +67,8 @@ tool or programmatic invocation of `delegate` or `agent_runs`.
|
|
|
58
67
|
|
|
59
68
|
Saved runs belong to the native main session that launched them, not its cwd,
|
|
60
69
|
project, display name, or a different session forked from its history.
|
|
70
|
+
Live guidance and saved-run successors keep the child's saved effort even if the
|
|
71
|
+
parent's effort has changed.
|
|
61
72
|
|
|
62
73
|
| Action | Purpose |
|
|
63
74
|
| ---------- | ------------------------------------------------------------------------------------ |
|
package/package.json
CHANGED
package/scripts/build.mjs
CHANGED
|
@@ -67,21 +67,37 @@ async function hasPublishedDist(/** @type {string} */ path) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} cwd
|
|
72
|
+
* @param {import("node:fs").Dirent} entry
|
|
73
|
+
* @returns {{ path: string, kind: "staging" | "previous" } | undefined}
|
|
74
|
+
*/
|
|
75
|
+
function strandedBuild(cwd, entry) {
|
|
71
76
|
// Signal 0 proves only that the owner was gone at probe time. Pid reuse
|
|
72
77
|
// between this probe and removal is inherent to pid-based reaping.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
const match = /^dist\.staging\.(\d+)(\.previous)?$/.exec(entry.name);
|
|
79
|
+
if (!match || !entry.isDirectory()) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const ownerPid = Number(match[1]);
|
|
83
|
+
if (ownerPid === process.pid || !isOwnerGone(ownerPid)) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
return { path: join(cwd, entry.name), kind: match[2] === undefined ? "staging" : "previous" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function reapStrandedBuilds(/** @type {string} */ cwd) {
|
|
90
|
+
const stranded = (await readdir(cwd, { withFileTypes: true })).flatMap((entry) => {
|
|
91
|
+
const build = strandedBuild(cwd, entry);
|
|
92
|
+
return build ? [build] : [];
|
|
93
|
+
});
|
|
94
|
+
for (const entry of stranded) {
|
|
95
|
+
if (entry.kind === "staging") {
|
|
96
|
+
await discardStaging(entry.path);
|
|
81
97
|
}
|
|
82
|
-
// Reap abandoned trees sequentially to bound filesystem removal pressure.
|
|
83
|
-
await discardStaging(join(cwd, entry.name));
|
|
84
98
|
}
|
|
99
|
+
// Never promote rollback material into another live publisher's retirement gap.
|
|
100
|
+
return stranded.filter((entry) => entry.kind === "previous").map((entry) => entry.path);
|
|
85
101
|
}
|
|
86
102
|
|
|
87
103
|
async function compileProject(
|
|
@@ -208,13 +224,24 @@ async function publishStaging(/** @type {string} */ stagingDir, /** @type {strin
|
|
|
208
224
|
async function main() {
|
|
209
225
|
const cwd = process.cwd();
|
|
210
226
|
const compiler = compilerInvocation(process.argv[2]);
|
|
211
|
-
await
|
|
227
|
+
const previous = await reapStrandedBuilds(cwd);
|
|
212
228
|
// Pid-scoped staging isolates concurrent emits; only a complete tree publishes.
|
|
213
229
|
const stagingDir = join(cwd, `dist.staging.${process.pid}`);
|
|
214
230
|
await rm(stagingDir, RM_OPTIONS);
|
|
215
231
|
await rm(`${stagingDir}.previous`, RM_OPTIONS);
|
|
216
|
-
|
|
217
|
-
|
|
232
|
+
try {
|
|
233
|
+
await compileToStaging(cwd, stagingDir, compiler);
|
|
234
|
+
await publishStaging(stagingDir, join(cwd, "dist"));
|
|
235
|
+
} catch (error) {
|
|
236
|
+
for (const retained of previous.filter(existsSync)) {
|
|
237
|
+
console.warn(`Previous build retained for recovery: ${retained}`);
|
|
238
|
+
}
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
// A complete published winner now replaces these older rollback candidates.
|
|
242
|
+
for (const retired of previous) {
|
|
243
|
+
await discardStaging(retired);
|
|
244
|
+
}
|
|
218
245
|
}
|
|
219
246
|
|
|
220
247
|
main().catch((/** @type {unknown} */ error) => {
|
|
@@ -14,6 +14,7 @@ profile discovery, loader, role body, chain DSL, or nested delegation.
|
|
|
14
14
|
delegate({
|
|
15
15
|
prompt: "Complete assignment with constraints and expected output",
|
|
16
16
|
name: "short-name",
|
|
17
|
+
thinking: "low",
|
|
17
18
|
});
|
|
18
19
|
```
|
|
19
20
|
|
|
@@ -23,12 +24,14 @@ expected result, and stop boundary. Do not rely on a hidden profile to add them.
|
|
|
23
24
|
|
|
24
25
|
Fresh context is default; `context:"fork"` preserves the native conversation,
|
|
25
26
|
including prior tool results. A forked child still cannot delegate. Optional
|
|
26
|
-
`cwd` and `
|
|
27
|
-
settings to implement an override.
|
|
27
|
+
`cwd`, `model`, and `thinking` are explicit launch overrides; do not change global
|
|
28
|
+
auth/model settings to implement an override. `thinking` accepts `off`, `minimal`,
|
|
29
|
+
`low`, `medium`, `high`, `xhigh`, or `max`. Omit it to inherit the parent's effort.
|
|
30
|
+
Pi clamps the child's effort to its model capabilities without changing the parent.
|
|
28
31
|
`model:"provider/model:thinking"` uses Pi's native suffix resolver when the host
|
|
29
32
|
exports it; a literal model ID wins before suffix parsing. Without that host API,
|
|
30
|
-
use
|
|
31
|
-
silently.
|
|
33
|
+
use `thinking` with an omitted model or literal ID. If both the field and a native
|
|
34
|
+
suffix supply effort, they must match. Never retry another model silently.
|
|
32
35
|
|
|
33
36
|
Every launch, continuation and guidance call returns a receipt; no tool waits for
|
|
34
37
|
child completion. Continue useful independent work; when child evidence gates the
|
|
@@ -45,6 +48,7 @@ invocation exits after its final turn and stops any remaining children.
|
|
|
45
48
|
- `inspect` reads a run by ID; follow returned offsets for complete output.
|
|
46
49
|
- `continue` takes a complete follow-up `prompt` and keeps the saved child chat.
|
|
47
50
|
Active work receives guidance; completed work gets an immutable successor.
|
|
51
|
+
Both keep the child's saved effort, independent of later parent changes.
|
|
48
52
|
- `stop` requests termination. Inspect actual process evidence; request acceptance
|
|
49
53
|
does not prove actor or command exit.
|
|
50
54
|
|
package/src/agent/contracts.ts
CHANGED
|
@@ -93,6 +93,23 @@ export const resultSchema = Type.Object({
|
|
|
93
93
|
),
|
|
94
94
|
});
|
|
95
95
|
export type Result = Static<typeof resultSchema>;
|
|
96
|
+
export type AccountingState =
|
|
97
|
+
| { readonly kind: "unrecorded"; readonly terminalEntryId: string | undefined }
|
|
98
|
+
| {
|
|
99
|
+
readonly kind: "verified";
|
|
100
|
+
readonly contributions: Contribution[];
|
|
101
|
+
readonly terminalEntryId: string | undefined;
|
|
102
|
+
}
|
|
103
|
+
| {
|
|
104
|
+
readonly kind: "pending";
|
|
105
|
+
readonly pending: NonNullable<Result["accountingPending"]>;
|
|
106
|
+
readonly error: string | undefined;
|
|
107
|
+
}
|
|
108
|
+
| {
|
|
109
|
+
readonly kind: "unavailable";
|
|
110
|
+
readonly error: string;
|
|
111
|
+
readonly terminalEntryId: string | undefined;
|
|
112
|
+
};
|
|
96
113
|
export const commandSchema = Type.Object({
|
|
97
114
|
id: Type.String(),
|
|
98
115
|
runId: Type.String(),
|
package/src/agent/host.ts
CHANGED
|
@@ -9,6 +9,25 @@ import { readIntercomLineage } from "../pi-intercom/identity.ts";
|
|
|
9
9
|
|
|
10
10
|
export { errorMessage as errorText } from "../shared/unknown.ts";
|
|
11
11
|
|
|
12
|
+
/** Native fork lifetime owner; structural detection keeps official Pi supported. */
|
|
13
|
+
export interface ManagedHost {
|
|
14
|
+
readonly owner: { readonly pid: number };
|
|
15
|
+
readonly ready: Promise<boolean>;
|
|
16
|
+
}
|
|
17
|
+
export function managedHost(context: SDK.ExtensionContext): ManagedHost | undefined {
|
|
18
|
+
const host: unknown = Reflect.get(context, "managedHost");
|
|
19
|
+
if (typeof host !== "object" || host === null) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const owner: unknown = Reflect.get(host, "owner");
|
|
23
|
+
const pid: unknown =
|
|
24
|
+
typeof owner === "object" && owner !== null ? Reflect.get(owner, "pid") : undefined;
|
|
25
|
+
const ready: unknown = Reflect.get(host, "ready");
|
|
26
|
+
return typeof pid === "number" && Number.isSafeInteger(pid) && ready instanceof Promise
|
|
27
|
+
? { owner: { pid }, ready: ready.then((accepted) => accepted === true) }
|
|
28
|
+
: undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
12
31
|
/** A launcher may still own children while handing off or replacing a worker. */
|
|
13
32
|
export function retainsChildren(event: object): boolean {
|
|
14
33
|
const disposition: unknown = Reflect.get(event, "disposition");
|
package/src/agent/index.ts
CHANGED
|
@@ -17,7 +17,14 @@ import {
|
|
|
17
17
|
signalProcess,
|
|
18
18
|
} from "./process.ts";
|
|
19
19
|
import { stopChildren } from "./shutdown.ts";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
isChild,
|
|
22
|
+
errorText,
|
|
23
|
+
managedHost,
|
|
24
|
+
rememberSDK,
|
|
25
|
+
retainsChildren,
|
|
26
|
+
type ManagedHost,
|
|
27
|
+
} from "./host.ts";
|
|
21
28
|
import { serveParked } from "./parking.ts";
|
|
22
29
|
import {
|
|
23
30
|
resultSchema,
|
|
@@ -54,24 +61,20 @@ import { getPiAgentDir } from "../pi-intercom/agent-dir.ts";
|
|
|
54
61
|
import { registerCompactMessages, renderCompactResult } from "../shared/compact-cards.ts";
|
|
55
62
|
import { BackgroundReporter } from "../shared/background-reporting.ts";
|
|
56
63
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
readonly owner:
|
|
60
|
-
readonly
|
|
61
|
-
|
|
62
|
-
function managedHost(context: ExtensionContext): ManagedHost | undefined {
|
|
63
|
-
const host: unknown = Reflect.get(context, "managedHost");
|
|
64
|
-
if (typeof host !== "object" || host === null) {
|
|
65
|
-
return undefined;
|
|
66
|
-
}
|
|
67
|
-
const owner: unknown = Reflect.get(host, "owner");
|
|
68
|
-
const pid: unknown =
|
|
69
|
-
typeof owner === "object" && owner !== null ? Reflect.get(owner, "pid") : undefined;
|
|
70
|
-
const ready: unknown = Reflect.get(host, "ready");
|
|
71
|
-
return typeof pid === "number" && Number.isSafeInteger(pid) && ready instanceof Promise
|
|
72
|
-
? { owner: { pid }, ready: ready.then((accepted) => accepted === true) }
|
|
73
|
-
: undefined;
|
|
64
|
+
interface AttachmentBinding {
|
|
65
|
+
readonly context: ExtensionContext;
|
|
66
|
+
readonly owner: string;
|
|
67
|
+
readonly base: string;
|
|
68
|
+
readonly host: ManagedHost | undefined;
|
|
74
69
|
}
|
|
70
|
+
type AttachmentState =
|
|
71
|
+
| { readonly kind: "detached" }
|
|
72
|
+
| { readonly kind: "waiting"; readonly binding: AttachmentBinding }
|
|
73
|
+
| {
|
|
74
|
+
readonly kind: "attaching" | "attached";
|
|
75
|
+
readonly binding: AttachmentBinding;
|
|
76
|
+
readonly generation: string;
|
|
77
|
+
};
|
|
75
78
|
|
|
76
79
|
/** Legacy results are optional; their reconciliation reports an invalid configured root. */
|
|
77
80
|
function legacyInput(): string[] {
|
|
@@ -82,14 +85,8 @@ function legacyInput(): string[] {
|
|
|
82
85
|
}
|
|
83
86
|
}
|
|
84
87
|
class AgentRuntime {
|
|
85
|
-
private
|
|
88
|
+
private attachment: AttachmentState = { kind: "detached" };
|
|
86
89
|
private readonly reporter = new BackgroundReporter(() => this.ctx);
|
|
87
|
-
// A managed launcher owns children across worker replacement; otherwise this process does.
|
|
88
|
-
private host?: ManagedHost | undefined;
|
|
89
|
-
private accepted = false;
|
|
90
|
-
private base = "";
|
|
91
|
-
private owner = "";
|
|
92
|
-
private generation = "";
|
|
93
90
|
// Polling remains only while change hints are unavailable or a reconciliation failed.
|
|
94
91
|
private timer?: NodeJS.Timeout;
|
|
95
92
|
private woken?: NodeJS.Immediate | undefined;
|
|
@@ -99,7 +96,6 @@ class AgentRuntime {
|
|
|
99
96
|
// Input stamps the last reconciliation saw; a one-second probe recovers dropped hints.
|
|
100
97
|
private observed = new Map<string, string>();
|
|
101
98
|
private probe?: NodeJS.Timeout;
|
|
102
|
-
private closing = false;
|
|
103
99
|
private reset: Promise<void> = Promise.resolve();
|
|
104
100
|
private readonly shutdown = new AbortController();
|
|
105
101
|
private readonly child = isChild();
|
|
@@ -109,11 +105,28 @@ class AgentRuntime {
|
|
|
109
105
|
this.pi = pi;
|
|
110
106
|
this.watches = new CompletionWatches(this.reporter, this.wake);
|
|
111
107
|
}
|
|
108
|
+
private get binding(): AttachmentBinding {
|
|
109
|
+
if (this.attachment.kind === "detached") {
|
|
110
|
+
throw new Error("Agent runtime is not attached.");
|
|
111
|
+
}
|
|
112
|
+
return this.attachment.binding;
|
|
113
|
+
}
|
|
114
|
+
private get ctx(): ExtensionContext | undefined {
|
|
115
|
+
return this.attachment.kind === "attaching" || this.attachment.kind === "attached"
|
|
116
|
+
? this.attachment.binding.context
|
|
117
|
+
: undefined;
|
|
118
|
+
}
|
|
119
|
+
private get base(): string {
|
|
120
|
+
return this.binding.base;
|
|
121
|
+
}
|
|
122
|
+
private get owner(): string {
|
|
123
|
+
return this.binding.owner;
|
|
124
|
+
}
|
|
112
125
|
private checkMain(): void {
|
|
113
126
|
if (isChild()) {
|
|
114
127
|
throw new Error("Only a main session may delegate or control agent runs.");
|
|
115
128
|
}
|
|
116
|
-
if (this.
|
|
129
|
+
if (this.shutdown.signal.aborted || this.attachment.kind !== "attached") {
|
|
117
130
|
throw new Error("Agent runtime is not attached.");
|
|
118
131
|
}
|
|
119
132
|
}
|
|
@@ -133,17 +146,18 @@ class AgentRuntime {
|
|
|
133
146
|
};
|
|
134
147
|
private readonly scan = (): void => {
|
|
135
148
|
clearTimeout(this.timer);
|
|
136
|
-
if (this.
|
|
149
|
+
if (this.shutdown.signal.aborted || this.attachment.kind !== "attached" || this.child) {
|
|
137
150
|
return;
|
|
138
151
|
}
|
|
152
|
+
const { context } = this.attachment.binding;
|
|
139
153
|
const inputs = () => [this.base, ...legacyInput(), ...this.awaited];
|
|
140
154
|
const before = new Map(inputs().map((file) => [file, pathStamp(file)]));
|
|
141
155
|
let reconciled = true;
|
|
142
156
|
try {
|
|
143
|
-
const { notices, awaited } = notifications(
|
|
157
|
+
const { notices, awaited } = notifications(context, this.base, this.owner);
|
|
144
158
|
this.awaited = awaited;
|
|
145
|
-
notices.unshift(...reconcileLegacy(
|
|
146
|
-
deliver(this.pi,
|
|
159
|
+
notices.unshift(...reconcileLegacy(context, this.reporter));
|
|
160
|
+
deliver(this.pi, context, notices);
|
|
147
161
|
this.reporter.recover("completion-scan");
|
|
148
162
|
} catch (error) {
|
|
149
163
|
reconciled = false;
|
|
@@ -172,23 +186,34 @@ class AgentRuntime {
|
|
|
172
186
|
this.awaited = new Set();
|
|
173
187
|
}
|
|
174
188
|
private start(context: ExtensionContext): Promise<void> | undefined {
|
|
189
|
+
if (this.shutdown.signal.aborted) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
175
192
|
this.reporter.start();
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
193
|
+
const owner = context.sessionManager.getSessionId();
|
|
194
|
+
const host = managedHost(context);
|
|
195
|
+
const binding = { context, owner, base: ownerDirectory(this.agentDir, owner), host };
|
|
196
|
+
if (
|
|
197
|
+
host === undefined ||
|
|
198
|
+
this.attachment.kind === "attached" ||
|
|
199
|
+
this.attachment.kind === "attaching" ||
|
|
200
|
+
this.child
|
|
201
|
+
) {
|
|
202
|
+
this.reset = this.reset.then(() => this.attach(binding));
|
|
179
203
|
return this.reset;
|
|
180
204
|
}
|
|
181
205
|
// Attach and publish only after the launcher retires rollback: an unready replacement
|
|
182
206
|
// must neither adopt children nor append completions to a journal it may abandon.
|
|
183
207
|
// Readiness follows session_start, so never await it here.
|
|
184
|
-
|
|
185
|
-
this.
|
|
186
|
-
|
|
187
|
-
.then(
|
|
188
|
-
if (accepted && !this.
|
|
189
|
-
this.
|
|
190
|
-
|
|
208
|
+
const waiting: AttachmentState = { kind: "waiting", binding };
|
|
209
|
+
this.attachment = waiting;
|
|
210
|
+
host.ready
|
|
211
|
+
.then((accepted) => {
|
|
212
|
+
if (accepted && !this.shutdown.signal.aborted && this.attachment === waiting) {
|
|
213
|
+
this.reset = this.reset.then(() => this.attach(binding));
|
|
214
|
+
return this.reset;
|
|
191
215
|
}
|
|
216
|
+
return null;
|
|
192
217
|
})
|
|
193
218
|
.catch((error: unknown) => {
|
|
194
219
|
this.reporter.report(undefined, `Agent runtime attachment: ${errorText(error)}`);
|
|
@@ -196,37 +221,36 @@ class AgentRuntime {
|
|
|
196
221
|
return undefined;
|
|
197
222
|
}
|
|
198
223
|
private ownerIdentity(): Omit<ProcessIdentity, "token"> {
|
|
199
|
-
return ownIdentity(this.host?.owner.pid);
|
|
224
|
+
return ownIdentity(this.binding.host?.owner.pid);
|
|
200
225
|
}
|
|
201
|
-
private async attach(
|
|
226
|
+
private async attach(binding: AttachmentBinding): Promise<void> {
|
|
202
227
|
this.unwatch();
|
|
203
|
-
|
|
228
|
+
const attaching: AttachmentState = { kind: "attaching", binding, generation: randomUUID() };
|
|
229
|
+
this.attachment = attaching;
|
|
204
230
|
if (this.child) {
|
|
231
|
+
this.attachment = { ...attaching, kind: "attached" };
|
|
205
232
|
return;
|
|
206
233
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
write(path.join(this.base, "attachment.json"), {
|
|
212
|
-
...this.ownerIdentity(),
|
|
213
|
-
generation: this.generation,
|
|
234
|
+
await fs.promises.mkdir(binding.base, { recursive: true, mode: 0o700 });
|
|
235
|
+
write(path.join(binding.base, "attachment.json"), {
|
|
236
|
+
...ownIdentity(binding.host?.owner.pid),
|
|
237
|
+
generation: attaching.generation,
|
|
214
238
|
attached: true,
|
|
215
239
|
});
|
|
216
|
-
if (this.
|
|
240
|
+
if (this.shutdown.signal.aborted) {
|
|
217
241
|
return;
|
|
218
242
|
}
|
|
243
|
+
this.attachment = { ...attaching, kind: "attached" };
|
|
219
244
|
// Recovery and watch setup stay off the awaited startup path.
|
|
220
245
|
this.timer = setTimeout(this.scan, 200).unref();
|
|
221
246
|
this.probe = setInterval(this.recover, 1000).unref();
|
|
222
247
|
}
|
|
223
248
|
private async dispose(retain: boolean): Promise<void> {
|
|
224
249
|
this.reporter.shutdown();
|
|
225
|
-
this.closing = true;
|
|
226
250
|
this.unwatch();
|
|
227
251
|
this.shutdown.abort();
|
|
228
252
|
await this.reset;
|
|
229
|
-
if (!retain && this.
|
|
253
|
+
if (!retain && !this.child && this.attachment.kind !== "detached") {
|
|
230
254
|
const file = path.join(this.base, "attachment.json");
|
|
231
255
|
const attachment = read(file, attachmentSchema);
|
|
232
256
|
if (attachment !== undefined && this.releases(attachment)) {
|
|
@@ -240,16 +264,20 @@ class AgentRuntime {
|
|
|
240
264
|
);
|
|
241
265
|
}
|
|
242
266
|
}
|
|
243
|
-
this.
|
|
267
|
+
this.attachment = { kind: "detached" };
|
|
244
268
|
}
|
|
245
269
|
private releases(attachment: Attachment): boolean {
|
|
246
|
-
|
|
247
|
-
|
|
270
|
+
const state = this.attachment;
|
|
271
|
+
switch (state.kind) {
|
|
272
|
+
case "detached":
|
|
273
|
+
return false;
|
|
274
|
+
case "waiting":
|
|
275
|
+
// An unready replacement can release the launcher's existing generation on quit.
|
|
276
|
+
return attachment.attached && attachment.pid === state.binding.host?.owner.pid;
|
|
277
|
+
case "attaching":
|
|
278
|
+
case "attached":
|
|
279
|
+
return attachment.generation === state.generation;
|
|
248
280
|
}
|
|
249
|
-
// An unready replacement never attached, but exiting still releases the launcher-owned children.
|
|
250
|
-
return (
|
|
251
|
-
this.generation.length === 0 && attachment.attached && attachment.pid === this.host?.owner.pid
|
|
252
|
-
);
|
|
253
281
|
}
|
|
254
282
|
private stop(launch: Launch) {
|
|
255
283
|
const base = this.base;
|
|
@@ -433,6 +461,7 @@ class AgentRuntime {
|
|
|
433
461
|
this.pi.on("turn_end", (event, context) => {
|
|
434
462
|
// The public pending flag covers user input; the preview also includes custom messages.
|
|
435
463
|
if (
|
|
464
|
+
this.attachment.kind === "attached" &&
|
|
436
465
|
settle(this.owner, context.hasPendingMessages() || event.context.pendingMessages.length > 0)
|
|
437
466
|
) {
|
|
438
467
|
// Released admissions may have been dropped from the queue rather than published.
|
|
@@ -459,7 +488,7 @@ class AgentRuntime {
|
|
|
459
488
|
name: "delegate",
|
|
460
489
|
label: "Delegate",
|
|
461
490
|
description:
|
|
462
|
-
"Run a child on exactly this prompt; main sessions only. Fork copies the full conversation; default is fresh. Returns a receipt; completion is notified later: do other work or end the turn, don't poll.",
|
|
491
|
+
"Run a child on exactly this prompt; main sessions only. Fork copies the full conversation; default is fresh. Optional thinking overrides inherited effort for this child. Returns a receipt; completion is notified later: do other work or end the turn, don't poll.",
|
|
463
492
|
parameters: delegateSchema,
|
|
464
493
|
exposure: this.child ? "hidden" : "direct",
|
|
465
494
|
renderResult: renderCompactResult,
|