@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.
- package/CHANGELOG.md +36 -0
- package/README.md +103 -83
- package/agents/artisan.md +0 -1
- package/agents/steward.md +1 -2
- package/{src/index.ts → index.ts} +19 -19
- package/package.json +4 -3
- package/src/{config.ts → configuration/config.ts} +19 -24
- package/src/configuration/setup.ts +375 -0
- package/src/configuration/ui.ts +245 -0
- package/src/{agents.ts → delegation/agents.ts} +3 -3
- package/src/{dispatch.ts → delegation/dispatch.ts} +12 -18
- package/src/{prompt.ts → delegation/prompt.ts} +5 -9
- package/src/{background.ts → execution/background.ts} +3 -6
- package/src/execution/rpc-control.ts +235 -0
- package/src/{rpc-run.ts → execution/rpc-run.ts} +35 -225
- package/src/{session-fork.ts → execution/session-fork.ts} +1 -1
- package/src/{spawn.ts → execution/spawn.ts} +10 -8
- package/src/isolation/git-command.ts +147 -0
- package/src/isolation/managed-paths.ts +145 -0
- package/src/{recovery.ts → isolation/recovery.ts} +42 -13
- package/src/{temp-hygiene.ts → isolation/temp-hygiene.ts} +7 -7
- package/src/{worktree.ts → isolation/worktree.ts} +11 -158
- package/src/{completion.ts → lifecycle/completion.ts} +2 -2
- package/src/{durable.ts → lifecycle/durable.ts} +101 -27
- package/src/{runtime.ts → lifecycle/runtime.ts} +12 -12
- package/src/{thread-lifecycle.ts → lifecycle/thread-lifecycle.ts} +25 -519
- package/src/lifecycle/thread-restore.ts +253 -0
- package/src/lifecycle/thread-shared.ts +269 -0
- package/src/{tools.ts → lifecycle/tools.ts} +51 -13
- package/src/{announcements.ts → presentation/announcements.ts} +4 -4
- package/src/{format.ts → presentation/format.ts} +3 -3
- package/src/{monitor.ts → presentation/monitor.ts} +2 -2
- package/src/{widget.ts → presentation/widget.ts} +1 -1
- package/agents/sentinel.md +0 -16
- package/src/setup.ts +0 -344
- package/src/ui.ts +0 -160
- /package/src/{models.ts → configuration/models.ts} +0 -0
- /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 "
|
|
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 "
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
371
|
-
|
|
372
|
-
|
|
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;
|
|
388
|
-
*
|
|
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
|
|
440
|
-
*
|
|
441
|
-
*
|
|
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 "
|
|
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 "
|
|
21
|
+
import { type ThinkingLevel } from "../configuration/config.ts";
|
|
22
22
|
import { removeThreadRecord, threadRecordFromThread, upsertThreadRecord, type ThreadRecord } from "./durable.ts";
|
|
23
|
-
import { isRunActiveStatus, monitor } from "
|
|
24
|
-
import type { RpcRunControl } from "
|
|
25
|
-
import type { StartBackgroundInternal } from "./thread-
|
|
26
|
-
import { isFailedResult, type SingleResult } from "
|
|
27
|
-
import type { IsolationMode, WorktreeFinalization, WorktreeIsolation } from "
|
|
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
|
|
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
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
pi.sendMessage(message, { deliverAs: "
|
|
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 });
|