pi-agent-replicate 0.0.0-stage → 1.4.1

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 (82) hide show
  1. package/AGENTS.md +27 -0
  2. package/CHANGELOG.md +152 -0
  3. package/CONTEXT.md +63 -0
  4. package/LICENSE +21 -0
  5. package/README.md +68 -2
  6. package/RELEASING.md +132 -0
  7. package/assets/banner.png +0 -0
  8. package/config.json.example +6 -0
  9. package/docs/README.md +23 -0
  10. package/docs/adr/0003-installable-role-packs.md +29 -0
  11. package/docs/adr/0008-adopt-pi-only-subagent-execution.md +22 -0
  12. package/docs/adr/0009-durable-recursive-subagent-ownership.md +23 -0
  13. package/docs/adr/0010-public-subagent-tree-registry.md +16 -0
  14. package/docs/configuration.md +14 -0
  15. package/docs/development.md +27 -0
  16. package/docs/public-subagent-tree-api.md +58 -0
  17. package/docs/retained-roles.md +71 -0
  18. package/docs/settled-lifecycle-contract.md +71 -0
  19. package/docs/worktree-subagents.md +58 -0
  20. package/examples/role-pack/extension.ts +18 -0
  21. package/examples/role-pack/package.json +16 -0
  22. package/examples/role-pack/roles/example-reviewer.md +12 -0
  23. package/index.d.ts +116 -0
  24. package/index.js +1064 -0
  25. package/index.ts +155 -0
  26. package/package.json +70 -4
  27. package/pi-extension/subagents/activity.js +575 -0
  28. package/pi-extension/subagents/activity.ts +980 -0
  29. package/pi-extension/subagents/completion.js +184 -0
  30. package/pi-extension/subagents/completion.ts +229 -0
  31. package/pi-extension/subagents/delivery-drain.js +15 -0
  32. package/pi-extension/subagents/delivery-drain.ts +20 -0
  33. package/pi-extension/subagents/delivery-files.js +86 -0
  34. package/pi-extension/subagents/delivery-files.ts +105 -0
  35. package/pi-extension/subagents/delivery-log.js +51 -0
  36. package/pi-extension/subagents/delivery-log.ts +51 -0
  37. package/pi-extension/subagents/delivery-payload.ts +55 -0
  38. package/pi-extension/subagents/delivery.ts +219 -0
  39. package/pi-extension/subagents/execution-owner.js +30 -0
  40. package/pi-extension/subagents/execution-owner.ts +38 -0
  41. package/pi-extension/subagents/herdr.js +645 -0
  42. package/pi-extension/subagents/herdr.ts +927 -0
  43. package/pi-extension/subagents/index.ts +3400 -0
  44. package/pi-extension/subagents/launch.js +774 -0
  45. package/pi-extension/subagents/launch.ts +1124 -0
  46. package/pi-extension/subagents/lifecycle.js +477 -0
  47. package/pi-extension/subagents/lifecycle.ts +582 -0
  48. package/pi-extension/subagents/lineage.js +806 -0
  49. package/pi-extension/subagents/lineage.ts +841 -0
  50. package/pi-extension/subagents/replica-background.js +32 -0
  51. package/pi-extension/subagents/replica-background.ts +38 -0
  52. package/pi-extension/subagents/replica-branch.ts +45 -0
  53. package/pi-extension/subagents/replica-directory-settings.ts +51 -0
  54. package/pi-extension/subagents/replica-history.js +22 -0
  55. package/pi-extension/subagents/replica-history.ts +21 -0
  56. package/pi-extension/subagents/replica-work.js +326 -0
  57. package/pi-extension/subagents/replica-work.ts +336 -0
  58. package/pi-extension/subagents/replica-worker.js +74 -0
  59. package/pi-extension/subagents/replica-worker.ts +71 -0
  60. package/pi-extension/subagents/replication.ts +99 -0
  61. package/pi-extension/subagents/result-delivery-queue.ts +33 -0
  62. package/pi-extension/subagents/role-catalog.js +123 -0
  63. package/pi-extension/subagents/role-catalog.ts +24 -0
  64. package/pi-extension/subagents/runtime-routing.js +154 -0
  65. package/pi-extension/subagents/runtime-routing.ts +239 -0
  66. package/pi-extension/subagents/session-binding.js +346 -0
  67. package/pi-extension/subagents/session-binding.ts +339 -0
  68. package/pi-extension/subagents/session-work.js +219 -0
  69. package/pi-extension/subagents/session-work.ts +167 -0
  70. package/pi-extension/subagents/session.js +318 -0
  71. package/pi-extension/subagents/session.ts +448 -0
  72. package/pi-extension/subagents/settled-contract.js +47 -0
  73. package/pi-extension/subagents/settled-contract.ts +175 -0
  74. package/pi-extension/subagents/source-state.js +180 -0
  75. package/pi-extension/subagents/source-state.ts +184 -0
  76. package/pi-extension/subagents/status.ts +493 -0
  77. package/pi-extension/subagents/subagent-done.js +805 -0
  78. package/pi-extension/subagents/subagent-done.ts +878 -0
  79. package/pi-extension/subagents/terminal.js +111 -0
  80. package/pi-extension/subagents/terminal.ts +208 -0
  81. package/pi-extension/subagents/watchdog.js +122 -0
  82. package/pi-extension/subagents/watchdog.ts +171 -0
