@hheei/pi-subagents 0.1.2

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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +161 -0
  3. package/dist/agent-resolver.d.ts +37 -0
  4. package/dist/agent-resolver.js +232 -0
  5. package/dist/child-bridge.d.ts +41 -0
  6. package/dist/child-bridge.js +214 -0
  7. package/dist/child-nudge.d.ts +24 -0
  8. package/dist/child-nudge.js +102 -0
  9. package/dist/commands.d.ts +6 -0
  10. package/dist/commands.js +155 -0
  11. package/dist/connector.d.ts +53 -0
  12. package/dist/connector.js +283 -0
  13. package/dist/diagnostics.d.ts +10 -0
  14. package/dist/diagnostics.js +20 -0
  15. package/dist/domain.d.ts +192 -0
  16. package/dist/domain.js +63 -0
  17. package/dist/extension.d.ts +2 -0
  18. package/dist/extension.js +145 -0
  19. package/dist/host-adapter.d.ts +76 -0
  20. package/dist/host-adapter.js +323 -0
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +1 -0
  23. package/dist/json-lines.d.ts +14 -0
  24. package/dist/json-lines.js +94 -0
  25. package/dist/launch-spec.d.ts +47 -0
  26. package/dist/launch-spec.js +149 -0
  27. package/dist/manager.d.ts +101 -0
  28. package/dist/manager.js +802 -0
  29. package/dist/pause-gate.d.ts +14 -0
  30. package/dist/pause-gate.js +64 -0
  31. package/dist/protocol.d.ts +141 -0
  32. package/dist/protocol.js +168 -0
  33. package/dist/registry.d.ts +41 -0
  34. package/dist/registry.js +575 -0
  35. package/dist/rpc-adapter.d.ts +40 -0
  36. package/dist/rpc-adapter.js +194 -0
  37. package/dist/rpc-events.d.ts +8 -0
  38. package/dist/rpc-events.js +29 -0
  39. package/dist/runner-entry.d.ts +24 -0
  40. package/dist/runner-entry.js +166 -0
  41. package/dist/runner.d.ts +52 -0
  42. package/dist/runner.js +930 -0
  43. package/dist/runtime.d.ts +22 -0
  44. package/dist/runtime.js +253 -0
  45. package/dist/session-bootstrap.d.ts +69 -0
  46. package/dist/session-bootstrap.js +195 -0
  47. package/dist/state.d.ts +15 -0
  48. package/dist/state.js +168 -0
  49. package/dist/tools.d.ts +9 -0
  50. package/dist/tools.js +158 -0
  51. package/dist/widget.d.ts +16 -0
  52. package/dist/widget.js +158 -0
  53. package/package.json +53 -0
