@hheei/pi-subagents 0.1.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -137
- package/dist/agent-resolver.d.ts +20 -0
- package/dist/agent-resolver.js +155 -21
- package/dist/bridge-client.d.ts +55 -0
- package/dist/bridge-client.js +453 -0
- package/dist/bridge-server.d.ts +72 -0
- package/dist/bridge-server.js +490 -0
- package/dist/builtin-agents.d.ts +24 -0
- package/dist/builtin-agents.js +152 -0
- package/dist/child-bridge.d.ts +31 -15
- package/dist/child-bridge.js +95 -117
- package/dist/child-control.d.ts +58 -0
- package/dist/child-control.js +176 -0
- package/dist/child-process.d.ts +21 -0
- package/dist/child-process.js +93 -0
- package/dist/commands.d.ts +2 -2
- package/dist/commands.js +52 -60
- package/dist/diagnostics.d.ts +0 -2
- package/dist/diagnostics.js +0 -8
- package/dist/domain.d.ts +57 -27
- package/dist/domain.js +60 -11
- package/dist/extension.d.ts +26 -1
- package/dist/extension.js +243 -46
- package/dist/host-adapter.d.ts +37 -12
- package/dist/host-adapter.js +214 -63
- package/dist/json-lines.js +2 -1
- package/dist/launch-spec.d.ts +8 -6
- package/dist/launch-spec.js +61 -18
- package/dist/manager.d.ts +93 -49
- package/dist/manager.js +1175 -528
- package/dist/markdown.d.ts +8 -0
- package/dist/markdown.js +21 -0
- package/dist/protocol.d.ts +33 -52
- package/dist/protocol.js +41 -52
- package/dist/registry.d.ts +3 -8
- package/dist/registry.js +41 -163
- package/dist/rpc-events.d.ts +3 -5
- package/dist/rpc-events.js +5 -9
- package/dist/runtime.d.ts +66 -11
- package/dist/runtime.js +182 -208
- package/dist/session-bootstrap.d.ts +23 -7
- package/dist/session-bootstrap.js +79 -15
- package/dist/session-fork.d.ts +16 -0
- package/dist/session-fork.js +67 -0
- package/dist/state.d.ts +7 -0
- package/dist/state.js +60 -25
- package/dist/tool-activation.d.ts +18 -0
- package/dist/tool-activation.js +227 -0
- package/dist/tools.d.ts +6 -3
- package/dist/tools.js +213 -72
- package/dist/widget.d.ts +4 -8
- package/dist/widget.js +55 -64
- package/package.json +51 -52
- package/dist/child-nudge.d.ts +0 -24
- package/dist/child-nudge.js +0 -102
- package/dist/connector.d.ts +0 -53
- package/dist/connector.js +0 -283
- package/dist/pause-gate.d.ts +0 -14
- package/dist/pause-gate.js +0 -64
- package/dist/rpc-adapter.d.ts +0 -40
- package/dist/rpc-adapter.js +0 -194
- package/dist/runner-entry.d.ts +0 -24
- package/dist/runner-entry.js +0 -166
- package/dist/runner.d.ts +0 -52
- package/dist/runner.js +0 -930
package/dist/runtime.js
CHANGED
|
@@ -1,253 +1,227 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Runtime plumbing for a child Pi process that this parent owns.
|
|
3
|
+
*
|
|
4
|
+
* There is no supervisor: a background child is a child process of this Pi, and a child that runs in
|
|
5
|
+
* a host panel is owned by that host. Either way the parent's bridge socket is what the child dials,
|
|
6
|
+
* so "is this child alive?" is answered by a bridge connection, and the runtime metadata in the
|
|
7
|
+
* registry only records which runtime and endpoint a child was last launched with.
|
|
8
|
+
*/
|
|
9
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
10
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
11
|
+
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
|
|
4
12
|
import { tmpdir } from "node:os";
|
|
5
13
|
import { join } from "node:path";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
14
|
+
import { errorMessage } from "@hheei/pi-ext-core";
|
|
15
|
+
import { spawnChildRuntime } from "./child-process.js";
|
|
8
16
|
import { buildLaunchSpec, withBridgeToken } from "./launch-spec.js";
|
|
9
|
-
import { planSessionPlacement } from "./session-bootstrap.js";
|
|
10
17
|
function runtimeDirectory() {
|
|
11
18
|
const user = typeof process.getuid === "function" ? process.getuid() : "user";
|
|
12
19
|
return join(tmpdir(), `pi-subagents-${user}`);
|
|
13
20
|
}
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The one socket a parent session listens on. A child dials this path, so a parent that restarts
|
|
23
|
+
* re-listens in the same place and a surviving child reconnects instead of being rebuilt.
|
|
24
|
+
*/
|
|
25
|
+
export function parentBridgeEndpoint(parentSessionId) {
|
|
26
|
+
return join(runtimeDirectory(), `parent-${parentSessionId}.sock`);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Every parent session keeps its own token file. One shared file is rewritten wholesale by whichever
|
|
30
|
+
* parent saved last, so two parent sessions in the same runtime directory would erase each other's
|
|
31
|
+
* credentials and a child that survived a restart could no longer authorize itself.
|
|
32
|
+
*/
|
|
33
|
+
export function parentTokenFile(parentSessionId) {
|
|
34
|
+
return join(runtimeDirectory(), `tokens-${parentSessionId}.json`);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Tokens outlive the parent process: a child that survived (a panel child, or one whose parent was
|
|
38
|
+
* killed) reconnects with the token it was launched with, and a restarted parent must be able to
|
|
39
|
+
* authorize it without holding the old process's memory. The store is a 0600 file in the 0700
|
|
40
|
+
* runtime directory; the registry never sees a token.
|
|
41
|
+
*/
|
|
42
|
+
export function createRuntimeTokenStore(options) {
|
|
43
|
+
const file = options.file ?? parentTokenFile(options.parentSessionId);
|
|
44
|
+
const diagnose = options.diagnose ?? (() => { });
|
|
45
|
+
const tokens = new Map(readStoredTokens(file, diagnose));
|
|
46
|
+
let writing = Promise.resolve();
|
|
47
|
+
const persist = () => {
|
|
48
|
+
const payload = `${JSON.stringify(Object.fromEntries(tokens))}\n`;
|
|
49
|
+
writing = writing
|
|
50
|
+
.then(async () => {
|
|
51
|
+
mkdirSync(runtimeDirectory(), { recursive: true, mode: 0o700 });
|
|
52
|
+
// Writing in place truncates the file first, and a parent killed inside that window would
|
|
53
|
+
// leave every surviving child locked out of the bridge. The swap is atomic instead.
|
|
54
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
55
|
+
await writeFile(temporary, payload, { mode: 0o600 });
|
|
56
|
+
await rename(temporary, file);
|
|
57
|
+
})
|
|
58
|
+
.catch((error) => {
|
|
59
|
+
diagnose(`could not persist bridge tokens: ${errorMessage(error)}`);
|
|
60
|
+
});
|
|
61
|
+
};
|
|
16
62
|
return {
|
|
17
63
|
remember(runtimeIdentity, token) {
|
|
18
64
|
tokens.set(runtimeIdentity, token);
|
|
65
|
+
persist();
|
|
19
66
|
},
|
|
20
67
|
get(runtimeIdentity) {
|
|
21
68
|
return tokens.get(runtimeIdentity);
|
|
22
69
|
},
|
|
23
70
|
forget(runtimeIdentity) {
|
|
24
|
-
tokens.delete(runtimeIdentity)
|
|
25
|
-
|
|
26
|
-
clear() {
|
|
27
|
-
tokens.clear();
|
|
71
|
+
if (tokens.delete(runtimeIdentity))
|
|
72
|
+
persist();
|
|
28
73
|
},
|
|
29
74
|
};
|
|
30
75
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
});
|
|
52
|
-
try {
|
|
53
|
-
await connectWithRetry(connection, {
|
|
54
|
-
attempts: 20,
|
|
55
|
-
delayMs: 50,
|
|
56
|
-
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
57
|
-
});
|
|
58
|
-
rememberClaimedToken(options.tokens, record, prepared.identity.runtimeIdentity, prepared.token);
|
|
59
|
-
return connection;
|
|
60
|
-
}
|
|
61
|
-
catch (error) {
|
|
62
|
-
connection.close();
|
|
63
|
-
const afterFailure = await requireCurrentRecord(options.registry, record.subagentId, options.signal);
|
|
64
|
-
if (afterFailure.claim?.claimId !== prepared.claim.claimId) {
|
|
65
|
-
throw new Error(`Recovery authorization for ${record.subagentId} was consumed before the connection completed`, { cause: error });
|
|
66
|
-
}
|
|
67
|
-
if (!(await isRecordedRunnerConfirmedDead(afterFailure))) {
|
|
68
|
-
await options.registry.releaseClaim(record.subagentId, prepared.claim.claimId, options.signal);
|
|
69
|
-
throw new Error(`Runner ${record.subagentId} could not be reconnected and is not confirmed dead; replacement refused`, { cause: error });
|
|
70
|
-
}
|
|
71
|
-
record = afterFailure;
|
|
72
|
-
}
|
|
76
|
+
function readStoredTokens(file, diagnose) {
|
|
77
|
+
let raw;
|
|
78
|
+
try {
|
|
79
|
+
raw = readFileSync(file, "utf8");
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
const code = error.code;
|
|
83
|
+
if (code !== "ENOENT")
|
|
84
|
+
diagnose(`could not read bridge tokens: ${errorMessage(error)}`);
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
if (raw.trim() === "")
|
|
88
|
+
return [];
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(raw);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
diagnose(`bridge token file is not valid JSON: ${errorMessage(error)}`);
|
|
95
|
+
return [];
|
|
73
96
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return startClaimedRunner(options.registry, prepared.record, prepared.identity, prepared.token, prepared.claim.claimId, options.signal, options.tokens);
|
|
97
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
98
|
+
return [];
|
|
99
|
+
return Object.entries(parsed).filter((entry) => typeof entry[1] === "string");
|
|
78
100
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Mints the runtime identity and its bridge token, remembers the token so the child's very first
|
|
103
|
+
* hello can be authorized, and builds the one LaunchSpec for this presentation. The token travels
|
|
104
|
+
* in the spec's environment, because who starts the child differs: the parent starts a background
|
|
105
|
+
* child and the host starts a panel one.
|
|
106
|
+
*/
|
|
107
|
+
function planChildRuntime(record, tokens, presentation) {
|
|
108
|
+
const runtimeIdentity = randomUUID();
|
|
83
109
|
const token = randomBytes(32).toString("hex");
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
await chmod(directory, 0o700);
|
|
87
|
-
const endpoint = kind === "reconnect" && record.runtime !== undefined
|
|
88
|
-
? record.runtime.endpoint
|
|
89
|
-
: join(directory, `${record.subagentId}-${runtimeIdentity.slice(0, 12)}.sock`);
|
|
90
|
-
const claim = {
|
|
91
|
-
claimId: randomUUID(),
|
|
92
|
-
kind,
|
|
93
|
-
holderPid: process.pid,
|
|
94
|
-
runtimeIdentity,
|
|
95
|
-
endpoint,
|
|
96
|
-
controllerTokenHash: createHash("sha256").update(token).digest("hex"),
|
|
97
|
-
};
|
|
98
|
-
const claimed = await registry.claim(record.subagentId, record.revision, claim, expectedRuntimeIdentity, expectedClaimId, signal);
|
|
99
|
-
return {
|
|
100
|
-
record: claimed,
|
|
101
|
-
identity: {
|
|
102
|
-
parentSessionId: claimed.parentSessionId,
|
|
103
|
-
subagentId: claimed.subagentId,
|
|
104
|
-
runtimeIdentity,
|
|
105
|
-
endpoint,
|
|
106
|
-
token,
|
|
107
|
-
},
|
|
108
|
-
token,
|
|
109
|
-
claim,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
async function startClaimedRunner(registry, record, identity, token, claimId, signal, tokens) {
|
|
113
|
-
const launch = buildLaunchSpec({
|
|
110
|
+
const endpoint = parentBridgeEndpoint(record.parentSessionId);
|
|
111
|
+
const spec = buildLaunchSpec({
|
|
114
112
|
config: record.launchConfig,
|
|
115
113
|
invocation: record.launchConfig.invocation,
|
|
116
|
-
|
|
114
|
+
presentation,
|
|
117
115
|
persistence: record.persistence,
|
|
118
116
|
bridge: {
|
|
119
|
-
parentSessionId:
|
|
120
|
-
subagentId:
|
|
121
|
-
runtimeIdentity
|
|
122
|
-
endpoint
|
|
117
|
+
parentSessionId: record.parentSessionId,
|
|
118
|
+
subagentId: record.subagentId,
|
|
119
|
+
runtimeIdentity,
|
|
120
|
+
endpoint,
|
|
123
121
|
},
|
|
124
122
|
});
|
|
125
|
-
const directory = runtimeDirectory();
|
|
126
|
-
const jobPath = join(directory, `${record.subagentId}-${identity.runtimeIdentity}.json`);
|
|
127
|
-
const job = {
|
|
128
|
-
invocation: { command: launch.command, args: launch.argv },
|
|
129
|
-
cwd: launch.cwd,
|
|
130
|
-
sessionId: record.sessionId,
|
|
131
|
-
...(record.sessionPath === undefined ? {} : { sessionPath: record.sessionPath }),
|
|
132
|
-
claimId,
|
|
133
|
-
env: launch.env,
|
|
134
|
-
};
|
|
135
|
-
let child;
|
|
136
|
-
const connection = new RunnerConnection({ endpoint: identity.endpoint, identity, token });
|
|
137
|
-
try {
|
|
138
|
-
await writeFile(jobPath, `${JSON.stringify(job)}\n`, { mode: 0o600, flag: "wx" });
|
|
139
|
-
const runnerEntry = fileURLToPath(new URL("./runner-entry.js", import.meta.url));
|
|
140
|
-
child = spawn(process.execPath, [runnerEntry, "--job", jobPath], {
|
|
141
|
-
cwd: launch.cwd,
|
|
142
|
-
detached: true,
|
|
143
|
-
env: { ...process.env, ...withBridgeToken(launch.env, token) },
|
|
144
|
-
stdio: "ignore",
|
|
145
|
-
});
|
|
146
|
-
if (child.pid === undefined)
|
|
147
|
-
throw new Error(`Runner process for ${record.subagentId} has no PID`);
|
|
148
|
-
await registry.markClaimRunner(record.subagentId, claimId, child.pid, signal);
|
|
149
|
-
child.unref();
|
|
150
|
-
await connectWithRetry(connection, {
|
|
151
|
-
attempts: 120,
|
|
152
|
-
delayMs: 50,
|
|
153
|
-
...(signal === undefined ? {} : { signal }),
|
|
154
|
-
});
|
|
155
|
-
await unlink(jobPath).catch(() => undefined);
|
|
156
|
-
rememberClaimedToken(tokens, record, identity.runtimeIdentity, token);
|
|
157
|
-
return connection;
|
|
158
|
-
}
|
|
159
|
-
catch (error) {
|
|
160
|
-
connection.close();
|
|
161
|
-
child?.kill("SIGTERM");
|
|
162
|
-
await unlink(jobPath).catch(() => undefined);
|
|
163
|
-
const current = await registry.get(record.subagentId).catch(() => undefined);
|
|
164
|
-
if (current?.claim?.claimId === claimId) {
|
|
165
|
-
await registry.releaseClaim(record.subagentId, claimId).catch(() => undefined);
|
|
166
|
-
}
|
|
167
|
-
throw error;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
function rememberClaimedToken(tokens, record, runtimeIdentity, token) {
|
|
171
|
-
const previous = record.runtime?.runtimeIdentity;
|
|
172
123
|
tokens.remember(runtimeIdentity, token);
|
|
173
|
-
|
|
174
|
-
tokens.forget(previous);
|
|
124
|
+
return { runtimeIdentity, endpoint, spec: { ...spec, env: withBridgeToken(spec.env, token) } };
|
|
175
125
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
126
|
+
/** Evidence first: the record names the runtime that was asked for even if starting it fails. */
|
|
127
|
+
async function recordRuntimeEvidence(registry, record, plan) {
|
|
128
|
+
await registry.update(record.subagentId, undefined, (current) => ({
|
|
129
|
+
...current,
|
|
130
|
+
runtime: { runtimeIdentity: plan.runtimeIdentity, endpoint: plan.endpoint },
|
|
131
|
+
}));
|
|
181
132
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Starts one headless child Pi and waits for it to connect. Nothing is driven over its stdio: the
|
|
135
|
+
* process gets the parent's socket path and its own runtime token in the environment, and the
|
|
136
|
+
* bridge is the only control channel. A child that never connects is terminated here rather than
|
|
137
|
+
* left running with no way to reach it.
|
|
138
|
+
*/
|
|
139
|
+
export async function launchChild(options) {
|
|
140
|
+
const record = options.record;
|
|
141
|
+
const plan = planChildRuntime(record, options.tokens, "background");
|
|
142
|
+
await recordRuntimeEvidence(options.registry, record, plan);
|
|
143
|
+
const runtime = spawnChildRuntime({
|
|
144
|
+
command: plan.spec.command,
|
|
145
|
+
args: plan.spec.argv,
|
|
146
|
+
cwd: plan.spec.cwd,
|
|
147
|
+
env: { ...process.env, ...plan.spec.env },
|
|
148
|
+
...(options.diagnose === undefined ? {} : { diagnose: options.diagnose }),
|
|
189
149
|
});
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
150
|
+
let connected;
|
|
151
|
+
try {
|
|
152
|
+
connected = await options.waitForBridge(record.subagentId, options.signal);
|
|
193
153
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
...(placement.sessionPath === undefined
|
|
198
|
-
? {}
|
|
199
|
-
: {
|
|
200
|
-
sessionPath: placement.sessionPath,
|
|
201
|
-
launchConfig: { ...current.launchConfig, sessionPath: placement.sessionPath },
|
|
202
|
-
}),
|
|
203
|
-
}), record.runtime?.runtimeIdentity, signal);
|
|
204
|
-
}
|
|
205
|
-
async function clearDeadHolderClaim(registry, record, signal) {
|
|
206
|
-
const claim = record.claim;
|
|
207
|
-
if (claim === undefined)
|
|
208
|
-
return record;
|
|
209
|
-
if (!isPidConfirmedDead(claim.holderPid)) {
|
|
210
|
-
throw new Error(`Child ${record.subagentId} is already claimed by a live or unknown owner`);
|
|
154
|
+
catch (error) {
|
|
155
|
+
options.diagnose?.(`bridge wait failed: ${errorMessage(error)}`);
|
|
156
|
+
connected = false;
|
|
211
157
|
}
|
|
212
|
-
if (
|
|
213
|
-
|
|
158
|
+
if (connected)
|
|
159
|
+
return { handle: runtime };
|
|
160
|
+
await runtime.terminate();
|
|
161
|
+
if (runtime.alive) {
|
|
162
|
+
// The process outlived termination, so it is not gone and the caller keeps the handle: it is
|
|
163
|
+
// the only thing that can try again.
|
|
164
|
+
return {
|
|
165
|
+
handle: runtime,
|
|
166
|
+
failure: `Child ${record.subagentId} never connected to the parent bridge and did not stop (it may still be running)`,
|
|
167
|
+
};
|
|
214
168
|
}
|
|
215
|
-
|
|
169
|
+
options.tokens.forget(plan.runtimeIdentity);
|
|
170
|
+
return { failure: `Child ${record.subagentId} never connected to the parent bridge` };
|
|
216
171
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Opens one child in a host panel and waits for it to connect. The host owns this process, so the
|
|
174
|
+
* parent holds no runtime handle for it; a panel that never yields a bridge is closed again instead
|
|
175
|
+
* of being left as a process nobody can control.
|
|
176
|
+
*/
|
|
177
|
+
export async function openChildPanel(options) {
|
|
178
|
+
const record = options.record;
|
|
179
|
+
const plan = planChildRuntime(record, options.tokens, "panel");
|
|
180
|
+
await recordRuntimeEvidence(options.registry, record, plan);
|
|
181
|
+
let attachment;
|
|
223
182
|
try {
|
|
224
|
-
|
|
225
|
-
const identity = environment
|
|
226
|
-
.split("\0")
|
|
227
|
-
.filter(Boolean)
|
|
228
|
-
.map((entry) => {
|
|
229
|
-
const separator = entry.indexOf("=");
|
|
230
|
-
return [entry.slice(0, separator), entry.slice(separator + 1)];
|
|
231
|
-
})
|
|
232
|
-
.find(([key]) => key === "PI_SUBAGENTS_RUNTIME_ID")?.[1];
|
|
233
|
-
return identity !== runtime.runtimeIdentity;
|
|
183
|
+
attachment = await options.host.open(plan.spec);
|
|
234
184
|
}
|
|
235
185
|
catch (error) {
|
|
236
|
-
|
|
186
|
+
// Opening is not atomic: the host may have created the panel and started the process before
|
|
187
|
+
// failing to report it. The token is kept so a child that does come up can still be adopted,
|
|
188
|
+
// and the caller is told the runtime is unconfirmed instead of that nothing was started.
|
|
189
|
+
return {
|
|
190
|
+
unconfirmed: true,
|
|
191
|
+
failure: `Child ${record.subagentId} could not be opened in a ${options.host.kind} panel (${errorMessage(error)}); a panel may have been created, so stop the child before sending again`,
|
|
192
|
+
};
|
|
237
193
|
}
|
|
238
|
-
|
|
239
|
-
export function isPidConfirmedDead(pid) {
|
|
194
|
+
let connected;
|
|
240
195
|
try {
|
|
241
|
-
|
|
242
|
-
return false;
|
|
196
|
+
connected = await options.waitForBridge(record.subagentId, options.signal);
|
|
243
197
|
}
|
|
244
198
|
catch (error) {
|
|
245
|
-
|
|
199
|
+
options.diagnose?.(`bridge wait failed: ${errorMessage(error)}`);
|
|
200
|
+
connected = false;
|
|
201
|
+
}
|
|
202
|
+
if (connected)
|
|
203
|
+
return { handle: attachment };
|
|
204
|
+
// The child never dialed in, so the panel is closed again — and only a host that confirms it is
|
|
205
|
+
// gone counts. While it may still run, the caller keeps the handle and the token: the handle is
|
|
206
|
+
// what stops a second runtime from being started for the same session, and the token is what lets
|
|
207
|
+
// a child that does come up be adopted instead of lingering as a process nobody owns.
|
|
208
|
+
const cleanup = await attachment.cleanup();
|
|
209
|
+
const observed = await attachment.observe();
|
|
210
|
+
if (!(observed.known && !observed.alive)) {
|
|
211
|
+
return {
|
|
212
|
+
handle: attachment,
|
|
213
|
+
failure: `Child ${record.subagentId} never connected to the parent bridge and its panel could not be closed (${cleanup.stderr || "its state could not be observed"}); close it manually`,
|
|
214
|
+
};
|
|
246
215
|
}
|
|
216
|
+
options.tokens.forget(plan.runtimeIdentity);
|
|
217
|
+
return { failure: `Child ${record.subagentId} never connected to the parent bridge` };
|
|
247
218
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
219
|
+
/** Creates the runtime directory if needed; the socket itself is 0600 in a 0700 directory. */
|
|
220
|
+
export async function prepareRuntimeDirectory(diagnose) {
|
|
221
|
+
const directory = runtimeDirectory();
|
|
222
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
223
|
+
await chmod(directory, 0o700).catch((error) => {
|
|
224
|
+
diagnose?.(`could not restrict the runtime directory: ${errorMessage(error)}`);
|
|
225
|
+
});
|
|
226
|
+
return directory;
|
|
253
227
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ModelRegistryLike, ParentAgentDefaults } from "./agent-resolver.js";
|
|
2
|
-
import type { EffectiveLaunchConfig,
|
|
1
|
+
import type { ModelRegistryLike, ParentAgentDefaults, SkillCatalogEntry } from "./agent-resolver.js";
|
|
2
|
+
import type { EffectiveLaunchConfig, PersistenceState, PiInvocation, Presentation, SpawnSubagentInput, SubagentRecord } from "./domain.js";
|
|
3
3
|
import type { SubagentRegistry } from "./registry.js";
|
|
4
4
|
export interface SessionPlacement {
|
|
5
5
|
readonly sessionId: string;
|
|
@@ -20,11 +20,18 @@ export interface SessionFileIdentity {
|
|
|
20
20
|
readonly cwd: string;
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
|
-
* The
|
|
24
|
-
*
|
|
25
|
-
* as an explicit `--session-dir` so recovery can find the file again.
|
|
23
|
+
* The session directory Pi itself would use for this cwd. At the top level it holds the
|
|
24
|
+
* human's own sessions, which is why children never write there directly.
|
|
26
25
|
*/
|
|
27
26
|
export declare function resolveDefaultSessionDir(cwd: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* The session directory a delegated child writes to.
|
|
29
|
+
*
|
|
30
|
+
* Pi discovers sessions with a non-recursive `readdir` of the session directory, so a
|
|
31
|
+
* subdirectory keeps delegated sessions out of the human's session list while the child still
|
|
32
|
+
* receives that directory as an explicit `--session-dir` recovery can find again.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveSubagentSessionDir(cwd: string): string;
|
|
28
35
|
/** Verifies that a durable session file exists and still carries the expected identity. */
|
|
29
36
|
export declare function inspectSessionFile(path: string, sessionId: string): Promise<SessionFileIdentity>;
|
|
30
37
|
/** Finds an already-flushed session file for an id. Empty files are not sessions yet. */
|
|
@@ -35,18 +42,27 @@ export declare function findSessionFile(sessionDir: string, sessionId: string):
|
|
|
35
42
|
* random id; a flushed session must still prove its identity on disk.
|
|
36
43
|
*/
|
|
37
44
|
export declare function planSessionPlacement(options: PlanSessionPlacementOptions): Promise<SessionPlacement>;
|
|
38
|
-
export declare function
|
|
45
|
+
export declare function resetSubagentIdCounter(value?: number): void;
|
|
46
|
+
export declare function createSubagentId(existingIds?: readonly string[]): string;
|
|
39
47
|
export interface ResolveSubagentLaunchOptions {
|
|
40
48
|
readonly input: SpawnSubagentInput;
|
|
41
49
|
/** Parent agent cwd, used when the tool call does not ask for another one. */
|
|
42
50
|
readonly cwd: string;
|
|
43
51
|
readonly parent: ParentAgentDefaults;
|
|
44
52
|
readonly modelRegistry: ModelRegistryLike;
|
|
53
|
+
/** Parent's loaded skills, the catalog this launch's skill names resolve against. */
|
|
54
|
+
readonly skillCatalog?: readonly SkillCatalogEntry[];
|
|
55
|
+
/** Diagnostics for the caller to surface; omitted means they are dropped. */
|
|
56
|
+
readonly onWarning?: (message: string) => void;
|
|
45
57
|
readonly invocation?: PiInvocation;
|
|
46
58
|
readonly bridgeExtensionPath?: string;
|
|
47
59
|
readonly homeDirectory?: string;
|
|
48
60
|
readonly subagentId?: string;
|
|
49
61
|
readonly sessionId?: string;
|
|
62
|
+
readonly existingSubagentIds?: readonly string[];
|
|
63
|
+
readonly enforceEnabled?: boolean;
|
|
64
|
+
readonly sessionPath?: string;
|
|
65
|
+
readonly referencedSessionPath?: string;
|
|
50
66
|
}
|
|
51
67
|
/**
|
|
52
68
|
* One resolution chain for a spawn: agent definition + explicit parent defaults
|
|
@@ -59,7 +75,7 @@ export interface PersistSubagentIntentOptions {
|
|
|
59
75
|
readonly parentSessionId: string;
|
|
60
76
|
readonly task: string;
|
|
61
77
|
readonly launchConfig: EffectiveLaunchConfig;
|
|
62
|
-
readonly
|
|
78
|
+
readonly presentation?: Presentation;
|
|
63
79
|
readonly now?: () => Date;
|
|
64
80
|
}
|
|
65
81
|
/**
|