@ferris1225/pi-subagents 4.1.3 → 4.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -99
- package/agents/cleaner.md +3 -2
- package/agents/documenter.md +6 -6
- package/agents/reviewer.md +8 -3
- package/agents/worker.md +4 -4
- package/package.json +1 -1
- package/src/completion.ts +160 -160
- package/src/config.ts +8 -6
- package/src/dispatch.ts +173 -65
- package/src/fixloop.ts +108 -102
- package/src/models.ts +189 -189
- package/src/monitor.ts +25 -0
- package/src/prompt.ts +24 -26
- package/src/recovery.ts +145 -145
- package/src/session-fork.ts +80 -80
- package/src/thread-lifecycle.ts +19 -5
- package/src/widget.ts +59 -8
package/src/recovery.ts
CHANGED
|
@@ -1,145 +1,145 @@
|
|
|
1
|
-
/** Durable handoff for worktree integration/cleanup failures across sessions. */
|
|
2
|
-
|
|
3
|
-
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { existsSync } from "node:fs";
|
|
5
|
-
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
6
|
-
import { dirname, join } from "node:path";
|
|
7
|
-
import { stripVTControlCharacters } from "node:util";
|
|
8
|
-
import type { WorktreeFinalization } from "./worktree.ts";
|
|
9
|
-
|
|
10
|
-
export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";
|
|
11
|
-
const RECOVERY_MANIFEST_VERSION = 1;
|
|
12
|
-
|
|
13
|
-
export interface RecoveryRecord {
|
|
14
|
-
runId: number;
|
|
15
|
-
createdAt: number;
|
|
16
|
-
integrated: boolean;
|
|
17
|
-
worktreePath?: string;
|
|
18
|
-
patchPath?: string;
|
|
19
|
-
error?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface RecoveryManifest {
|
|
23
|
-
version: number;
|
|
24
|
-
records: RecoveryRecord[];
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function getRecoveryManifestPath(configPath: string): string {
|
|
28
|
-
return join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function normalizeRecord(value: unknown): RecoveryRecord | undefined {
|
|
32
|
-
if (!value || typeof value !== "object") return undefined;
|
|
33
|
-
const raw = value as Record<string, unknown>;
|
|
34
|
-
if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
|
|
35
|
-
if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
|
|
36
|
-
return {
|
|
37
|
-
runId: raw.runId,
|
|
38
|
-
createdAt: raw.createdAt,
|
|
39
|
-
integrated: raw.integrated === true,
|
|
40
|
-
...(typeof raw.worktreePath === "string" && raw.worktreePath ? { worktreePath: raw.worktreePath } : {}),
|
|
41
|
-
...(typeof raw.patchPath === "string" && raw.patchPath ? { patchPath: raw.patchPath } : {}),
|
|
42
|
-
...(typeof raw.error === "string" && raw.error ? { error: raw.error } : {}),
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
|
|
47
|
-
try {
|
|
48
|
-
const parsed = JSON.parse(await readFile(getRecoveryManifestPath(configPath), "utf8")) as {
|
|
49
|
-
records?: unknown;
|
|
50
|
-
};
|
|
51
|
-
if (!Array.isArray(parsed.records)) return [];
|
|
52
|
-
return parsed.records.flatMap((record) => {
|
|
53
|
-
const normalized = normalizeRecord(record);
|
|
54
|
-
return normalized ? [normalized] : [];
|
|
55
|
-
});
|
|
56
|
-
} catch {
|
|
57
|
-
return [];
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function recoveryKey(record: RecoveryRecord): string {
|
|
62
|
-
return `${record.runId}\0${record.worktreePath ?? ""}\0${record.patchPath ?? ""}\0${record.error ?? ""}`;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function writeManifest(path: string, records: readonly RecoveryRecord[]): Promise<void> {
|
|
66
|
-
if (records.length === 0) {
|
|
67
|
-
await rm(path, { force: true });
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
await mkdir(dirname(path), { recursive: true });
|
|
71
|
-
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
72
|
-
try {
|
|
73
|
-
const manifest: RecoveryManifest = {
|
|
74
|
-
version: RECOVERY_MANIFEST_VERSION,
|
|
75
|
-
records: [...records],
|
|
76
|
-
};
|
|
77
|
-
await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
78
|
-
await rename(temporaryPath, path);
|
|
79
|
-
} finally {
|
|
80
|
-
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Merge retained artifacts into the durable manifest. */
|
|
85
|
-
export async function persistRecoveryRecords(
|
|
86
|
-
configPath: string,
|
|
87
|
-
records: readonly RecoveryRecord[],
|
|
88
|
-
): Promise<void> {
|
|
89
|
-
if (records.length === 0) return;
|
|
90
|
-
const path = getRecoveryManifestPath(configPath);
|
|
91
|
-
await withFileMutationQueue(path, async () => {
|
|
92
|
-
const merged = new Map<string, RecoveryRecord>();
|
|
93
|
-
for (const record of await readRecoveryRecords(configPath)) merged.set(recoveryKey(record), record);
|
|
94
|
-
for (const record of records) merged.set(recoveryKey(record), record);
|
|
95
|
-
await writeManifest(path, [...merged.values()]);
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function recoveryRecordFromFinalization(
|
|
100
|
-
runId: number,
|
|
101
|
-
finalization: WorktreeFinalization,
|
|
102
|
-
now = Date.now(),
|
|
103
|
-
): RecoveryRecord {
|
|
104
|
-
return {
|
|
105
|
-
runId,
|
|
106
|
-
createdAt: now,
|
|
107
|
-
integrated: finalization.integrated,
|
|
108
|
-
...(finalization.worktreePath ? { worktreePath: finalization.worktreePath } : {}),
|
|
109
|
-
...(finalization.patchPath ? { patchPath: finalization.patchPath } : {}),
|
|
110
|
-
...(finalization.error ? { error: finalization.error } : {}),
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Show retained recovery paths on every later session start until the user
|
|
115
|
-
* removes the artifacts. Stale records are pruned automatically. */
|
|
116
|
-
export async function announceRecoveryRecords(
|
|
117
|
-
configPath: string,
|
|
118
|
-
ctx: {
|
|
119
|
-
hasUI?: boolean;
|
|
120
|
-
ui: { notify(message: string, kind: "info" | "warning" | "error"): void };
|
|
121
|
-
},
|
|
122
|
-
): Promise<void> {
|
|
123
|
-
if (ctx.hasUI === false) return;
|
|
124
|
-
const records = await readRecoveryRecords(configPath);
|
|
125
|
-
if (records.length === 0) return;
|
|
126
|
-
const live = records.filter((record) =>
|
|
127
|
-
(record.worktreePath ? existsSync(record.worktreePath) : false) ||
|
|
128
|
-
(record.patchPath ? existsSync(record.patchPath) : false),
|
|
129
|
-
);
|
|
130
|
-
if (live.length !== records.length) {
|
|
131
|
-
const path = getRecoveryManifestPath(configPath);
|
|
132
|
-
await withFileMutationQueue(path, () => writeManifest(path, live)).catch(() => undefined);
|
|
133
|
-
}
|
|
134
|
-
for (const record of live) {
|
|
135
|
-
const paths = [
|
|
136
|
-
record.worktreePath ? `worktree ${stripVTControlCharacters(record.worktreePath)}` : undefined,
|
|
137
|
-
record.patchPath ? `patch ${stripVTControlCharacters(record.patchPath)}` : undefined,
|
|
138
|
-
].filter(Boolean).join(" · ");
|
|
139
|
-
const reason = record.error ? ` · ${stripVTControlCharacters(record.error)}` : "";
|
|
140
|
-
ctx.ui.notify(
|
|
141
|
-
`pi-subagents recovery for run #${record.runId}: ${record.integrated ? "changes were applied but cleanup failed" : "integration failed"}${paths ? ` · retained ${paths}` : ""}${reason}`,
|
|
142
|
-
"error",
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
1
|
+
/** Durable handoff for worktree integration/cleanup failures across sessions. */
|
|
2
|
+
|
|
3
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { stripVTControlCharacters } from "node:util";
|
|
8
|
+
import type { WorktreeFinalization } from "./worktree.ts";
|
|
9
|
+
|
|
10
|
+
export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";
|
|
11
|
+
const RECOVERY_MANIFEST_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
export interface RecoveryRecord {
|
|
14
|
+
runId: number;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
integrated: boolean;
|
|
17
|
+
worktreePath?: string;
|
|
18
|
+
patchPath?: string;
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RecoveryManifest {
|
|
23
|
+
version: number;
|
|
24
|
+
records: RecoveryRecord[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getRecoveryManifestPath(configPath: string): string {
|
|
28
|
+
return join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeRecord(value: unknown): RecoveryRecord | undefined {
|
|
32
|
+
if (!value || typeof value !== "object") return undefined;
|
|
33
|
+
const raw = value as Record<string, unknown>;
|
|
34
|
+
if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
|
|
35
|
+
if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
|
|
36
|
+
return {
|
|
37
|
+
runId: raw.runId,
|
|
38
|
+
createdAt: raw.createdAt,
|
|
39
|
+
integrated: raw.integrated === true,
|
|
40
|
+
...(typeof raw.worktreePath === "string" && raw.worktreePath ? { worktreePath: raw.worktreePath } : {}),
|
|
41
|
+
...(typeof raw.patchPath === "string" && raw.patchPath ? { patchPath: raw.patchPath } : {}),
|
|
42
|
+
...(typeof raw.error === "string" && raw.error ? { error: raw.error } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(await readFile(getRecoveryManifestPath(configPath), "utf8")) as {
|
|
49
|
+
records?: unknown;
|
|
50
|
+
};
|
|
51
|
+
if (!Array.isArray(parsed.records)) return [];
|
|
52
|
+
return parsed.records.flatMap((record) => {
|
|
53
|
+
const normalized = normalizeRecord(record);
|
|
54
|
+
return normalized ? [normalized] : [];
|
|
55
|
+
});
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function recoveryKey(record: RecoveryRecord): string {
|
|
62
|
+
return `${record.runId}\0${record.worktreePath ?? ""}\0${record.patchPath ?? ""}\0${record.error ?? ""}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function writeManifest(path: string, records: readonly RecoveryRecord[]): Promise<void> {
|
|
66
|
+
if (records.length === 0) {
|
|
67
|
+
await rm(path, { force: true });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await mkdir(dirname(path), { recursive: true });
|
|
71
|
+
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
72
|
+
try {
|
|
73
|
+
const manifest: RecoveryManifest = {
|
|
74
|
+
version: RECOVERY_MANIFEST_VERSION,
|
|
75
|
+
records: [...records],
|
|
76
|
+
};
|
|
77
|
+
await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
78
|
+
await rename(temporaryPath, path);
|
|
79
|
+
} finally {
|
|
80
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Merge retained artifacts into the durable manifest. */
|
|
85
|
+
export async function persistRecoveryRecords(
|
|
86
|
+
configPath: string,
|
|
87
|
+
records: readonly RecoveryRecord[],
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
if (records.length === 0) return;
|
|
90
|
+
const path = getRecoveryManifestPath(configPath);
|
|
91
|
+
await withFileMutationQueue(path, async () => {
|
|
92
|
+
const merged = new Map<string, RecoveryRecord>();
|
|
93
|
+
for (const record of await readRecoveryRecords(configPath)) merged.set(recoveryKey(record), record);
|
|
94
|
+
for (const record of records) merged.set(recoveryKey(record), record);
|
|
95
|
+
await writeManifest(path, [...merged.values()]);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function recoveryRecordFromFinalization(
|
|
100
|
+
runId: number,
|
|
101
|
+
finalization: WorktreeFinalization,
|
|
102
|
+
now = Date.now(),
|
|
103
|
+
): RecoveryRecord {
|
|
104
|
+
return {
|
|
105
|
+
runId,
|
|
106
|
+
createdAt: now,
|
|
107
|
+
integrated: finalization.integrated,
|
|
108
|
+
...(finalization.worktreePath ? { worktreePath: finalization.worktreePath } : {}),
|
|
109
|
+
...(finalization.patchPath ? { patchPath: finalization.patchPath } : {}),
|
|
110
|
+
...(finalization.error ? { error: finalization.error } : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Show retained recovery paths on every later session start until the user
|
|
115
|
+
* removes the artifacts. Stale records are pruned automatically. */
|
|
116
|
+
export async function announceRecoveryRecords(
|
|
117
|
+
configPath: string,
|
|
118
|
+
ctx: {
|
|
119
|
+
hasUI?: boolean;
|
|
120
|
+
ui: { notify(message: string, kind: "info" | "warning" | "error"): void };
|
|
121
|
+
},
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
if (ctx.hasUI === false) return;
|
|
124
|
+
const records = await readRecoveryRecords(configPath);
|
|
125
|
+
if (records.length === 0) return;
|
|
126
|
+
const live = records.filter((record) =>
|
|
127
|
+
(record.worktreePath ? existsSync(record.worktreePath) : false) ||
|
|
128
|
+
(record.patchPath ? existsSync(record.patchPath) : false),
|
|
129
|
+
);
|
|
130
|
+
if (live.length !== records.length) {
|
|
131
|
+
const path = getRecoveryManifestPath(configPath);
|
|
132
|
+
await withFileMutationQueue(path, () => writeManifest(path, live)).catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
for (const record of live) {
|
|
135
|
+
const paths = [
|
|
136
|
+
record.worktreePath ? `worktree ${stripVTControlCharacters(record.worktreePath)}` : undefined,
|
|
137
|
+
record.patchPath ? `patch ${stripVTControlCharacters(record.patchPath)}` : undefined,
|
|
138
|
+
].filter(Boolean).join(" · ");
|
|
139
|
+
const reason = record.error ? ` · ${stripVTControlCharacters(record.error)}` : "";
|
|
140
|
+
ctx.ui.notify(
|
|
141
|
+
`pi-subagents recovery for run #${record.runId}: ${record.integrated ? "changes were applied but cleanup failed" : "integration failed"}${paths ? ` · retained ${paths}` : ""}${reason}`,
|
|
142
|
+
"error",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
package/src/session-fork.ts
CHANGED
|
@@ -1,80 +1,80 @@
|
|
|
1
|
-
/** Pi SessionManager-backed cloning of a retained sub-agent session branch. */
|
|
2
|
-
|
|
3
|
-
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { existsSync } from "node:fs";
|
|
5
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
6
|
-
import { tmpdir } from "node:os";
|
|
7
|
-
import { join } from "node:path";
|
|
8
|
-
|
|
9
|
-
export interface ForkedSession {
|
|
10
|
-
sessionDir: string;
|
|
11
|
-
sessionId: string;
|
|
12
|
-
sessionFile: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Locate one retained session by its authoritative header id. */
|
|
16
|
-
export async function findRetainedSessionFile(
|
|
17
|
-
sessionDir: string,
|
|
18
|
-
sessionId: string,
|
|
19
|
-
): Promise<string> {
|
|
20
|
-
// The retained header may point at a worktree that has since been removed.
|
|
21
|
-
// The session id is authoritative inside this explicit private directory;
|
|
22
|
-
// listing the directory directly avoids a stale-cwd filter rejecting it.
|
|
23
|
-
const sessions = await SessionManager.listAll(sessionDir);
|
|
24
|
-
const matches = sessions.filter((session) => session.id === sessionId);
|
|
25
|
-
if (matches.length === 0) {
|
|
26
|
-
throw new Error(`Retained session ${sessionId} was not found in ${sessionDir}.`);
|
|
27
|
-
}
|
|
28
|
-
if (matches.length > 1) {
|
|
29
|
-
throw new Error(`Retained session id ${sessionId} is ambiguous in ${sessionDir}.`);
|
|
30
|
-
}
|
|
31
|
-
return matches[0].path;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Copy only the source file's active branch into a new isolated temp session
|
|
36
|
-
* directory. SessionManager performs all JSONL/tree handling; source state is
|
|
37
|
-
* never mutated.
|
|
38
|
-
*/
|
|
39
|
-
export async function forkRetainedSession(options: {
|
|
40
|
-
/** Cwd stored in the source session header (used for exact lookup). */
|
|
41
|
-
cwd: string;
|
|
42
|
-
/** Optional cwd for the cloned session header and future child tools. */
|
|
43
|
-
targetCwd?: string;
|
|
44
|
-
sessionDir: string;
|
|
45
|
-
sessionId: string;
|
|
46
|
-
}): Promise<ForkedSession> {
|
|
47
|
-
const sourceSessionFile = await findRetainedSessionFile(
|
|
48
|
-
options.sessionDir,
|
|
49
|
-
options.sessionId,
|
|
50
|
-
);
|
|
51
|
-
const sessionDir = await mkdtemp(join(tmpdir(), "pi-subagent-session-fork-"));
|
|
52
|
-
try {
|
|
53
|
-
// Supplying the new directory makes createBranchedSession write there.
|
|
54
|
-
// cwdOverride rewrites the cloned header so a settled isolated session can
|
|
55
|
-
// safely continue in its fresh worktree instead of a removed old path.
|
|
56
|
-
const manager = SessionManager.open(
|
|
57
|
-
sourceSessionFile,
|
|
58
|
-
sessionDir,
|
|
59
|
-
options.targetCwd ?? options.cwd,
|
|
60
|
-
);
|
|
61
|
-
const leafId = manager.getLeafId();
|
|
62
|
-
if (!leafId) throw new Error(`Retained session ${options.sessionId} has no active branch to fork.`);
|
|
63
|
-
const sessionFile = manager.createBranchedSession(leafId);
|
|
64
|
-
if (!sessionFile) throw new Error("Pi SessionManager did not create a persistent fork.");
|
|
65
|
-
// Pi defers branch files that contain no assistant response. Such a file
|
|
66
|
-
// cannot be resumed by RPC without creating a blank session, so reject
|
|
67
|
-
// rather than pretending context was preserved.
|
|
68
|
-
if (!existsSync(sessionFile)) {
|
|
69
|
-
throw new Error(`Forked session branch has no persisted assistant checkpoint at ${sessionFile}.`);
|
|
70
|
-
}
|
|
71
|
-
return {
|
|
72
|
-
sessionDir,
|
|
73
|
-
sessionId: manager.getSessionId(),
|
|
74
|
-
sessionFile,
|
|
75
|
-
};
|
|
76
|
-
} catch (error) {
|
|
77
|
-
await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
78
|
-
throw error;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
1
|
+
/** Pi SessionManager-backed cloning of a retained sub-agent session branch. */
|
|
2
|
+
|
|
3
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
export interface ForkedSession {
|
|
10
|
+
sessionDir: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
sessionFile: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Locate one retained session by its authoritative header id. */
|
|
16
|
+
export async function findRetainedSessionFile(
|
|
17
|
+
sessionDir: string,
|
|
18
|
+
sessionId: string,
|
|
19
|
+
): Promise<string> {
|
|
20
|
+
// The retained header may point at a worktree that has since been removed.
|
|
21
|
+
// The session id is authoritative inside this explicit private directory;
|
|
22
|
+
// listing the directory directly avoids a stale-cwd filter rejecting it.
|
|
23
|
+
const sessions = await SessionManager.listAll(sessionDir);
|
|
24
|
+
const matches = sessions.filter((session) => session.id === sessionId);
|
|
25
|
+
if (matches.length === 0) {
|
|
26
|
+
throw new Error(`Retained session ${sessionId} was not found in ${sessionDir}.`);
|
|
27
|
+
}
|
|
28
|
+
if (matches.length > 1) {
|
|
29
|
+
throw new Error(`Retained session id ${sessionId} is ambiguous in ${sessionDir}.`);
|
|
30
|
+
}
|
|
31
|
+
return matches[0].path;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Copy only the source file's active branch into a new isolated temp session
|
|
36
|
+
* directory. SessionManager performs all JSONL/tree handling; source state is
|
|
37
|
+
* never mutated.
|
|
38
|
+
*/
|
|
39
|
+
export async function forkRetainedSession(options: {
|
|
40
|
+
/** Cwd stored in the source session header (used for exact lookup). */
|
|
41
|
+
cwd: string;
|
|
42
|
+
/** Optional cwd for the cloned session header and future child tools. */
|
|
43
|
+
targetCwd?: string;
|
|
44
|
+
sessionDir: string;
|
|
45
|
+
sessionId: string;
|
|
46
|
+
}): Promise<ForkedSession> {
|
|
47
|
+
const sourceSessionFile = await findRetainedSessionFile(
|
|
48
|
+
options.sessionDir,
|
|
49
|
+
options.sessionId,
|
|
50
|
+
);
|
|
51
|
+
const sessionDir = await mkdtemp(join(tmpdir(), "pi-subagent-session-fork-"));
|
|
52
|
+
try {
|
|
53
|
+
// Supplying the new directory makes createBranchedSession write there.
|
|
54
|
+
// cwdOverride rewrites the cloned header so a settled isolated session can
|
|
55
|
+
// safely continue in its fresh worktree instead of a removed old path.
|
|
56
|
+
const manager = SessionManager.open(
|
|
57
|
+
sourceSessionFile,
|
|
58
|
+
sessionDir,
|
|
59
|
+
options.targetCwd ?? options.cwd,
|
|
60
|
+
);
|
|
61
|
+
const leafId = manager.getLeafId();
|
|
62
|
+
if (!leafId) throw new Error(`Retained session ${options.sessionId} has no active branch to fork.`);
|
|
63
|
+
const sessionFile = manager.createBranchedSession(leafId);
|
|
64
|
+
if (!sessionFile) throw new Error("Pi SessionManager did not create a persistent fork.");
|
|
65
|
+
// Pi defers branch files that contain no assistant response. Such a file
|
|
66
|
+
// cannot be resumed by RPC without creating a blank session, so reject
|
|
67
|
+
// rather than pretending context was preserved.
|
|
68
|
+
if (!existsSync(sessionFile)) {
|
|
69
|
+
throw new Error(`Forked session branch has no persisted assistant checkpoint at ${sessionFile}.`);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
sessionDir,
|
|
73
|
+
sessionId: manager.getSessionId(),
|
|
74
|
+
sessionFile,
|
|
75
|
+
};
|
|
76
|
+
} catch (error) {
|
|
77
|
+
await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/thread-lifecycle.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stable logical-thread generation lifecycle for background sub-agents.
|
|
3
3
|
*
|
|
4
|
-
* Dispatch owns workflow policy
|
|
4
|
+
* Dispatch owns workflow policy, the live stage projection, and internal role
|
|
5
|
+
* briefs; this module owns one
|
|
5
6
|
* stable parent generation end to end: managed-repository lane use,
|
|
6
7
|
* worktree setup/finalization after downstream review, queue/process ownership,
|
|
7
8
|
* retained-session resume/fork, and guarded one-time terminal publication.
|
|
@@ -88,6 +89,16 @@ export function isWorktreeCapableAgent(agent: AgentConfig): boolean {
|
|
|
88
89
|
return isWriteCapableAgent(agent);
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
/** A direct reviewer otherwise cannot infer enabled-role availability from its
|
|
93
|
+
* isolated task. Managed internal gates receive the same contract in their
|
|
94
|
+
* generated briefs. Advisory reviews still emit neither machine marker. */
|
|
95
|
+
function withEnabledDocumenterReviewContract(agent: AgentConfig): AgentConfig {
|
|
96
|
+
return {
|
|
97
|
+
...agent,
|
|
98
|
+
systemPrompt: `${agent.systemPrompt.trimEnd()}\n\nRuntime workflow context: documenter is enabled. In gate reviews, documentation drift is non-gating: emit DOCUMENTATION: NEEDED with ## Documentation notes, or DOCUMENTATION: CLEAN when no sync is needed. Advisory reviews still emit neither VERDICT nor DOCUMENTATION markers.`.trim(),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
91
102
|
interface DispatchEnvironment {
|
|
92
103
|
ctx: ExtensionContext;
|
|
93
104
|
config: SubagentsConfig;
|
|
@@ -249,7 +260,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
249
260
|
if (!discoveredAgent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
|
|
250
261
|
const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
|
|
251
262
|
resolveAgentTools({ ...candidate, tools: discoveredAgent.tools }, runtime.getActiveTools());
|
|
252
|
-
const
|
|
263
|
+
const resolvedAgent = resolveLiveAgentTools(discoveredAgent);
|
|
264
|
+
const agent = agentName === "reviewer" && runAgents.some((candidate) => candidate.name === "documenter")
|
|
265
|
+
? withEnabledDocumenterReviewContract(resolvedAgent)
|
|
266
|
+
: resolvedAgent;
|
|
253
267
|
if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
|
|
254
268
|
return {
|
|
255
269
|
...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as worker, cleaner, or documenter.`),
|
|
@@ -1051,9 +1065,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
1051
1065
|
thread.lifecycleOperation === "settle" &&
|
|
1052
1066
|
!thread.retired;
|
|
1053
1067
|
try {
|
|
1054
|
-
// For isolated writers this is deliberately after the managed
|
|
1055
|
-
// and
|
|
1056
|
-
// lifecycle owner integrates the complete
|
|
1068
|
+
// For isolated writers this is deliberately after the managed reviewer
|
|
1069
|
+
// and any needed documentation stage: every child sees the same worktree,
|
|
1070
|
+
// then one lifecycle owner integrates the complete settled state exactly once.
|
|
1057
1071
|
await thread.finalizeIsolation(generation, result);
|
|
1058
1072
|
if (!ownsSettlement()) return;
|
|
1059
1073
|
if (workflowOutcome && isolation === "worktree") {
|
package/src/widget.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
monitor,
|
|
11
11
|
statusIcon,
|
|
12
12
|
type RunView,
|
|
13
|
+
type WorkflowStage,
|
|
13
14
|
} from "./monitor.ts";
|
|
14
15
|
|
|
15
16
|
export const SUBAGENTS_WIDGET_ID = "pi-subagents";
|
|
@@ -50,7 +51,9 @@ function runPrimaryLine(
|
|
|
50
51
|
prefix: string,
|
|
51
52
|
): string {
|
|
52
53
|
const dim = (text: string): string => theme.fg("dim", text);
|
|
53
|
-
const icon =
|
|
54
|
+
const icon = run.managedWorkflow && run.status === "running"
|
|
55
|
+
? theme.fg("accent", theme.bold("◆"))
|
|
56
|
+
: statusIcon(run.status, theme);
|
|
54
57
|
const displayName = run.managedWorkflow ? `${run.agent} workflow` : run.agent;
|
|
55
58
|
const name = theme.fg("accent", theme.bold(displayName));
|
|
56
59
|
const identity = `${prefix}${icon} ${name}`;
|
|
@@ -72,7 +75,9 @@ function runPrimaryLine(
|
|
|
72
75
|
: undefined;
|
|
73
76
|
const taskSource = run.parentRunId !== undefined
|
|
74
77
|
? [run.relationLabel, run.label].filter((part): part is string => Boolean(part)).join(" · ")
|
|
75
|
-
:
|
|
78
|
+
: run.managedWorkflow
|
|
79
|
+
? run.label ?? formatTaskSummary(run.task, 64)
|
|
80
|
+
: formatTaskSummary(run.task, 64);
|
|
76
81
|
const taskDesiredSource = [continuation, taskSource]
|
|
77
82
|
.filter((part): part is string => Boolean(part))
|
|
78
83
|
.join(" · ");
|
|
@@ -129,6 +134,46 @@ function runPrimaryLine(
|
|
|
129
134
|
return compactLine(primaryLeft, elapsed ? dim(`· ${elapsed}`) : "", width);
|
|
130
135
|
}
|
|
131
136
|
|
|
137
|
+
function workflowStageToken(stage: WorkflowStage, theme: Theme): string {
|
|
138
|
+
const content = (icon: string): string => `${icon} ${stage.relation}`;
|
|
139
|
+
switch (stage.status) {
|
|
140
|
+
case "done":
|
|
141
|
+
return theme.fg("success", content("✓"));
|
|
142
|
+
case "active":
|
|
143
|
+
return theme.fg("accent", theme.bold(content("●")));
|
|
144
|
+
case "changes":
|
|
145
|
+
return theme.fg("warning", content("!"));
|
|
146
|
+
case "failed":
|
|
147
|
+
return theme.fg("error", content("✗"));
|
|
148
|
+
default:
|
|
149
|
+
return theme.fg("dim", content("○"));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Render the real/currently planned stage sequence. On narrow terminals the
|
|
154
|
+
* active (or next actionable) stage becomes the left edge so it survives before
|
|
155
|
+
* older history; the workflow-wide elapsed tail is independently preserved on
|
|
156
|
+
* the primary line. */
|
|
157
|
+
function workflowTimelineLine(run: RunView, theme: Theme, width: number): string | undefined {
|
|
158
|
+
const stages = run.workflowStages;
|
|
159
|
+
const indent = " ";
|
|
160
|
+
if (!stages || stages.length === 0 || width <= visibleWidth(indent)) return undefined;
|
|
161
|
+
const separator = theme.fg("dim", " ─ ");
|
|
162
|
+
const render = (items: readonly WorkflowStage[]): string =>
|
|
163
|
+
items.map((stage) => workflowStageToken(stage, theme)).join(separator);
|
|
164
|
+
const full = `${indent}${render(stages)}`;
|
|
165
|
+
if (visibleWidth(full) <= width) return full;
|
|
166
|
+
|
|
167
|
+
let focusIndex = stages.findIndex((stage) => stage.status === "active");
|
|
168
|
+
if (focusIndex === -1) {
|
|
169
|
+
focusIndex = stages.findLastIndex((stage) => stage.status === "changes" || stage.status === "failed");
|
|
170
|
+
}
|
|
171
|
+
if (focusIndex === -1) focusIndex = stages.findIndex((stage) => stage.status === "pending");
|
|
172
|
+
if (focusIndex === -1) focusIndex = stages.length - 1;
|
|
173
|
+
const omittedPrefix = focusIndex > 0 ? theme.fg("dim", "… ─ ") : "";
|
|
174
|
+
return truncateToWidth(`${indent}${omittedPrefix}${render(stages.slice(focusIndex))}`, width, "…");
|
|
175
|
+
}
|
|
176
|
+
|
|
132
177
|
function runActivityLine(run: RunView, theme: Theme, width: number, indent: string): string[] {
|
|
133
178
|
const dim = (text: string): string => theme.fg("dim", text);
|
|
134
179
|
const activity = run.activity?.trim();
|
|
@@ -140,9 +185,10 @@ function runActivityLine(run: RunView, theme: Theme, width: number, indent: stri
|
|
|
140
185
|
return [truncateToWidth(`${indent}${dim(activitySummary)}`, width, "")];
|
|
141
186
|
}
|
|
142
187
|
|
|
143
|
-
/** Render active runs as
|
|
144
|
-
*
|
|
145
|
-
* include their source id; other
|
|
188
|
+
/** Render active runs as compact workflow-aware trees. Stable managed parents
|
|
189
|
+
* retain their stage timeline while the current internal child supplies exact
|
|
190
|
+
* model/thinking/activity telemetry. Fork labels include their source id; other
|
|
191
|
+
* control ids remain available through status. */
|
|
146
192
|
export function formatActiveRunLines(
|
|
147
193
|
runs: readonly RunView[],
|
|
148
194
|
theme: Theme,
|
|
@@ -166,9 +212,14 @@ export function formatActiveRunLines(
|
|
|
166
212
|
for (const root of roots) {
|
|
167
213
|
const children = childrenOf.get(root.id) ?? [];
|
|
168
214
|
lines.push(runPrimaryLine(root, theme, width, now, ""));
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (
|
|
215
|
+
const hasTimeline = Boolean(root.managedWorkflow && root.workflowStages?.length);
|
|
216
|
+
const timeline = hasTimeline ? workflowTimelineLine(root, theme, width) : undefined;
|
|
217
|
+
if (timeline) lines.push(timeline);
|
|
218
|
+
// A parent placeholder ("managed workflow running") would duplicate the
|
|
219
|
+
// timeline. Standalone roots keep their useful activity line as before.
|
|
220
|
+
if (children.length === 0 && !hasTimeline) {
|
|
221
|
+
lines.push(...runActivityLine(root, theme, width, " "));
|
|
222
|
+
}
|
|
172
223
|
children.forEach((child, index) => {
|
|
173
224
|
const connector = index === children.length - 1 ? "└ " : "├ ";
|
|
174
225
|
lines.push(runPrimaryLine(child, theme, width, now, theme.fg("dim", ` ${connector}`)));
|