@@ -0,0 +1,22 @@
1
+ import type { SubagentRecord } from "./domain.js";
2
+ import type { RunnerLike } from "./manager.js";
3
+ import type { SubagentRegistry } from "./registry.js";
4
+ export interface RuntimeTokenStore {
5
+ remember(runtimeIdentity: string, token: string): void;
6
+ get(runtimeIdentity: string): string | undefined;
7
+ forget(runtimeIdentity: string): void;
8
+ clear(): void;
9
+ }
10
+ export interface LaunchDetachedRunnerOptions {
11
+ readonly registry: SubagentRegistry;
12
+ readonly record: SubagentRecord;
13
+ readonly tokens: RuntimeTokenStore;
14
+ readonly signal?: AbortSignal;
15
+ }
16
+ export declare function createRuntimeTokenStore(): RuntimeTokenStore;
17
+ /** Starts the package runner as a detached process and returns its authenticated controller. */
18
+ export declare function launchDetachedRunner(options: LaunchDetachedRunnerOptions): Promise<RunnerLike>;
19
+ /** Reconnects to a surviving writer, launching a replacement only after its PID is confirmed dead. */
20
+ export declare function recoverDetachedRunner(options: LaunchDetachedRunnerOptions): Promise<RunnerLike>;
21
+ export declare function isRecordedRunnerConfirmedDead(record: SubagentRecord): Promise<boolean>;
22
+ export declare function isPidConfirmedDead(pid: number): boolean;
@@ -0,0 +1,253 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
3
+ import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { connectWithRetry, RunnerConnection } from "./connector.js";
8
+ import { buildLaunchSpec, withBridgeToken } from "./launch-spec.js";
9
+ import { planSessionPlacement } from "./session-bootstrap.js";
10
+ function runtimeDirectory() {
11
+ const user = typeof process.getuid === "function" ? process.getuid() : "user";
12
+ return join(tmpdir(), `pi-subagents-${user}`);
13
+ }
14
+ export function createRuntimeTokenStore() {
15
+ const tokens = new Map();
16
+ return {
17
+ remember(runtimeIdentity, token) {
18
+ tokens.set(runtimeIdentity, token);
19
+ },
20
+ get(runtimeIdentity) {
21
+ return tokens.get(runtimeIdentity);
22
+ },
23
+ forget(runtimeIdentity) {
24
+ tokens.delete(runtimeIdentity);
25
+ },
26
+ clear() {
27
+ tokens.clear();
28
+ },
29
+ };
30
+ }
31
+ /** Starts the package runner as a detached process and returns its authenticated controller. */
32
+ export async function launchDetachedRunner(options) {
33
+ const record = await requireCurrentRecord(options.registry, options.record.subagentId, options.signal);
34
+ const prepared = await claimRuntime(options.registry, record, "replacement", undefined, undefined, options.signal);
35
+ return startClaimedRunner(options.registry, prepared.record, prepared.identity, prepared.token, prepared.claim.claimId, options.signal, options.tokens);
36
+ }
37
+ /** Reconnects to a surviving writer, launching a replacement only after its PID is confirmed dead. */
38
+ export async function recoverDetachedRunner(options) {
39
+ let record = await requireCurrentRecord(options.registry, options.record.subagentId, options.signal);
40
+ if (record.intent === "stopped")
41
+ throw new Error(`Child ${record.subagentId} is stopped`);
42
+ record = await clearDeadHolderClaim(options.registry, record, options.signal);
43
+ if (record.runtime !== undefined) {
44
+ const prepared = await claimRuntime(options.registry, record, "reconnect", record.runtime.runtimeIdentity, record.claim?.claimId, options.signal);
45
+ const connection = new RunnerConnection({
46
+ endpoint: prepared.identity.endpoint,
47
+ identity: prepared.identity,
48
+ token: prepared.token,
49
+ role: "recovery",
50
+ claimId: prepared.claim.claimId,
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
+ }
73
+ }
74
+ const expectedRuntimeIdentity = record.runtime?.runtimeIdentity;
75
+ record = await refreshSessionPlacement(options.registry, record, options.signal);
76
+ const prepared = await claimRuntime(options.registry, record, "replacement", expectedRuntimeIdentity, record.claim?.claimId, options.signal);
77
+ return startClaimedRunner(options.registry, prepared.record, prepared.identity, prepared.token, prepared.claim.claimId, options.signal, options.tokens);
78
+ }
79
+ async function claimRuntime(registry, record, kind, expectedRuntimeIdentity, expectedClaimId, signal) {
80
+ const runtimeIdentity = kind === "reconnect" ? expectedRuntimeIdentity : randomUUID();
81
+ if (runtimeIdentity === undefined)
82
+ throw new Error("Reconnect requires a runtime identity");
83
+ const token = randomBytes(32).toString("hex");
84
+ const directory = runtimeDirectory();
85
+ await mkdir(directory, { recursive: true, mode: 0o700 });
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({
114
+ config: record.launchConfig,
115
+ invocation: record.launchConfig.invocation,
116
+ mode: "rpc",
117
+ persistence: record.persistence,
118
+ bridge: {
119
+ parentSessionId: identity.parentSessionId,
120
+ subagentId: identity.subagentId,
121
+ runtimeIdentity: identity.runtimeIdentity,
122
+ endpoint: identity.endpoint,
123
+ },
124
+ });
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
+ tokens.remember(runtimeIdentity, token);
173
+ if (previous !== undefined && previous !== runtimeIdentity)
174
+ tokens.forget(previous);
175
+ }
176
+ async function requireCurrentRecord(registry, id, signal) {
177
+ const record = await registry.get(id, signal);
178
+ if (record === undefined)
179
+ throw new Error(`Child ${id} disappeared from the registry`);
180
+ return record;
181
+ }
182
+ async function refreshSessionPlacement(registry, record, signal) {
183
+ const placement = await planSessionPlacement({
184
+ sessionId: record.sessionId,
185
+ cwd: record.cwd,
186
+ sessionDir: record.launchConfig.sessionDir,
187
+ persistence: record.persistence,
188
+ ...(record.sessionPath === undefined ? {} : { sessionPath: record.sessionPath }),
189
+ });
190
+ if (placement.persistence === record.persistence &&
191
+ placement.sessionPath === record.sessionPath) {
192
+ return record;
193
+ }
194
+ return registry.update(record.subagentId, record.revision, (current) => ({
195
+ ...current,
196
+ persistence: placement.persistence,
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`);
211
+ }
212
+ if (claim.runnerPid !== undefined && !isPidConfirmedDead(claim.runnerPid)) {
213
+ throw new Error(`Child ${record.subagentId} has a live or unknown claimed runner`);
214
+ }
215
+ return registry.releaseClaim(record.subagentId, claim.claimId, signal);
216
+ }
217
+ export async function isRecordedRunnerConfirmedDead(record) {
218
+ const runtime = record.runtime;
219
+ if (runtime?.pid === undefined)
220
+ return false;
221
+ if (process.platform !== "linux")
222
+ return isPidConfirmedDead(runtime.pid);
223
+ try {
224
+ const environment = await readFile(`/proc/${runtime.pid}/environ`, "utf8");
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;
234
+ }
235
+ catch (error) {
236
+ return isErrno(error, "ENOENT");
237
+ }
238
+ }
239
+ export function isPidConfirmedDead(pid) {
240
+ try {
241
+ process.kill(pid, 0);
242
+ return false;
243
+ }
244
+ catch (error) {
245
+ return isErrno(error, "ESRCH");
246
+ }
247
+ }
248
+ function isErrno(value, code) {
249
+ return (typeof value === "object" &&
250
+ value !== null &&
251
+ "code" in value &&
252
+ value.code === code);
253
+ }
@@ -0,0 +1,69 @@
1
+ import type { ModelRegistryLike, ParentAgentDefaults } from "./agent-resolver.js";
2
+ import type { EffectiveLaunchConfig, ExecutionMode, PersistenceState, PiInvocation, SpawnSubagentInput, SubagentRecord } from "./domain.js";
3
+ import type { SubagentRegistry } from "./registry.js";
4
+ export interface SessionPlacement {
5
+ readonly sessionId: string;
6
+ readonly sessionDir: string;
7
+ /** Present only when a durable session file already exists for this id. */
8
+ readonly sessionPath?: string;
9
+ readonly persistence: PersistenceState;
10
+ }
11
+ export interface PlanSessionPlacementOptions {
12
+ readonly sessionId: string;
13
+ readonly cwd: string;
14
+ readonly sessionDir?: string;
15
+ readonly sessionPath?: string;
16
+ readonly persistence?: PersistenceState;
17
+ }
18
+ export interface SessionFileIdentity {
19
+ readonly sessionId: string;
20
+ readonly cwd: string;
21
+ }
22
+ /**
23
+ * The Pi session directory a child would use by default for its cwd. It is asked
24
+ * of Pi itself instead of re-deriving the layout, and is always passed to the child
25
+ * as an explicit `--session-dir` so recovery can find the file again.
26
+ */
27
+ export declare function resolveDefaultSessionDir(cwd: string): string;
28
+ /** Verifies that a durable session file exists and still carries the expected identity. */
29
+ export declare function inspectSessionFile(path: string, sessionId: string): Promise<SessionFileIdentity>;
30
+ /** Finds an already-flushed session file for an id. Empty files are not sessions yet. */
31
+ export declare function findSessionFile(sessionDir: string, sessionId: string): Promise<string | undefined>;
32
+ /**
33
+ * Decides whether a child session is already durable. A never-flushed session keeps
34
+ * its recorded id and gets no path, so nothing can open an absent path and mint a
35
+ * random id; a flushed session must still prove its identity on disk.
36
+ */
37
+ export declare function planSessionPlacement(options: PlanSessionPlacementOptions): Promise<SessionPlacement>;
38
+ export declare function createSubagentId(): string;
39
+ export interface ResolveSubagentLaunchOptions {
40
+ readonly input: SpawnSubagentInput;
41
+ /** Parent agent cwd, used when the tool call does not ask for another one. */
42
+ readonly cwd: string;
43
+ readonly parent: ParentAgentDefaults;
44
+ readonly modelRegistry: ModelRegistryLike;
45
+ readonly invocation?: PiInvocation;
46
+ readonly bridgeExtensionPath?: string;
47
+ readonly homeDirectory?: string;
48
+ readonly subagentId?: string;
49
+ readonly sessionId?: string;
50
+ }
51
+ /**
52
+ * One resolution chain for a spawn: agent definition + explicit parent defaults
53
+ * become a single immutable, non-secret launch configuration. Child and session ids
54
+ * are minted here, before any process exists.
55
+ */
56
+ export declare function resolveSubagentLaunch(options: ResolveSubagentLaunchOptions): Promise<EffectiveLaunchConfig>;
57
+ export interface PersistSubagentIntentOptions {
58
+ readonly registry: SubagentRegistry;
59
+ readonly parentSessionId: string;
60
+ readonly task: string;
61
+ readonly launchConfig: EffectiveLaunchConfig;
62
+ readonly mode?: ExecutionMode;
63
+ readonly now?: () => Date;
64
+ }
65
+ /**
66
+ * Writes the spawn intent before anything is launched. Registry failure propagates,
67
+ * so a child is never started from intent that could not be recovered.
68
+ */
69
+ export declare function persistSubagentIntent(options: PersistSubagentIntentOptions): Promise<SubagentRecord>;
@@ -0,0 +1,195 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { open, readdir, stat } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
5
+ import { resolveAgent } from "./agent-resolver.js";
6
+ import { isRecord, isSessionId } from "./domain.js";
7
+ import { assembleChildPrompt, resolveBridgeExtensionPath, resolvePiInvocation, } from "./launch-spec.js";
8
+ /** Bounded read for a session header; the first line holds the whole identity of the file. */
9
+ const HEADER_READ_BYTES = 64 * 1024;
10
+ /**
11
+ * The Pi session directory a child would use by default for its cwd. It is asked
12
+ * of Pi itself instead of re-deriving the layout, and is always passed to the child
13
+ * as an explicit `--session-dir` so recovery can find the file again.
14
+ */
15
+ export function resolveDefaultSessionDir(cwd) {
16
+ return SessionManager.create(cwd).getSessionDir();
17
+ }
18
+ async function readSessionHeader(path) {
19
+ const handle = await open(path, "r");
20
+ try {
21
+ const buffer = Buffer.alloc(HEADER_READ_BYTES);
22
+ const { bytesRead } = await handle.read(buffer, 0, HEADER_READ_BYTES, 0);
23
+ const chunk = buffer.subarray(0, bytesRead).toString("utf8");
24
+ const end = chunk.indexOf("\n");
25
+ const line = (end === -1 ? chunk : chunk.slice(0, end)).trim();
26
+ if (line === "") {
27
+ throw new Error(`Session file ${path} has no header`);
28
+ }
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(line);
32
+ }
33
+ catch (error) {
34
+ throw new Error(`Session file ${path} has an unreadable header`, { cause: error });
35
+ }
36
+ if (!isRecord(parsed) || parsed.type !== "session") {
37
+ throw new Error(`Session file ${path} is not a Pi session`);
38
+ }
39
+ if (typeof parsed.id !== "string" || !isSessionId(parsed.id)) {
40
+ throw new Error(`Session file ${path} has an invalid session id`);
41
+ }
42
+ return { sessionId: parsed.id, cwd: typeof parsed.cwd === "string" ? parsed.cwd : "" };
43
+ }
44
+ finally {
45
+ await handle.close();
46
+ }
47
+ }
48
+ /** Verifies that a durable session file exists and still carries the expected identity. */
49
+ export async function inspectSessionFile(path, sessionId) {
50
+ const header = await readSessionHeader(path);
51
+ if (header.sessionId !== sessionId) {
52
+ throw new Error(`Session file ${path} belongs to session ${header.sessionId}, not ${sessionId}`);
53
+ }
54
+ return header;
55
+ }
56
+ /** Finds an already-flushed session file for an id. Empty files are not sessions yet. */
57
+ export async function findSessionFile(sessionDir, sessionId) {
58
+ let names;
59
+ try {
60
+ names = await readdir(sessionDir);
61
+ }
62
+ catch (error) {
63
+ if (isRecord(error) && error.code === "ENOENT")
64
+ return undefined;
65
+ throw error;
66
+ }
67
+ const candidates = names
68
+ .filter((name) => name === `${sessionId}.jsonl` || name.endsWith(`_${sessionId}.jsonl`))
69
+ .sort((left, right) => left.localeCompare(right));
70
+ for (const name of candidates) {
71
+ const path = join(sessionDir, name);
72
+ const info = await stat(path);
73
+ if (info.size === 0)
74
+ continue;
75
+ await inspectSessionFile(path, sessionId);
76
+ return path;
77
+ }
78
+ return undefined;
79
+ }
80
+ /**
81
+ * Decides whether a child session is already durable. A never-flushed session keeps
82
+ * its recorded id and gets no path, so nothing can open an absent path and mint a
83
+ * random id; a flushed session must still prove its identity on disk.
84
+ */
85
+ export async function planSessionPlacement(options) {
86
+ if (!isSessionId(options.sessionId)) {
87
+ throw new Error(`Invalid session id ${options.sessionId}`);
88
+ }
89
+ const sessionDir = options.sessionDir ?? resolveDefaultSessionDir(options.cwd);
90
+ const sessionPath = options.sessionPath;
91
+ if (sessionPath !== undefined &&
92
+ (await stat(sessionPath).then(() => true, () => false))) {
93
+ await inspectSessionFile(sessionPath, options.sessionId);
94
+ return { sessionId: options.sessionId, sessionDir, sessionPath, persistence: "flushed" };
95
+ }
96
+ if (sessionPath !== undefined && options.persistence === "flushed") {
97
+ throw new Error(`Recorded session file is missing: ${sessionPath}`);
98
+ }
99
+ const discovered = await findSessionFile(sessionDir, options.sessionId);
100
+ if (discovered !== undefined) {
101
+ return {
102
+ sessionId: options.sessionId,
103
+ sessionDir,
104
+ sessionPath: discovered,
105
+ persistence: "flushed",
106
+ };
107
+ }
108
+ return { sessionId: options.sessionId, sessionDir, persistence: "never_flushed" };
109
+ }
110
+ export function createSubagentId() {
111
+ return `sa_${randomUUID().replace(/-/gu, "").slice(0, 12)}`;
112
+ }
113
+ /**
114
+ * One resolution chain for a spawn: agent definition + explicit parent defaults
115
+ * become a single immutable, non-secret launch configuration. Child and session ids
116
+ * are minted here, before any process exists.
117
+ */
118
+ export async function resolveSubagentLaunch(options) {
119
+ const task = options.input.task.trim();
120
+ if (task === "")
121
+ throw new Error("spawn_subagent requires a non-empty task");
122
+ const name = options.input.agent?.trim();
123
+ if (name === undefined || name === "") {
124
+ throw new Error("spawn_subagent requires an explicit agent name; V1 has no built-in default agent");
125
+ }
126
+ const cwd = resolve(options.input.cwd?.trim() || options.cwd);
127
+ const subagentId = options.subagentId ?? createSubagentId();
128
+ const sessionId = options.sessionId ?? randomUUID();
129
+ const bridgeExtensionPath = options.bridgeExtensionPath ?? resolveBridgeExtensionPath();
130
+ const policy = await resolveAgent({
131
+ name,
132
+ cwd,
133
+ modelRegistry: options.modelRegistry,
134
+ parent: options.parent,
135
+ bridgeExtensionPath,
136
+ ...(options.homeDirectory === undefined ? {} : { homeDirectory: options.homeDirectory }),
137
+ });
138
+ return Object.freeze({
139
+ subagentId,
140
+ invocation: options.invocation ?? resolvePiInvocation(),
141
+ cwd,
142
+ sessionId,
143
+ sessionDir: resolveDefaultSessionDir(cwd),
144
+ agent: policy.agent,
145
+ model: policy.model,
146
+ thinking: policy.thinking,
147
+ tools: policy.tools,
148
+ excludeTools: policy.excludeTools,
149
+ extensions: policy.extensions,
150
+ skills: policy.skills,
151
+ prompt: assembleChildPrompt(policy.agent.instructions),
152
+ bridgeExtensionPath,
153
+ interactive: policy.interactive,
154
+ });
155
+ }
156
+ /**
157
+ * Writes the spawn intent before anything is launched. Registry failure propagates,
158
+ * so a child is never started from intent that could not be recovered.
159
+ */
160
+ export async function persistSubagentIntent(options) {
161
+ const task = options.task.trim();
162
+ if (task === "")
163
+ throw new Error("Cannot persist a spawn intent without a task");
164
+ if (options.registry.parentSessionId !== options.parentSessionId) {
165
+ throw new Error(`Registry belongs to parent session ${options.registry.parentSessionId}, not ${options.parentSessionId}`);
166
+ }
167
+ const { launchConfig } = options;
168
+ const placement = await planSessionPlacement({
169
+ sessionId: launchConfig.sessionId,
170
+ cwd: launchConfig.cwd,
171
+ sessionDir: launchConfig.sessionDir,
172
+ ...(launchConfig.sessionPath === undefined ? {} : { sessionPath: launchConfig.sessionPath }),
173
+ });
174
+ const timestamp = (options.now ?? (() => new Date()))().toISOString();
175
+ return options.registry.create({
176
+ subagentId: launchConfig.subagentId,
177
+ parentSessionId: options.parentSessionId,
178
+ revision: 1,
179
+ createdAt: timestamp,
180
+ updatedAt: timestamp,
181
+ sessionId: placement.sessionId,
182
+ ...(placement.sessionPath === undefined ? {} : { sessionPath: placement.sessionPath }),
183
+ cwd: launchConfig.cwd,
184
+ initialTask: task,
185
+ intent: "active",
186
+ state: "starting",
187
+ mode: options.mode ?? "rpc",
188
+ persistence: placement.persistence,
189
+ launchConfig: Object.freeze({
190
+ ...launchConfig,
191
+ ...(placement.sessionPath === undefined ? {} : { sessionPath: placement.sessionPath }),
192
+ }),
193
+ unacknowledgedInput: task,
194
+ });
195
+ }
@@ -0,0 +1,15 @@
1
+ import type { SubagentState, UsageSummary } from "./domain.js";
2
+ export interface StateSnapshot {
3
+ readonly state: SubagentState;
4
+ readonly summary?: string;
5
+ readonly interrupted?: string;
6
+ readonly usage: UsageSummary;
7
+ }
8
+ export interface StateProjector {
9
+ applyEvent(event: unknown): StateSnapshot;
10
+ rebuild(entries: readonly unknown[]): StateSnapshot;
11
+ snapshot(): StateSnapshot;
12
+ }
13
+ export declare function summarizeCurrentBranch(entries: readonly unknown[]): string | undefined;
14
+ export declare function aggregateUsage(entries: readonly unknown[]): UsageSummary;
15
+ export declare function createStateProjector(initialState?: SubagentState): StateProjector;