@@ -0,0 +1,336 @@
1
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readlinkSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { dirname, join } from "node:path";
4
+ import { writeBindingFile, readSessionBinding, childSessionLocation, sessionLocationAlive, sessionOwnsPane, launchOwnsPane, type SessionLocation, type LaunchPaneOwnership } from "./session-binding.ts";
5
+ import { closeHerdrSurface, getHerdrPaneProcessInfo, listHerdrWorktrees } from "./herdr.ts";
6
+ import { applySourceJoin, planSourceJoin, snapshotSource, type SourceJoinReady, type SourceSnapshot } from "./source-state.ts";
7
+ import type { WorktreeLaunch } from "./launch.ts";
8
+ import { processIncarnationAlive, withLineageMutationGate, type ProcessIncarnation } from "./lineage.ts";
9
+
10
+ /** Durable terminal task facts, separate from the later integration outcome. */
11
+ export interface ReplicaCompletion {
12
+ name: string;
13
+ task: string;
14
+ summary: string;
15
+ exitCode: number;
16
+ elapsed: number;
17
+ error?: string;
18
+ errorMessage?: string;
19
+ historyPath?: string;
20
+ /** Why post-fork answers could not be safely exported; never blocks settlement. */
21
+ historyError?: string;
22
+ }
23
+
24
+ /** One owned attempt. Its source/session checkpoint survives worktree cleanup. */
25
+ export interface ReplicaWork {
26
+ version: 1;
27
+ id: string;
28
+ parentSessionId: string;
29
+ mode?: "worktree" | "shared";
30
+ /** Launch directory and pane for shared replicas. */
31
+ sharedCwd?: string;
32
+ sharedPane?: string;
33
+ fork?: SourceSnapshot;
34
+ /** Original fork basis for outstanding work, or the last already-integrated checkpoint. */
35
+ joinBase?: string;
36
+ worktree?: WorktreeLaunch;
37
+ sessionFile?: string;
38
+ /** Durable JSONL copy outside the disposable checkout. */
39
+ sessionCheckpoint?: string;
40
+ /** Session-copy failure; source work is still checkpointed and retained. */
41
+ sessionCheckpointError?: string;
42
+ worktreeLocation?: SessionLocation;
43
+ /** Original allocated shell, recorded by the parent before running Pi. */
44
+ launchPane?: LaunchPaneOwnership;
45
+ checkpoint?: SourceSnapshot;
46
+ checkpointError?: string;
47
+ completion?: ReplicaCompletion;
48
+ joinPlan?: SourceJoinReady;
49
+ integration: { status: "pending" | "applied" | "shared" | "conflict" | "blocked" | "not-attempted"; details?: string };
50
+ cleaned?: boolean;
51
+ /** The next attempt in this continuation; completed chains resolve to their latest state. */
52
+ continuationAttempt?: string;
53
+ /** Owner of an unpublished next attempt; only a proven dead process can release it. */
54
+ continuationOwner?: ProcessIncarnation;
55
+ launchError?: string;
56
+ /** Exact failing invocation, for replay acknowledgement of its native tool error. */
57
+ launchErrorToolCallId?: string;
58
+ /** Saved inherited runtime, independent of the requesting parent's current configuration. */
59
+ runtime?: { systemPrompt: string; extensionArgs: string[]; provider: string; modelId: string; thinking: string; tools: string[] };
60
+ }
61
+
62
+ /** Resolve an attempt only inside the session's durable binding store. */
63
+ export function replicaWorkFile(storeDir: string, id: string): string {
64
+ if (!/^[a-zA-Z0-9-]+$/.test(id)) throw new Error("Invalid replica continuation ID.");
65
+ return join(storeDir, "replicas", `${id}.json`);
66
+ }
67
+
68
+ /** Load known ownership; missing or malformed records are never guessed recoverable. */
69
+ export function readReplicaWork(file: string): ReplicaWork {
70
+ const value = JSON.parse(readFileSync(file, "utf8")) as ReplicaWork;
71
+ if (value.version !== 1 || !value.id || !value.parentSessionId || !value.integration || (value.mode === "shared" ? !value.sharedCwd : ((value.mode != null && value.mode !== "worktree") || !value.fork?.commit || !value.joinBase)) ||
72
+ (value.continuationOwner && (!Number.isInteger(value.continuationOwner.pid) || value.continuationOwner.pid <= 0 || (value.continuationOwner.startTime != null && typeof value.continuationOwner.startTime !== "string")))) throw new Error("Invalid replica work record.");
73
+ return value;
74
+ }
75
+
76
+ /** Record the source basis before Herdr allocates any resources. */
77
+ export function createReplicaWork(file: string, id: string, parentSessionId: string, fork: SourceSnapshot, joinBase = fork.commit): void {
78
+ if (existsSync(file)) throw new Error("Replica work identity already exists.");
79
+ writeBindingFile(file, { version: 1, id, parentSessionId, fork, joinBase, integration: { status: "pending" } } satisfies ReplicaWork);
80
+ }
81
+
82
+ /** Record a shared attempt without creating a Git snapshot or branch. */
83
+ export function createSharedReplicaWork(file: string, id: string, parentSessionId: string, cwd: string): void {
84
+ if (existsSync(file)) throw new Error("Replica work identity already exists.");
85
+ writeBindingFile(file, { version: 1, id, parentSessionId, mode: "shared", sharedCwd: cwd, integration: { status: "pending" } } satisfies ReplicaWork);
86
+ }
87
+
88
+ /** Persist the allocated shared pane before dispatch. */
89
+ export function attachSharedReplicaPane(file: string, pane: string, sessionFile?: string): void {
90
+ const record = readReplicaWork(file);
91
+ if (record.mode !== "shared" || (record.sharedPane && record.sharedPane !== pane)) throw new Error("Invalid shared replica pane.");
92
+ writeBindingFile(file, { ...record, sharedPane: pane, ...(sessionFile ? { sessionFile } : {}) });
93
+ }
94
+
95
+ /** Reserve the latest settled attempt for its exact immediate parent. */
96
+ export function reserveReplicaContinuation(file: string, parentSessionId: string, attemptId: string): ReplicaWork {
97
+ let current = file;
98
+ const seen = new Set<string>();
99
+ for (;;) {
100
+ const result = withLineageMutationGate(current + ".artifacts", (owner): { record: ReplicaWork } | { next: string } => {
101
+ const record = readReplicaWork(current);
102
+ if (record.parentSessionId !== parentSessionId) throw new Error("Continuation is not owned by this session.");
103
+ if (seen.has(record.id)) throw new Error("Invalid continuation cycle.");
104
+ seen.add(record.id);
105
+ if (!record.completion && !record.launchError) throw new Error("Continuation is still active.");
106
+ if (record.integration.status === "pending") throw new Error("Continuation has an active integration.");
107
+ const binding = record.sessionFile && existsSync(record.sessionFile) ? readSessionBinding(record.sessionFile) : undefined;
108
+ const location = binding?.child ? childSessionLocation(binding.child.lineage.rootDir, binding.child.lineage.nodeId) : record.worktreeLocation;
109
+ if (location && sessionLocationAlive(location)) throw new Error("Continuation session is still active.");
110
+ if (record.continuationAttempt) {
111
+ const next = join(dirname(current), record.continuationAttempt + ".json");
112
+ if (!existsSync(next)) {
113
+ if (!record.continuationOwner || processIncarnationAlive(record.continuationOwner, owner)) throw new Error("Continuation has an active launch reservation.");
114
+ } else {
115
+ const child = readReplicaWork(next);
116
+ if (!child.completion && !child.launchError) throw new Error("Continuation is still active.");
117
+ if (!child.launchError) return { next };
118
+ }
119
+ }
120
+ if (!record.checkpoint || !record.sessionCheckpoint) throw new Error("Continuation has no usable saved source/session checkpoint.");
121
+ writeBindingFile(current, { ...record, continuationAttempt: attemptId, continuationOwner: owner });
122
+ return { record };
123
+ });
124
+ if ("record" in result) return result.record;
125
+ current = result.next;
126
+ }
127
+ }
128
+
129
+ /** Release only this unpublished launch; never clear another attempt's reservation. */
130
+ export function releaseReplicaContinuation(file: string, attemptId: string): void {
131
+ withLineageMutationGate(file + ".artifacts", () => {
132
+ const record = readReplicaWork(file);
133
+ if (record.continuationAttempt !== attemptId) return;
134
+ const { continuationAttempt: _attempt, continuationOwner: _owner, ...rest } = record;
135
+ writeBindingFile(file, rest);
136
+ });
137
+ }
138
+ /** Persist runtime before launch, so recovery never silently adopts another configuration. */
139
+ export function saveReplicaRuntime(file: string, runtime: NonNullable<ReplicaWork["runtime"]>): void {
140
+ writeBindingFile(file, { ...readReplicaWork(file), runtime });
141
+ }
142
+
143
+ /** Preserve failed allocation facts and release a reservation without discarding evidence. */
144
+ export function failReplicaLaunch(file: string, error: unknown, toolCallId?: string): void {
145
+ const record = readReplicaWork(file);
146
+ writeBindingFile(file, { ...record, launchError: String(error), ...(toolCallId ? { launchErrorToolCallId: toolCallId } : {}), integration: { status: "not-attempted", details: record.mode === "shared" ? "Launch failed; changes may already be present in the shared directory." : "Launch failed; saved work was not applied." } });
147
+ }
148
+
149
+ /** A persisted native tool error already delivered this pre-acknowledgement failure. */
150
+ export function replicaLaunchFailureReported(record: ReplicaWork, entries: readonly unknown[], owner?: { toolCallId: string; parentToolCallId: string }): boolean {
151
+ if (!record.launchError) return false;
152
+ const ids = new Set([record.launchErrorToolCallId, owner?.toolCallId].filter(Boolean));
153
+ const diagnostic = record.launchError.replace(/^(?:Error:\s*)+/, "");
154
+ return entries.some(entry => {
155
+ const row = entry as { type?: string; message?: { role?: string; toolCallId?: string; isError?: boolean; content?: { text?: string }[] } } | undefined;
156
+ const message = row?.message;
157
+ if (row?.type !== "message" || message?.role !== "toolResult" || message.isError !== true) return false;
158
+ if (ids.has(message.toolCallId)) return true;
159
+ // An outer script may fail for another call. Require this launch's actual facts.
160
+ return !!owner?.parentToolCallId && message.toolCallId === owner.parentToolCallId && !!diagnostic && Array.isArray(message.content) &&
161
+ message.content.some(block => typeof block.text === "string" &&
162
+ (block.text.includes(diagnostic) || block.text.includes(JSON.stringify(diagnostic).slice(1, -1))));
163
+ });
164
+ }
165
+ /** Bind allocated worktree and session before the child process can start. */
166
+ export function attachReplicaWork(file: string, worktree: WorktreeLaunch, sessionFile?: string): void {
167
+ const record = readReplicaWork(file);
168
+ writeBindingFile(file, { ...record, worktree, ...(sessionFile ? { sessionFile } : {}) });
169
+ }
170
+
171
+ /** Save launch ownership before the child can fail during extension loading. */
172
+ export function captureReplicaLaunchPane(file: string, owner: LaunchPaneOwnership): void {
173
+ const record = readReplicaWork(file);
174
+ if ((record.mode === "shared" ? record.sharedPane : record.worktree?.paneId) !== owner.paneId) throw new Error("Launch ownership does not match the replica pane.");
175
+ if (record.launchPane) {
176
+ if (!launchOwnsPane(record.launchPane, owner.paneId, owner.shellPid)) throw new Error("Replica launch shell incarnation changed.");
177
+ return;
178
+ }
179
+ writeBindingFile(file, { ...record, launchPane: owner });
180
+ }
181
+
182
+ /** Preserve the launch workspace's process incarnation even if the session later teleports. */
183
+ export function captureReplicaWorkLocation(file: string, location: SessionLocation): void {
184
+ const record = readReplicaWork(file);
185
+ const pane = record.mode === "shared" ? record.sharedPane : record.worktree?.paneId;
186
+ const cwd = record.mode === "shared" ? record.sharedCwd : record.worktree?.path;
187
+ if (record.worktreeLocation || !pane || !cwd || location.paneId !== pane) return;
188
+ if (realpathSync(location.cwd) !== realpathSync(cwd)) return;
189
+ writeBindingFile(file, { ...record, worktreeLocation: location });
190
+ }
191
+
192
+ /** Save task result and latest files before join or pane/worktree cleanup. */
193
+ export function checkpointReplicaCompletion(file: string, cwd: string, sessionFile: string, completion: ReplicaCompletion): ReplicaWork {
194
+ let record = readReplicaWork(file);
195
+ if (record.completion) return record;
196
+ const sessionDir = file + ".artifacts";
197
+ mkdirSync(sessionDir, { recursive: true });
198
+ const sessionCheckpoint = join(sessionDir, "session.jsonl");
199
+ try {
200
+ writeFileSync(sessionCheckpoint, readFileSync(sessionFile), { mode: 0o600 });
201
+ for (const path of [sessionCheckpoint, sessionDir]) {
202
+ const fd = openSync(path, "r");
203
+ try { fsyncSync(fd); } finally { closeSync(fd); }
204
+ }
205
+ record = { ...record, sessionCheckpoint };
206
+ } catch (error) {
207
+ record = { ...record, sessionCheckpointError: String(error) };
208
+ }
209
+ if (record.mode === "shared") {
210
+ const failed = completion.exitCode !== 0 || !!completion.error || !!completion.errorMessage || !completion.summary.trim();
211
+ record = { ...record, sessionFile, completion, integration: failed
212
+ ? { status: "not-attempted", details: "The task failed; changes may already be present in the shared directory." }
213
+ : { status: "shared" } };
214
+ writeBindingFile(file, record);
215
+ return record;
216
+ }
217
+ try {
218
+ const checkpoint = snapshotSource(cwd, join(file + ".artifacts", "source"));
219
+ if (checkpoint.commonDir !== record.fork!.commonDir) throw new Error("Replica moved outside its source repository.");
220
+ record = { ...record, checkpoint };
221
+ } catch (error) {
222
+ record = { ...record, checkpointError: String(error) };
223
+ }
224
+ const failed = completion.exitCode !== 0 || !!completion.error || !!completion.errorMessage || !completion.summary.trim();
225
+ record = { ...record, sessionFile, completion, integration: failed
226
+ ? { status: "not-attempted", details: "The task failed; partial work was not applied." }
227
+ : record.sessionCheckpointError
228
+ ? { status: "blocked", details: `The session could not be saved: ${record.sessionCheckpointError}` }
229
+ : record.integration };
230
+ writeBindingFile(file, record);
231
+ return record;
232
+ }
233
+
234
+ /** Attempt successful work; the optional receiver check can refuse application after a session move. */
235
+ export function joinReplicaWork(file: string, parentCwd: string, parentActive: () => boolean = () => true): ReplicaWork {
236
+ let record = readReplicaWork(file);
237
+ if (!record.completion) throw new Error("Replica has no saved task result.");
238
+ if (record.integration.status !== "pending") return record;
239
+ if (!record.checkpoint) {
240
+ record = { ...record, integration: { status: "blocked", details: record.checkpointError ?? "No saved source checkpoint." } };
241
+ writeBindingFile(file, record); return record;
242
+ }
243
+ try {
244
+ const parent = snapshotSource(parentCwd, join(file + ".artifacts", "parent"));
245
+ if (parent.commonDir !== record.fork!.commonDir) throw new Error("The parent is now in a different repository; saved work was not applied there.");
246
+ if (record.joinPlan && record.joinPlan.before.repoDir !== parent.repoDir) throw new Error("An interrupted join belongs to another parent checkout; its saved plan is retained.");
247
+ if (!record.joinPlan) {
248
+ const plan = planSourceJoin(parent.repoDir, record.joinBase!, record.checkpoint.commit, join(file + ".artifacts", "join"));
249
+ if (plan.status === "conflict") {
250
+ record = { ...record, integration: { status: "conflict", details: plan.details } };
251
+ writeBindingFile(file, record); return record;
252
+ }
253
+ record = { ...record, joinPlan: plan };
254
+ writeBindingFile(file, record);
255
+ }
256
+ applySourceJoin(record.joinPlan!, () => {
257
+ if (!parentActive()) throw new Error("The parent session moved or closed during integration; saved work was retained.");
258
+ });
259
+ record = { ...record, integration: { status: "applied" } };
260
+ } catch (error) {
261
+ record = { ...record, integration: { status: "blocked", details: String(error) } };
262
+ }
263
+ writeBindingFile(file, record);
264
+ return record;
265
+ }
266
+
267
+ /** Close an owned settled pane; remove its checkout only after safe integration. */
268
+ export function cleanupReplicaWork(file: string, parentCwd: string): boolean {
269
+ const record = readReplicaWork(file), worktree = record.worktree;
270
+ if (record.cleaned) return true;
271
+ if (record.mode === "shared") {
272
+ if (!record.sharedPane) return true;
273
+ if (record.worktreeLocation && sessionLocationAlive(record.worktreeLocation)) return false;
274
+ let info: ReturnType<typeof getHerdrPaneProcessInfo>;
275
+ try { info = getHerdrPaneProcessInfo(record.sharedPane); }
276
+ catch (error) {
277
+ if (/pane_not_found|pane.*not found/i.test(String(error))) { writeBindingFile(file, { ...record, cleaned: true }); return true; }
278
+ throw error;
279
+ }
280
+ const owned = record.worktreeLocation
281
+ ? sessionOwnsPane(record.worktreeLocation, record.sharedPane, info.shellPid)
282
+ : !!record.launchPane && launchOwnsPane(record.launchPane, record.sharedPane, info.shellPid);
283
+ if (!owned || info.pids.some(pid => pid !== info.shellPid)) return false;
284
+ closeHerdrSurface(record.sharedPane);
285
+ writeBindingFile(file, { ...record, cleaned: true });
286
+ return true;
287
+ }
288
+ if (!worktree) return true;
289
+ const removeWorktree = record.integration.status === "applied";
290
+ const matches = listHerdrWorktrees(parentCwd).filter((entry) => entry.path === worktree.path && entry.branch === worktree.branch && entry.isLinkedWorktree);
291
+ if (matches.length > 1) throw new Error("Ambiguous replica worktree ownership.");
292
+ if (!matches.length) {
293
+ if (!removeWorktree) return true;
294
+ if (existsSync(worktree.path)) return false;
295
+ writeBindingFile(file, { ...record, cleaned: true });
296
+ return true;
297
+ }
298
+ const observed = matches[0];
299
+ if (record.worktreeLocation && sessionLocationAlive(record.worktreeLocation)) return false;
300
+ if (removeWorktree) {
301
+ if (!record.checkpoint || !record.sessionCheckpoint || !existsSync(record.sessionCheckpoint)) return false;
302
+ const checkpoint = snapshotSource(worktree.path, join(file + ".artifacts", "cleanup"));
303
+ if (checkpoint.tree !== record.checkpoint.tree || checkpoint.head !== record.checkpoint.head || checkpoint.indexHash !== record.checkpoint.indexHash) return false; // Never discard edits made after settlement.
304
+ }
305
+ if (observed.workspaceId) {
306
+ if (observed.workspaceId !== worktree.workspaceId) return false;
307
+ const failedLaunch = !record.worktreeLocation && !!record.launchPane &&
308
+ (!!record.launchError || (record.completion != null && record.completion.exitCode !== 0));
309
+ if (!record.worktreeLocation && !failedLaunch) return false;
310
+ const response = JSON.parse(execFileSync("herdr", ["pane", "list", "--workspace", observed.workspaceId], { encoding: "utf8" }));
311
+ const panes = response?.result?.panes;
312
+ if (!Array.isArray(panes) || panes.length !== 1 || panes[0].pane_id !== worktree.paneId) return false;
313
+ const info = getHerdrPaneProcessInfo(worktree.paneId);
314
+ const owned = record.worktreeLocation
315
+ ? sessionOwnsPane(record.worktreeLocation, worktree.paneId, info.shellPid)
316
+ : launchOwnsPane(record.launchPane!, worktree.paneId, info.shellPid);
317
+ if (!owned || info.pids.some((pid) => pid !== info.shellPid)) return false;
318
+ if (!removeWorktree) {
319
+ closeHerdrSurface(worktree.paneId);
320
+ return true;
321
+ }
322
+ execFileSync("herdr", ["worktree", "remove", "--workspace", observed.workspaceId, "--force", "--trust-repository"], { encoding: "utf8" });
323
+ } else {
324
+ if (!removeWorktree) return true;
325
+ // A restarted Herdr may no longer have its workspace. Keep any surviving cwd users.
326
+ for (const pid of readdirSync("/proc").filter((entry) => /^\d+$/.test(entry))) {
327
+ let cwd: string;
328
+ try { cwd = readlinkSync(`/proc/${pid}/cwd`); } catch { continue; }
329
+ if (cwd === worktree.path || cwd === `${worktree.path} (deleted)` || cwd.startsWith(worktree.path + "/")) return false;
330
+ }
331
+ execFileSync("git", ["worktree", "remove", "--force", worktree.path], { cwd: parentCwd, encoding: "utf8" });
332
+ }
333
+ if (existsSync(worktree.path)) throw new Error("Replica worktree cleanup could not be verified.");
334
+ writeBindingFile(file, { ...record, cleaned: true });
335
+ return true;
336
+ }
@@ -0,0 +1,74 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { checkpointReplicaCompletion, cleanupReplicaWork, joinReplicaWork, readReplicaWork } from "./replica-work.js";
4
+ import { materializeReplicationHistory } from "./replica-history.js";
5
+ import { childSessionLocation, parentSessionLocation, sessionLocationAlive, sessionOwnsPane } from "./session-binding.js";
6
+ import { closeHerdrSurface, getHerdrPaneProcessInfo } from "./herdr.js";
7
+ import { withLineageMutationGate } from "./lineage.js";
8
+ function sameParent(file, expected) {
9
+ const record = readReplicaWork(file);
10
+ const current = parentSessionLocation(dirname(dirname(file)), record.parentSessionId);
11
+ return !!current && current.sessionId === expected.sessionId && current.pid === expected.pid &&
12
+ current.processStart === expected.processStart && current.cwd === expected.cwd &&
13
+ current.sessionFile === expected.sessionFile && sessionLocationAlive(current);
14
+ }
15
+ function execute(request) {
16
+ return withLineageMutationGate(request.file + ".artifacts", () => {
17
+ if (request.operation === "cleanup") {
18
+ const record = readReplicaWork(request.file);
19
+ if ((record.worktree?.paneId ?? record.sharedPane) && request.surface !== (record.worktree?.paneId ?? record.sharedPane) && request.child) {
20
+ const location = childSessionLocation(request.child.rootDir, request.child.nodeId);
21
+ if (location && sessionLocationAlive(location))
22
+ return { cleaned: false };
23
+ const info = getHerdrPaneProcessInfo(request.surface);
24
+ if (location && sessionOwnsPane(location, request.surface, info.shellPid)) {
25
+ closeHerdrSurface(request.surface);
26
+ return { cleaned: false };
27
+ }
28
+ }
29
+ return { cleaned: cleanupReplicaWork(request.file, request.parentCwd) };
30
+ }
31
+ const saved = readReplicaWork(request.file);
32
+ const completion = { ...request.completion };
33
+ const failed = completion.exitCode !== 0 || !!completion.error || !!completion.errorMessage || !completion.summary.trim();
34
+ if (failed && !saved.completion) {
35
+ try {
36
+ completion.historyPath = materializeReplicationHistory(existsSync(request.sessionFile) ? request.sessionFile : saved.sessionCheckpoint ?? request.sessionFile, saved.id, completion.name);
37
+ }
38
+ catch (error) {
39
+ // Missing history must not strand an otherwise reportable terminal failure.
40
+ completion.historyError = String(error);
41
+ }
42
+ }
43
+ let work = checkpointReplicaCompletion(request.file, request.cwd, request.sessionFile, completion);
44
+ if (!failed && work.mode !== "shared" && request.parent && sameParent(request.file, request.parent)) {
45
+ // Different Pi incarnations and siblings share this gate, not just an in-memory queue.
46
+ work = withLineageMutationGate(join(dirname(request.file), "joins", encodeURIComponent(work.parentSessionId)), () => {
47
+ if (!sameParent(request.file, request.parent))
48
+ return work;
49
+ return joinReplicaWork(request.file, request.parent.cwd, () => sameParent(request.file, request.parent));
50
+ });
51
+ }
52
+ const { runtime: _runtime, ...result } = work;
53
+ return { work: result };
54
+ });
55
+ }
56
+ process.once("message", async (request) => {
57
+ let response;
58
+ for (;;) {
59
+ try {
60
+ response = { result: execute(request) };
61
+ break;
62
+ }
63
+ catch (error) {
64
+ if (!String(error).includes("Durable state has an active mutation.")) {
65
+ response = { error: String(error) };
66
+ break;
67
+ }
68
+ // Wait in this worker instead of starting fresh processes while another owner works.
69
+ await new Promise((resolve) => setTimeout(resolve, 100));
70
+ }
71
+ }
72
+ if (process.connected)
73
+ process.send(response, () => process.disconnect?.());
74
+ });
@@ -0,0 +1,71 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { checkpointReplicaCompletion, cleanupReplicaWork, joinReplicaWork, readReplicaWork } from "./replica-work.ts";
4
+ import { materializeReplicationHistory } from "./replica-history.ts";
5
+ import { childSessionLocation, parentSessionLocation, sessionLocationAlive, sessionOwnsPane, type SessionLocation } from "./session-binding.ts";
6
+ import { closeHerdrSurface, getHerdrPaneProcessInfo } from "./herdr.ts";
7
+ import { withLineageMutationGate } from "./lineage.ts";
8
+ import type { ReplicaBackgroundRequest, ReplicaBackgroundResult } from "./replica-background.ts";
9
+
10
+ function sameParent(file: string, expected: SessionLocation): boolean {
11
+ const record = readReplicaWork(file);
12
+ const current = parentSessionLocation(dirname(dirname(file)), record.parentSessionId);
13
+ return !!current && current.sessionId === expected.sessionId && current.pid === expected.pid &&
14
+ current.processStart === expected.processStart && current.cwd === expected.cwd &&
15
+ current.sessionFile === expected.sessionFile && sessionLocationAlive(current);
16
+ }
17
+
18
+ function execute(request: ReplicaBackgroundRequest): ReplicaBackgroundResult {
19
+ return withLineageMutationGate(request.file + ".artifacts", () => {
20
+ if (request.operation === "cleanup") {
21
+ const record = readReplicaWork(request.file);
22
+ if ((record.worktree?.paneId ?? record.sharedPane) && request.surface !== (record.worktree?.paneId ?? record.sharedPane) && request.child) {
23
+ const location = childSessionLocation(request.child.rootDir, request.child.nodeId);
24
+ if (location && sessionLocationAlive(location)) return { cleaned: false };
25
+ const info = getHerdrPaneProcessInfo(request.surface);
26
+ if (location && sessionOwnsPane(location, request.surface, info.shellPid)) {
27
+ closeHerdrSurface(request.surface);
28
+ return { cleaned: false };
29
+ }
30
+ }
31
+ return { cleaned: cleanupReplicaWork(request.file, request.parentCwd) };
32
+ }
33
+ const saved = readReplicaWork(request.file);
34
+ const completion = { ...request.completion };
35
+ const failed = completion.exitCode !== 0 || !!completion.error || !!completion.errorMessage || !completion.summary.trim();
36
+ if (failed && !saved.completion) {
37
+ try {
38
+ completion.historyPath = materializeReplicationHistory(
39
+ existsSync(request.sessionFile) ? request.sessionFile : saved.sessionCheckpoint ?? request.sessionFile, saved.id, completion.name);
40
+ } catch (error) {
41
+ // Missing history must not strand an otherwise reportable terminal failure.
42
+ completion.historyError = String(error);
43
+ }
44
+ }
45
+ let work = checkpointReplicaCompletion(request.file, request.cwd, request.sessionFile, completion);
46
+ if (!failed && work.mode !== "shared" && request.parent && sameParent(request.file, request.parent)) {
47
+ // Different Pi incarnations and siblings share this gate, not just an in-memory queue.
48
+ work = withLineageMutationGate(join(dirname(request.file), "joins", encodeURIComponent(work.parentSessionId)), () => {
49
+ if (!sameParent(request.file, request.parent!)) return work;
50
+ return joinReplicaWork(request.file, request.parent!.cwd, () => sameParent(request.file, request.parent!));
51
+ });
52
+ }
53
+ const { runtime: _runtime, ...result } = work;
54
+ return { work: result };
55
+ });
56
+ }
57
+
58
+ process.once("message", async (request: ReplicaBackgroundRequest) => {
59
+ let response: { result?: ReplicaBackgroundResult; error?: string };
60
+ for (;;) {
61
+ try { response = { result: execute(request) }; break; }
62
+ catch (error) {
63
+ if (!String(error).includes("Durable state has an active mutation.")) {
64
+ response = { error: String(error) }; break;
65
+ }
66
+ // Wait in this worker instead of starting fresh processes while another owner works.
67
+ await new Promise((resolve) => setTimeout(resolve, 100));
68
+ }
69
+ }
70
+ if (process.connected) process.send!(response, () => process.disconnect?.());
71
+ });
@@ -0,0 +1,99 @@
1
+ import { basename, resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { Type, type Static } from "@sinclair/typebox";
4
+ export { materializeReplicationHistory } from "./replica-history.ts";
5
+ import type { SessionEntry, ReplicationContextMessage } from "./session.ts";
6
+ import { buildSessionContext } from "@earendil-works/pi-coding-agent";
7
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+
9
+ const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
10
+ /** The fresh surface requires a task name and bounded work. */
11
+ export const ReplicateParams = Type.Object({
12
+ name: Type.String({ pattern: namePattern.source, description: "A task-specific name in lowercase kebab-case, such as check-auth-risks." }),
13
+ task: Type.String({ minLength: 1, description: "The exact work this copy must complete and the result you expect back." }),
14
+ }, { additionalProperties: false });
15
+
16
+ export type ReplicateRequest = Static<typeof ReplicateParams>;
17
+
18
+ /** Register the single fresh-copy tool without model-facing continuation controls. */
19
+ export function registerReplicateTool(pi: ExtensionAPI, launch: (params: ReplicateRequest, ctx: ExtensionContext, toolCallId: string) => Promise<{ id: string; name: string; sessionFile: string }>): void {
20
+ pi.registerTool({
21
+ name: "replicate", label: "Replicate",
22
+ description: "Run a clearly defined part of your task in parallel with another copy of yourself while you continue a different part. The copy's result returns automatically to this session.",
23
+ promptSnippet: "Run a defined part of your task in parallel with another copy of yourself while you continue a different part.",
24
+ promptGuidelines: [
25
+ "Use replicate only from a task-ready state: you are already in the intended working location, understand the task, and have identified independent parts that can proceed in parallel.",
26
+ "Before calling replicate, know exactly what the copy must do, what result you expect back, and what separate work you will continue yourself.",
27
+ "Do not replicate while still deciding the task, its scope, or where to work. Do not use it merely to hand off work.",
28
+ "If your next action after launching a copy would be to wait for it, do the work yourself instead.",
29
+ "After finishing your own parallel work, you may end your turn and wait for the copy's automatic result.",
30
+ "Results arrive automatically. Do not poll for completion.",
31
+ ],
32
+ parameters: ReplicateParams,
33
+ async execute(toolCallId, input, _signal, _update, ctx) {
34
+ const params = input as ReplicateRequest;
35
+ if (Object.keys(params).some((key) => key !== "name" && key !== "task")) throw new Error("Replication accepts only name and task.");
36
+ if (typeof params.name !== "string" || !namePattern.test(params.name)) throw new Error("Replication needs a lowercase kebab-case name.");
37
+ if (typeof params.task !== "string" || !params.task.trim()) throw new Error("Replication needs a nonempty task.");
38
+ const child = await launch(params, ctx, toolCallId);
39
+ return {
40
+ content: [{ type: "text", text: `${child.name} started. Its result will arrive here automatically.` }],
41
+ details: { ...child, status: "running" },
42
+ };
43
+ },
44
+ });
45
+ }
46
+
47
+ /** Add the immediate parent's readable origin to the caller's task name. */
48
+ export function replicationName(name: string, pi: ExtensionAPI, ctx: ExtensionContext, paneTitle: () => string | undefined): string {
49
+ // Strip control characters so the origin stays on one safe terminal line.
50
+ // oxlint-disable-next-line no-control-regex
51
+ const clean = (value: string | undefined) => value?.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim();
52
+ const origin = clean(pi.getSessionName()) || clean(paneTitle()) || clean(basename(ctx.cwd));
53
+ return `${name} ← ${origin}`;
54
+ }
55
+
56
+ /** Plain startup data captured before asynchronous launch work can observe another turn. */
57
+ export interface ReplicationSnapshot {
58
+ entries: readonly SessionEntry[];
59
+ contextMessages: readonly ReplicationContextMessage[];
60
+ systemPrompt: string;
61
+ /** Explicit extension loading flags from this Pi process. */
62
+ extensionArgs: string[];
63
+ /** Durable source/checkpoint record owned by this attempt. */
64
+ recordFile?: string;
65
+ /** Resume preserves the saved header identity but uses fresh per-attempt artifacts. */
66
+ resumeSessionId?: string;
67
+ }
68
+
69
+ /** Use Pi's native projection, including compaction and context edits. */
70
+ export function replicationContextMessages(ctx: ExtensionContext): readonly ReplicationContextMessage[] {
71
+ const manager = ctx.sessionManager as typeof ctx.sessionManager & {
72
+ buildSessionProjection?: () => { messages: readonly ReplicationContextMessage[] };
73
+ };
74
+ return manager.buildSessionProjection?.().messages ?? buildSessionContext(manager.getBranch()).messages;
75
+ }
76
+ /** Carry explicit CLI extensions, including isolated loading, into each copy. */
77
+ export function replicationExtensionArgs(argv: readonly string[], cwd: string): string[] {
78
+ const args: string[] = [];
79
+ for (let i = 0; i < argv.length; i++) {
80
+ if (argv[i] === "-ne" || argv[i] === "--no-extensions") args.push("--no-extensions");
81
+ else if ((argv[i] === "-e" || argv[i] === "--extension") && argv[i + 1]) {
82
+ const path = resolve(cwd, argv[++i]);
83
+ // The fresh lifecycle goes last so inherited prompt hooks cannot append twice.
84
+ if (path !== fileURLToPath(new URL("./subagent-done.ts", import.meta.url))) args.push("-e", path);
85
+ }
86
+ }
87
+ return args;
88
+ }
89
+ /** Give the parallel copy only its bounded assignment and result requirement. */
90
+ export function replicationAssignment(task: string): string {
91
+ return [
92
+ "You are a parallel copy of the agent working on this task.",
93
+ "Complete only the assignment below.",
94
+ "Return a clear summary of the result and any unresolved issues.",
95
+ "",
96
+ "Your assignment:",
97
+ task,
98
+ ].join("\n");
99
+ }
@@ -0,0 +1,33 @@
1
+ /** A small promise queue keyed by child id. Unrelated children can finalize concurrently. */
2
+ export interface ResultDeliveryQueue {
3
+ enqueue<T>(childId: string, job: () => T | Promise<T>): Promise<T>;
4
+ }
5
+
6
+ /** Create the process-local queue used to serialize one child's terminal delivery. */
7
+ export function createResultDeliveryQueue(): ResultDeliveryQueue {
8
+ const tails = new Map<string, Promise<void>>();
9
+ return {
10
+ enqueue<T>(childId: string, job: () => T | Promise<T>): Promise<T> {
11
+ const previous = tails.get(childId) ?? Promise.resolve();
12
+ const run = previous.then(job);
13
+ const tail = run.then(
14
+ () => undefined,
15
+ () => undefined,
16
+ );
17
+ tails.set(childId, tail);
18
+ void tail.finally(() => {
19
+ if (tails.get(childId) === tail) tails.delete(childId);
20
+ });
21
+ return run;
22
+ },
23
+ };
24
+ }
25
+
26
+ /** Queue the one terminal delivery for a child. */
27
+ export function enqueueResultDelivery<T>(options: {
28
+ queue: ResultDeliveryQueue;
29
+ childId: string;
30
+ deliver: () => T | Promise<T>;
31
+ }): Promise<T> {
32
+ return options.queue.enqueue(options.childId, options.deliver);
33
+ }