@ferris1225/pi-subagents 4.3.4 → 4.3.6

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 (38) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +103 -83
  3. package/agents/artisan.md +0 -1
  4. package/agents/steward.md +1 -2
  5. package/{src/index.ts → index.ts} +19 -19
  6. package/package.json +4 -3
  7. package/src/{config.ts → configuration/config.ts} +19 -24
  8. package/src/configuration/setup.ts +375 -0
  9. package/src/configuration/ui.ts +245 -0
  10. package/src/{agents.ts → delegation/agents.ts} +3 -3
  11. package/src/{dispatch.ts → delegation/dispatch.ts} +12 -18
  12. package/src/{prompt.ts → delegation/prompt.ts} +5 -9
  13. package/src/{background.ts → execution/background.ts} +3 -6
  14. package/src/execution/rpc-control.ts +235 -0
  15. package/src/{rpc-run.ts → execution/rpc-run.ts} +35 -225
  16. package/src/{session-fork.ts → execution/session-fork.ts} +1 -1
  17. package/src/{spawn.ts → execution/spawn.ts} +10 -8
  18. package/src/isolation/git-command.ts +147 -0
  19. package/src/isolation/managed-paths.ts +145 -0
  20. package/src/{recovery.ts → isolation/recovery.ts} +42 -13
  21. package/src/{temp-hygiene.ts → isolation/temp-hygiene.ts} +7 -7
  22. package/src/{worktree.ts → isolation/worktree.ts} +11 -158
  23. package/src/{completion.ts → lifecycle/completion.ts} +2 -2
  24. package/src/{durable.ts → lifecycle/durable.ts} +101 -27
  25. package/src/{runtime.ts → lifecycle/runtime.ts} +12 -12
  26. package/src/{thread-lifecycle.ts → lifecycle/thread-lifecycle.ts} +25 -519
  27. package/src/lifecycle/thread-restore.ts +253 -0
  28. package/src/lifecycle/thread-shared.ts +269 -0
  29. package/src/{tools.ts → lifecycle/tools.ts} +51 -13
  30. package/src/{announcements.ts → presentation/announcements.ts} +4 -4
  31. package/src/{format.ts → presentation/format.ts} +3 -3
  32. package/src/{monitor.ts → presentation/monitor.ts} +2 -2
  33. package/src/{widget.ts → presentation/widget.ts} +1 -1
  34. package/agents/sentinel.md +0 -16
  35. package/src/setup.ts +0 -344
  36. package/src/ui.ts +0 -160
  37. /package/src/{models.ts → configuration/models.ts} +0 -0
  38. /package/src/{status.ts → presentation/status.ts} +0 -0
@@ -15,27 +15,30 @@
15
15
 
16
16
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
17
17
  import { existsSync, type Dirent, readdirSync, statSync } from "node:fs";
18
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
18
+ import { mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
19
19
  import { uptime } from "node:os";
20
- import { dirname, join } from "node:path";
21
- import type { UsageStats } from "./rpc-run.ts";
20
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
21
+ import type { UsageStats } from "../execution/rpc-control.ts";
22
22
  import type { SubagentThread } from "./runtime.ts";
23
- import { getResultOutput, isFailedResult, getProjectRoot, getSubagentsRoot, type SingleResult } from "./spawn.ts";
23
+ import { getResultOutput, isFailedResult, getProjectRoot, getSubagentsRoot, type SingleResult } from "../execution/spawn.ts";
24
+ import { isManagedSessionDir, isManagedWorktreeLayout, samePath } from "../isolation/managed-paths.ts";
25
+ import { readRecoveryRecords, referencedRecoveryPaths } from "../isolation/recovery.ts";
24
26
  import {
25
27
  isPathInside,
28
+ normalizeWorktreeSnapshot,
29
+ resolveRepositoryRoot,
26
30
  restoreWorktreeIsolation,
27
31
  type IsolationMode,
28
- normalizeWorktreeSnapshot,
29
- worktreeSnapshot,
30
32
  type WorktreeSnapshot,
31
- } from "./worktree.ts";
33
+ worktreeSnapshot,
34
+ } from "../isolation/worktree.ts";
32
35
 
33
36
  export const THREADS_MANIFEST_FILE_NAME = "pi-subagents-threads.json";
34
37
  const THREADS_MANIFEST_VERSION = 1;
35
38
 
36
39
  /** Project directories whose newest file has not been touched for this long
37
40
  * are deleted wholesale at session start, so per-project sessions/worktrees/results
38
- * can never accumulate forever. Parked threads' manifest references always
41
+ * can never accumulate forever. Valid thread and recovery manifest references always
39
42
  * win over the age rule. */
40
43
  export const PROJECT_ROOT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1_000;
41
44
 
@@ -190,19 +193,80 @@ function normalizeRecord(value: unknown): ThreadRecord | undefined {
190
193
  };
191
194
  }
192
195
 
193
- async function readManifestRecords(path: string): Promise<ThreadRecord[]> {
196
+ interface ThreadManifestRead {
197
+ valid: boolean;
198
+ sourceCount: number;
199
+ records: ThreadRecord[];
200
+ }
201
+
202
+ async function readManifest(path: string): Promise<ThreadManifestRead> {
194
203
  try {
195
- const parsed = JSON.parse(await readFile(path, "utf8")) as {
196
- records?: unknown;
204
+ const parsed = JSON.parse(await readFile(path, "utf8")) as { records?: unknown };
205
+ if (!Array.isArray(parsed.records)) return { valid: false, sourceCount: 0, records: [] };
206
+ return {
207
+ valid: true,
208
+ sourceCount: parsed.records.length,
209
+ records: parsed.records.flatMap((record) => {
210
+ const normalized = normalizeRecord(record);
211
+ return normalized ? [normalized] : [];
212
+ }),
197
213
  };
198
- if (!Array.isArray(parsed.records)) return [];
199
- return parsed.records.flatMap((record) => {
200
- const normalized = normalizeRecord(record);
201
- return normalized ? [normalized] : [];
202
- });
203
214
  } catch {
204
- return [];
215
+ return { valid: false, sourceCount: 0, records: [] };
216
+ }
217
+ }
218
+
219
+ async function readManifestRecords(path: string): Promise<ThreadRecord[]> {
220
+ return (await readManifest(path)).records;
221
+ }
222
+
223
+ async function validateThreadRecord(
224
+ configPath: string,
225
+ manifestPath: string,
226
+ record: ThreadRecord,
227
+ ): Promise<boolean> {
228
+ if (
229
+ !isAbsolute(record.cwd) ||
230
+ !isAbsolute(record.executionCwd) ||
231
+ record.cwd !== resolve(record.cwd) ||
232
+ record.executionCwd !== resolve(record.executionCwd) ||
233
+ !samePath(dirname(manifestPath), getProjectRoot(configPath, record.cwd))
234
+ ) {
235
+ return false;
205
236
  }
237
+ if ((record.sessionId === undefined) !== (record.sessionDir === undefined)) return false;
238
+ if (record.sessionDir && !await isManagedSessionDir(configPath, record.cwd, record.sessionDir)) return false;
239
+ if (record.isolation === "shared") {
240
+ return record.worktree === undefined && samePath(record.executionCwd, record.cwd);
241
+ }
242
+ const worktree = record.worktree;
243
+ if (!worktree || !await isManagedWorktreeLayout(configPath, record.cwd, worktree)) return false;
244
+ try {
245
+ const canonicalCwd = await realpath(record.cwd);
246
+ const canonicalRoot = await resolveRepositoryRoot(record.cwd);
247
+ if (!samePath(worktree.originalCwd, canonicalCwd) || !samePath(worktree.originalRoot, canonicalRoot)) {
248
+ return false;
249
+ }
250
+ if (!isPathInside(canonicalRoot, canonicalCwd)) return false;
251
+ const restoredCwd = join(worktree.worktreePath, relative(canonicalRoot, canonicalCwd));
252
+ if (!samePath(worktree.cwd, restoredCwd) || !samePath(record.executionCwd, restoredCwd)) return false;
253
+ if (existsSync(worktree.cwd)) {
254
+ const [realWorktree, realCwd] = await Promise.all([realpath(worktree.worktreePath), realpath(worktree.cwd)]);
255
+ if (!samePath(realCwd, join(realWorktree, relative(canonicalRoot, canonicalCwd)))) return false;
256
+ }
257
+ return true;
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+
263
+ async function validatedManifestRecords(
264
+ configPath: string,
265
+ path: string,
266
+ records: readonly ThreadRecord[],
267
+ ): Promise<ThreadRecord[]> {
268
+ const validity = await Promise.all(records.map((record) => validateThreadRecord(configPath, path, record)));
269
+ return records.filter((_record, index) => validity[index]);
206
270
  }
207
271
 
208
272
  /** Manifest paths of every project that has a durable root. */
@@ -221,7 +285,12 @@ function projectManifestPaths(durableRoot: string): string[] {
221
285
  export async function readThreadRecords(configPath: string): Promise<ThreadRecord[]> {
222
286
  const manifests = await Promise.all(
223
287
  projectManifestPaths(getSubagentsRoot(configPath))
224
- .map((path) => readManifestRecords(path)),
288
+ .map((path) => withFileMutationQueue(path, async () => {
289
+ const manifest = await readManifest(path);
290
+ const validated = await validatedManifestRecords(configPath, path, manifest.records);
291
+ if (!manifest.valid || validated.length !== manifest.sourceCount) await writeManifest(path, validated);
292
+ return validated;
293
+ })),
225
294
  );
226
295
  return manifests.flat();
227
296
  }
@@ -367,11 +436,14 @@ export async function pruneThreadRecords(
367
436
  const durableRoot = getSubagentsRoot(configPath);
368
437
  for (const path of projectManifestPaths(durableRoot)) {
369
438
  await withFileMutationQueue(path, async () => {
370
- const records = await readManifestRecords(path);
371
- if (records.length === 0) return;
372
- let changed = false;
439
+ const manifest = await readManifest(path);
440
+ const records = manifest.records;
441
+ if (manifest.valid && records.length === 0) return;
442
+ const validity = await Promise.all(records.map((record) => validateThreadRecord(configPath, path, record)));
443
+ let changed = !manifest.valid || manifest.sourceCount !== records.length || validity.some((valid) => !valid);
373
444
  const kept: ThreadRecord[] = [];
374
- for (const record of records) {
445
+ for (const [index, record] of records.entries()) {
446
+ if (!validity[index]) continue;
375
447
  if (now - record.updatedAt <= PARKED_RECORD_MAX_AGE_MS) {
376
448
  kept.push(record);
377
449
  continue;
@@ -384,8 +456,8 @@ export async function pruneThreadRecords(
384
456
  }
385
457
  }
386
458
 
387
- /** Paths a manifest still references; used by the state-root sweep so
388
- * freshly created-but-unrecorded directories are never touched. */
459
+ /** Paths a thread manifest still references; combined with recovery references by
460
+ * startup retention before any durable directory is swept. */
389
461
  export function referencedDurablePaths(records: readonly ThreadRecord[]): Set<string> {
390
462
  const paths = new Set<string>();
391
463
  for (const record of records) {
@@ -436,13 +508,15 @@ function isIdleSince(root: string, cutoffMs: number, now: number): boolean {
436
508
  }
437
509
 
438
510
  /** Delete project directories under the ferris-pi-subagents root that have
439
- * been idle past PROJECT_ROOT_MAX_AGE_MS. A directory containing any path the
440
- * threads manifest still references is never touched, so parked work outlives
441
- * the age rule. Returns the removed directory names. */
511
+ * been idle past PROJECT_ROOT_MAX_AGE_MS. A directory containing any path a valid
512
+ * thread or recovery manifest still references is never touched. Returns the removed
513
+ * directory names. */
442
514
  export async function pruneStaleProjectRoots(configPath: string, options: { now?: number } = {}): Promise<string[]> {
443
515
  const now = options.now ?? Date.now();
444
516
  const records = await readThreadRecords(configPath).catch(() => [] as ThreadRecord[]);
517
+ const recoveryRecords = await readRecoveryRecords(configPath).catch(() => []);
445
518
  const referenced = referencedDurablePaths(records);
519
+ for (const path of await referencedRecoveryPaths(configPath, recoveryRecords)) referenced.add(path);
446
520
  const root = getSubagentsRoot(configPath);
447
521
  let projects: Dirent[];
448
522
  try {
@@ -10,7 +10,7 @@
10
10
 
11
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { rmSync } from "node:fs";
13
- import { resolveSubagentConcurrency, BackgroundTaskQueue } from "./background.ts";
13
+ import { resolveSubagentConcurrency, BackgroundTaskQueue } from "../execution/background.ts";
14
14
  import {
15
15
  createCompletionBatcher,
16
16
  formatActiveRunsFooter,
@@ -18,13 +18,13 @@ import {
18
18
  type CompletionBatcher,
19
19
  type CompletionMessageItem,
20
20
  } from "./completion.ts";
21
- import { type ThinkingLevel } from "./config.ts";
21
+ import { type ThinkingLevel } from "../configuration/config.ts";
22
22
  import { removeThreadRecord, threadRecordFromThread, upsertThreadRecord, type ThreadRecord } from "./durable.ts";
23
- import { isRunActiveStatus, monitor } from "./monitor.ts";
24
- import type { RpcRunControl } from "./rpc-run.ts";
25
- import type { StartBackgroundInternal } from "./thread-lifecycle.ts";
26
- import { isFailedResult, type SingleResult } from "./spawn.ts";
27
- import type { IsolationMode, WorktreeFinalization, WorktreeIsolation } from "./worktree.ts";
23
+ import { isRunActiveStatus, monitor } from "../presentation/monitor.ts";
24
+ import type { RpcRunControl } from "../execution/rpc-control.ts";
25
+ import type { StartBackgroundInternal } from "./thread-shared.ts";
26
+ import { isFailedResult, type SingleResult } from "../execution/spawn.ts";
27
+ import type { IsolationMode, WorktreeFinalization, WorktreeIsolation } from "../isolation/worktree.ts";
28
28
 
29
29
  export type ThreadState =
30
30
  | "queued"
@@ -107,7 +107,7 @@ export interface SubagentRuntime {
107
107
  * one-time session-start notice. */
108
108
  restoredRunIds: number[];
109
109
  restoredNotified: boolean;
110
- /** Deliver a batch of completion messages as a waking follow-up. */
110
+ /** Deliver a batch at the next safe parent turn boundary and wake an idle parent. */
111
111
  sendCompletionGroup: (items: CompletionMessageItem[]) => void;
112
112
  /** Claim the sole delivery route before a generation can settle. */
113
113
  claimRunDelivery: (runId: number, route: "background" | "await") => void;
@@ -189,10 +189,10 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
189
189
  content: formatCompletionMessage(items) + formatActiveRunsFooter(active),
190
190
  display: true,
191
191
  };
192
- // Follow-ups never interrupt an active parent lane. triggerTurn wakes an
193
- // idle parent immediately, while a streaming parent receives the result
194
- // only after its current tool/assistant lane settles.
195
- pi.sendMessage(message, { deliverAs: "followUp", triggerTurn: true });
192
+ // Steer delivers after the current assistant turn's tool calls and before
193
+ // the next model call. A follow-up would wait for the whole parent run to
194
+ // settle, allowing completions and stop results to arrive after its final reply.
195
+ pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true });
196
196
  },
197
197
  claimRunDelivery: (runId, route) => {
198
198
  runDeliveries.set(runId, { route, immediate: false });