@pi-unipi/subagents 2.6.0 → 2.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -78
- package/agents/delegate.md +14 -0
- package/agents/oracle.md +78 -0
- package/agents/researcher.md +52 -0
- package/agents/reviewer.md +79 -0
- package/agents/scout.md +50 -0
- package/agents/worker.md +59 -0
- package/package.json +19 -17
- package/prompts/council.md +48 -0
- package/prompts/gather-context-and-clarify.md +13 -0
- package/prompts/parallel-cleanup.md +59 -0
- package/prompts/parallel-research.md +50 -0
- package/prompts/parallel-review.md +54 -0
- package/prompts/review-loop.md +43 -0
- package/skills/council-mode/SKILL.md +230 -0
- package/skills/subagents/SKILL.md +49 -0
- package/skills/subagents/references/constraints-and-recipes.md +259 -0
- package/skills/subagents/references/execution-controls.md +454 -0
- package/skills/subagents/references/management-authoring-rpc.md +161 -0
- package/skills/subagents/references/multi-lane-orchestration.md +39 -0
- package/skills/subagents/references/prompting-and-roles.md +267 -0
- package/src/acceptance.ts +331 -0
- package/src/agent-manager.ts +405 -0
- package/src/agent-memory.ts +300 -0
- package/src/agent-overrides.ts +183 -0
- package/src/agent-runner.ts +265 -0
- package/src/async-runner.ts +417 -0
- package/src/authority-policy.ts +47 -0
- package/src/budgets.ts +285 -0
- package/src/child-safety.ts +140 -0
- package/src/config.ts +226 -0
- package/src/conversation-viewer.ts +281 -0
- package/src/core-compat.ts +94 -0
- package/src/custom-agents.ts +323 -0
- package/src/file-system-retry.ts +50 -0
- package/src/fleet-data.ts +61 -0
- package/src/fleet-view.ts +316 -0
- package/src/foreground-detach.ts +59 -0
- package/src/fork-context.ts +285 -0
- package/src/global.d.ts +7 -0
- package/src/guide.ts +129 -0
- package/src/index.ts +1149 -0
- package/src/mission-state.ts +133 -0
- package/src/mission-store.ts +434 -0
- package/src/model-resolver.ts +79 -0
- package/src/output-limits.ts +142 -0
- package/src/parity-types.ts +315 -0
- package/src/pi-args.ts +161 -0
- package/src/pi-spawn.ts +147 -0
- package/src/result-files.ts +232 -0
- package/src/result-watcher.ts +221 -0
- package/src/retained-children.ts +176 -0
- package/src/run-fanout-budget.ts +356 -0
- package/src/scheduled-runs.ts +351 -0
- package/src/schemas.ts +263 -0
- package/src/slash-commands.ts +77 -0
- package/src/supervisor-channel.ts +249 -0
- package/src/tool-handler.ts +1144 -0
- package/src/types.ts +238 -0
- package/src/widget.ts +447 -0
- package/src/workflow-script.ts +737 -0
- package/src/workflow-worker.ts +384 -0
- package/src/worktree.ts +614 -0
- package/dist/agent-manager.d.ts +0 -81
- package/dist/agent-manager.d.ts.map +0 -1
- package/dist/agent-manager.js +0 -295
- package/dist/agent-manager.js.map +0 -1
- package/dist/agent-runner.d.ts +0 -51
- package/dist/agent-runner.d.ts.map +0 -1
- package/dist/agent-runner.js +0 -262
- package/dist/agent-runner.js.map +0 -1
- package/dist/config.d.ts +0 -24
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js +0 -132
- package/dist/config.js.map +0 -1
- package/dist/conversation-viewer.d.ts +0 -40
- package/dist/conversation-viewer.d.ts.map +0 -1
- package/dist/conversation-viewer.js +0 -276
- package/dist/conversation-viewer.js.map +0 -1
- package/dist/core-compat.d.ts +0 -21
- package/dist/core-compat.d.ts.map +0 -1
- package/dist/core-compat.js +0 -86
- package/dist/core-compat.js.map +0 -1
- package/dist/custom-agents.d.ts +0 -14
- package/dist/custom-agents.d.ts.map +0 -1
- package/dist/custom-agents.js +0 -113
- package/dist/custom-agents.js.map +0 -1
- package/dist/file-lock.d.ts +0 -42
- package/dist/file-lock.d.ts.map +0 -1
- package/dist/file-lock.js +0 -91
- package/dist/file-lock.js.map +0 -1
- package/dist/index.d.ts +0 -10
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -758
- package/dist/index.js.map +0 -1
- package/dist/model-resolver.d.ts +0 -19
- package/dist/model-resolver.d.ts.map +0 -1
- package/dist/model-resolver.js +0 -61
- package/dist/model-resolver.js.map +0 -1
- package/dist/types.d.ts +0 -98
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -47
- package/dist/types.js.map +0 -1
- package/dist/widget.d.ts +0 -56
- package/dist/widget.d.ts.map +0 -1
- package/dist/widget.js +0 -396
- package/dist/widget.js.map +0 -1
package/src/pi-spawn.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/subagents — Child pi process spawn command resolution
|
|
3
|
+
*
|
|
4
|
+
* Ported from pi-subagents src/runs/shared/pi-spawn.ts. Env override uses OUR
|
|
5
|
+
* prefix: UNIPI_SUBAGENT_PI_BINARY.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
export const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
13
|
+
export const UNIPI_SUBAGENT_PI_BINARY_ENV = "UNIPI_SUBAGENT_PI_BINARY";
|
|
14
|
+
|
|
15
|
+
export function findPiPackageRootFromEntry(entryPoint: string): string | undefined {
|
|
16
|
+
let dir = path.dirname(entryPoint);
|
|
17
|
+
while (dir !== path.dirname(dir)) {
|
|
18
|
+
const packageJsonPath = path.join(dir, "package.json");
|
|
19
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
20
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { name?: unknown };
|
|
21
|
+
if (pkg.name === PI_CODING_AGENT_PACKAGE) return dir;
|
|
22
|
+
}
|
|
23
|
+
dir = path.dirname(dir);
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveInstalledPiPackageRoot(): string | undefined {
|
|
29
|
+
return findPiPackageRootFromEntry(fileURLToPath(import.meta.resolve(PI_CODING_AGENT_PACKAGE)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolvePiPackageRoot(): string | undefined {
|
|
33
|
+
try {
|
|
34
|
+
const entry = process.argv[1];
|
|
35
|
+
return entry ? findPiPackageRootFromEntry(fs.realpathSync(entry)) : undefined;
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PiSpawnDeps {
|
|
42
|
+
platform?: NodeJS.Platform;
|
|
43
|
+
execPath?: string;
|
|
44
|
+
argv1?: string;
|
|
45
|
+
existsSync?: (filePath: string) => boolean;
|
|
46
|
+
realpathSync?: (filePath: string) => string;
|
|
47
|
+
readFileSync?: (filePath: string, encoding: "utf8") => string;
|
|
48
|
+
resolvePackageJson?: () => string;
|
|
49
|
+
resolvePackageEntry?: () => string;
|
|
50
|
+
piPackageRoot?: string;
|
|
51
|
+
env?: NodeJS.ProcessEnv;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PiSpawnCommand {
|
|
55
|
+
command: string;
|
|
56
|
+
args: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isRunnableNodeScript(
|
|
60
|
+
filePath: string,
|
|
61
|
+
existsSync: (filePath: string) => boolean,
|
|
62
|
+
): boolean {
|
|
63
|
+
if (!existsSync(filePath)) return false;
|
|
64
|
+
return /\.(?:mjs|cjs|js)$/i.test(filePath);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizePath(filePath: string): string {
|
|
68
|
+
return path.isAbsolute(filePath) ? path.resolve(filePath) : filePath;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isStandalonePiExecutable(execPath: string): boolean {
|
|
72
|
+
const executableName = execPath.split(/[\\/]/).pop();
|
|
73
|
+
return /^pi(?:\.exe)?$/i.test(executableName ?? "");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolvePiCliScript(deps: PiSpawnDeps = {}): string | undefined {
|
|
77
|
+
const existsSync = deps.existsSync ?? fs.existsSync;
|
|
78
|
+
const realpathSync = deps.realpathSync ?? fs.realpathSync;
|
|
79
|
+
const readFileSync =
|
|
80
|
+
deps.readFileSync ?? ((filePath: string, encoding: "utf8") => fs.readFileSync(filePath, encoding));
|
|
81
|
+
const argv1 = deps.argv1 ?? process.argv[1];
|
|
82
|
+
|
|
83
|
+
if (argv1) {
|
|
84
|
+
const argvPath = normalizePath(argv1);
|
|
85
|
+
if (isRunnableNodeScript(argvPath, existsSync)) {
|
|
86
|
+
try {
|
|
87
|
+
const canonicalArgvPath = realpathSync(argvPath);
|
|
88
|
+
if (isRunnableNodeScript(canonicalArgvPath, existsSync) && findPiPackageRootFromEntry(canonicalArgvPath)) {
|
|
89
|
+
return canonicalArgvPath;
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// Host package metadata is untrusted here; keep resolving the installed Pi CLI.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const resolvePackageJson =
|
|
99
|
+
deps.resolvePackageJson ??
|
|
100
|
+
(() => {
|
|
101
|
+
const root = deps.piPackageRoot ?? resolvePiPackageRoot();
|
|
102
|
+
if (root) return path.join(root, "package.json");
|
|
103
|
+
const packageRoot = deps.resolvePackageEntry
|
|
104
|
+
? findPiPackageRootFromEntry(deps.resolvePackageEntry())
|
|
105
|
+
: resolveInstalledPiPackageRoot();
|
|
106
|
+
if (!packageRoot) {
|
|
107
|
+
throw new Error(`Could not resolve ${PI_CODING_AGENT_PACKAGE} package root`);
|
|
108
|
+
}
|
|
109
|
+
return path.join(packageRoot, "package.json");
|
|
110
|
+
});
|
|
111
|
+
const packageJsonPath = resolvePackageJson();
|
|
112
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
|
113
|
+
bin?: string | Record<string, string>;
|
|
114
|
+
};
|
|
115
|
+
const binField = packageJson.bin;
|
|
116
|
+
const binPath = typeof binField === "string" ? binField : (binField?.pi ?? Object.values(binField ?? {})[0]);
|
|
117
|
+
if (!binPath) return undefined;
|
|
118
|
+
const candidate = path.resolve(path.dirname(packageJsonPath), binPath);
|
|
119
|
+
if (isRunnableNodeScript(candidate, existsSync)) {
|
|
120
|
+
return candidate;
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function getPiSpawnCommand(args: string[], deps: PiSpawnDeps = {}): PiSpawnCommand {
|
|
130
|
+
const env = deps.env ?? process.env;
|
|
131
|
+
const piBinary = env[UNIPI_SUBAGENT_PI_BINARY_ENV]?.trim();
|
|
132
|
+
if (piBinary) {
|
|
133
|
+
return { command: piBinary, args };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const execPath = deps.execPath ?? process.execPath;
|
|
137
|
+
if (isStandalonePiExecutable(execPath)) {
|
|
138
|
+
return { command: execPath, args };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const piCliPath = resolvePiCliScript(deps);
|
|
142
|
+
if (piCliPath) {
|
|
143
|
+
return { command: execPath, args: [piCliPath, ...args] };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { command: "pi", args };
|
|
147
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/subagents — Durable async result files + indexes
|
|
3
|
+
*
|
|
4
|
+
* Ported from pi-subagents src/runs/background/result-files.ts (core).
|
|
5
|
+
* Results land under OUR RESULTS_DIR (temp root /async-subagent-results):
|
|
6
|
+
* <runId>.json — the result payload
|
|
7
|
+
* result-index/runs/<runId> — run-id index entry
|
|
8
|
+
* result-index/sessions/<sid> — session index entries
|
|
9
|
+
* result-pending/<sid>/<rid> — pending delivery markers
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { RESULTS_DIR } from "./parity-types.js";
|
|
15
|
+
|
|
16
|
+
export const RESULT_INDEX_VERSION = 1;
|
|
17
|
+
const RESULT_INDEX_DIR = "result-index";
|
|
18
|
+
const SESSION_INDEX_DIR = "sessions";
|
|
19
|
+
const RUN_INDEX_DIR = "runs";
|
|
20
|
+
const RESULT_PENDING_DIR = "result-pending";
|
|
21
|
+
|
|
22
|
+
export interface ResultIndexEntry {
|
|
23
|
+
version: 1;
|
|
24
|
+
runId: string;
|
|
25
|
+
sessionId: string;
|
|
26
|
+
file: string;
|
|
27
|
+
writtenAt: number;
|
|
28
|
+
asyncDir?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ResultFileData {
|
|
32
|
+
version: 1;
|
|
33
|
+
runId: string;
|
|
34
|
+
sessionId: string;
|
|
35
|
+
agent?: string;
|
|
36
|
+
output?: string;
|
|
37
|
+
structuredOutput?: unknown;
|
|
38
|
+
error?: string;
|
|
39
|
+
success?: boolean;
|
|
40
|
+
state?: string;
|
|
41
|
+
timedOut?: boolean;
|
|
42
|
+
stopped?: boolean;
|
|
43
|
+
processSignal?: string | null;
|
|
44
|
+
durationMs?: number;
|
|
45
|
+
writtenAt: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Encode an index segment safely (reference index-segment core). */
|
|
49
|
+
function encodeSegment(value: string): string {
|
|
50
|
+
const sanitized = value
|
|
51
|
+
.trim()
|
|
52
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
53
|
+
.replace(/^-+|-+$/g, "");
|
|
54
|
+
return sanitized || "unknown";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resultFilePath(resultsDir: string, runId: string): string {
|
|
58
|
+
return path.join(resultsDir, `${runId}.json`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sessionIndexDir(resultsDir: string, sessionId: string): string {
|
|
62
|
+
return path.join(resultsDir, RESULT_INDEX_DIR, SESSION_INDEX_DIR, encodeSegment(sessionId));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resultIndexPath(resultsDir: string, sessionId: string, runId: string): string {
|
|
66
|
+
return path.join(sessionIndexDir(resultsDir, sessionId), `${runId}.json`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function runIndexPath(resultsDir: string, runId: string): string {
|
|
70
|
+
return path.join(resultsDir, RESULT_INDEX_DIR, RUN_INDEX_DIR, `${runId}.json`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resultPendingPath(resultsDir: string, sessionId: string, runId: string): string {
|
|
74
|
+
return path.join(resultsDir, RESULT_PENDING_DIR, encodeSegment(sessionId), `${runId}.json`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeAtomicJson(filePath: string, value: unknown): void {
|
|
78
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
79
|
+
const tmp = `${filePath}.tmp-${process.pid}`;
|
|
80
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
81
|
+
fs.renameSync(tmp, filePath);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseResultIndexEntry(value: unknown): ResultIndexEntry | undefined {
|
|
85
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
86
|
+
const record = value as Partial<ResultIndexEntry>;
|
|
87
|
+
if (
|
|
88
|
+
record.version !== RESULT_INDEX_VERSION ||
|
|
89
|
+
typeof record.runId !== "string" ||
|
|
90
|
+
!record.runId ||
|
|
91
|
+
typeof record.sessionId !== "string" ||
|
|
92
|
+
!record.sessionId ||
|
|
93
|
+
typeof record.file !== "string" ||
|
|
94
|
+
!record.file ||
|
|
95
|
+
typeof record.writtenAt !== "number"
|
|
96
|
+
) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return record as ResultIndexEntry;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Write the durable result payload + run/session indexes + pending marker. */
|
|
103
|
+
export function writeAsyncResultFile(
|
|
104
|
+
resultsDir: string,
|
|
105
|
+
data: Omit<ResultFileData, "version" | "writtenAt">,
|
|
106
|
+
options: { asyncDir?: string } = {},
|
|
107
|
+
): ResultFileData {
|
|
108
|
+
const payload: ResultFileData = { ...data, version: 1, writtenAt: Date.now() };
|
|
109
|
+
const file = resultFilePath(resultsDir, payload.runId);
|
|
110
|
+
writeAtomicJson(file, payload);
|
|
111
|
+
|
|
112
|
+
const entry: ResultIndexEntry = {
|
|
113
|
+
version: RESULT_INDEX_VERSION,
|
|
114
|
+
runId: payload.runId,
|
|
115
|
+
sessionId: payload.sessionId,
|
|
116
|
+
file,
|
|
117
|
+
writtenAt: payload.writtenAt,
|
|
118
|
+
...(options.asyncDir ? { asyncDir: options.asyncDir } : {}),
|
|
119
|
+
};
|
|
120
|
+
writeAtomicJson(runIndexPath(resultsDir, payload.runId), entry);
|
|
121
|
+
writeAtomicJson(resultIndexPath(resultsDir, payload.sessionId, payload.runId), entry);
|
|
122
|
+
writeAtomicJson(resultPendingPath(resultsDir, payload.sessionId, payload.runId), entry);
|
|
123
|
+
return payload;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Read a result payload by run id (via the run index). */
|
|
127
|
+
export function readAsyncResultFile(resultsDir: string, runId: string): ResultFileData | undefined {
|
|
128
|
+
const entry = readResultIndexEntry(runIndexPath(resultsDir, runId));
|
|
129
|
+
if (!entry) return undefined;
|
|
130
|
+
try {
|
|
131
|
+
const parsed = JSON.parse(fs.readFileSync(entry.file, "utf8")) as ResultFileData;
|
|
132
|
+
if (parsed?.version !== 1 || typeof parsed.runId !== "string") return undefined;
|
|
133
|
+
return parsed;
|
|
134
|
+
} catch {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readResultIndexEntry(indexPath: string): ResultIndexEntry | undefined {
|
|
140
|
+
try {
|
|
141
|
+
return parseResultIndexEntry(JSON.parse(fs.readFileSync(indexPath, "utf8")));
|
|
142
|
+
} catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** List result index entries for a session. */
|
|
148
|
+
export function resultCandidateFilesForSession(
|
|
149
|
+
resultsDir: string,
|
|
150
|
+
sessionId: string,
|
|
151
|
+
): string[] {
|
|
152
|
+
const dir = sessionIndexDir(resultsDir, sessionId);
|
|
153
|
+
let entries: fs.Dirent[];
|
|
154
|
+
try {
|
|
155
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
156
|
+
} catch {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
return entries
|
|
160
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
161
|
+
.map((entry) => path.join(dir, entry.name))
|
|
162
|
+
.sort();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Remove a pending delivery marker (after delivery or dismissal). */
|
|
166
|
+
export function removePendingResult(resultsDir: string, sessionId: string, runId: string): void {
|
|
167
|
+
try {
|
|
168
|
+
fs.unlinkSync(resultPendingPath(resultsDir, sessionId, runId));
|
|
169
|
+
} catch {
|
|
170
|
+
// Already gone.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Remove the result payload + all indexes for a run. */
|
|
175
|
+
export function removeResultIndex(resultsDir: string, sessionId: string, runId: string): void {
|
|
176
|
+
removePendingResult(resultsDir, sessionId, runId);
|
|
177
|
+
for (const target of [
|
|
178
|
+
runIndexPath(resultsDir, runId),
|
|
179
|
+
resultIndexPath(resultsDir, sessionId, runId),
|
|
180
|
+
resultFilePath(resultsDir, runId),
|
|
181
|
+
]) {
|
|
182
|
+
try {
|
|
183
|
+
fs.unlinkSync(target);
|
|
184
|
+
} catch {
|
|
185
|
+
// Already gone.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Prune result indexes older than maxAgeMs (default 24h). Returns removed count. */
|
|
191
|
+
export function cleanupResultIndexes(resultsDir: string, now = Date.now(), maxAgeMs = 24 * 60 * 60 * 1000): number {
|
|
192
|
+
let removed = 0;
|
|
193
|
+
const indexRoot = path.join(resultsDir, RESULT_INDEX_DIR);
|
|
194
|
+
let walk: string[];
|
|
195
|
+
try {
|
|
196
|
+
walk = [indexRoot];
|
|
197
|
+
} catch {
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
while (walk.length > 0) {
|
|
201
|
+
const dir = walk.pop()!;
|
|
202
|
+
let entries: fs.Dirent[];
|
|
203
|
+
try {
|
|
204
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
205
|
+
} catch {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
const entryPath = path.join(dir, entry.name);
|
|
210
|
+
if (entry.isDirectory()) {
|
|
211
|
+
walk.push(entryPath);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (!entry.name.endsWith(".json")) continue;
|
|
215
|
+
try {
|
|
216
|
+
const stat = fs.statSync(entryPath);
|
|
217
|
+
if (now - stat.mtimeMs > maxAgeMs) {
|
|
218
|
+
fs.unlinkSync(entryPath);
|
|
219
|
+
removed++;
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
// raced away
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return removed;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Our default results dir. */
|
|
230
|
+
export function defaultResultsDir(): string {
|
|
231
|
+
return RESULTS_DIR;
|
|
232
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/subagents — Async result watcher
|
|
3
|
+
*
|
|
4
|
+
* Ported from pi-subagents src/runs/background/result-watcher.ts (core).
|
|
5
|
+
* Watches OUR results dir for pending delivery markers, parses payloads,
|
|
6
|
+
* delivers completion notifications to the owning session, and logs slow
|
|
7
|
+
* scans per the resultScanLogging config ("all" | "activity" | "off").
|
|
8
|
+
* Periodic healthy rescans keep cross-session deliveries working.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
readAsyncResultFile,
|
|
15
|
+
removePendingResult,
|
|
16
|
+
removeResultIndex,
|
|
17
|
+
resultCandidateFilesForSession,
|
|
18
|
+
type ResultFileData,
|
|
19
|
+
} from "./result-files.js";
|
|
20
|
+
|
|
21
|
+
const POLL_INTERVAL_MS = 3000;
|
|
22
|
+
const HEALTHY_SCAN_INTERVAL_MS = 60_000;
|
|
23
|
+
const SLOW_RESULT_SCAN_MS = 500;
|
|
24
|
+
|
|
25
|
+
export interface CompletionNotification {
|
|
26
|
+
runId: string;
|
|
27
|
+
agent?: string;
|
|
28
|
+
output?: string;
|
|
29
|
+
error?: string;
|
|
30
|
+
success: boolean;
|
|
31
|
+
state?: string;
|
|
32
|
+
timedOut?: boolean;
|
|
33
|
+
durationMs?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ResultWatcherDeps {
|
|
37
|
+
resultsDir: string;
|
|
38
|
+
sessionId: string;
|
|
39
|
+
/** Deliver a completion (e.g. pi.sendMessage notification). */
|
|
40
|
+
notifier: (notification: CompletionNotification) => void;
|
|
41
|
+
/** Logging mode for slow scans. Default "all". */
|
|
42
|
+
resultScanLogging?: "all" | "activity" | "off";
|
|
43
|
+
/** Test seams. */
|
|
44
|
+
intervalMs?: number;
|
|
45
|
+
healthyScanIntervalMs?: number;
|
|
46
|
+
log?: (message: string) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ResultWatcher {
|
|
50
|
+
/** Force a scan now. */
|
|
51
|
+
scan(): Promise<void>;
|
|
52
|
+
stop(): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Create the watcher. Scans the session's pending result markers; for each
|
|
57
|
+
* found payload, delivers the completion and removes the marker. The full
|
|
58
|
+
* result payload + indexes are retained for later reads (get_helper_result,
|
|
59
|
+
* children.list) until retention cleanup.
|
|
60
|
+
*/
|
|
61
|
+
export function createResultWatcher(deps: ResultWatcherDeps): ResultWatcher {
|
|
62
|
+
const log = deps.log ?? ((message: string) => console.error(message));
|
|
63
|
+
const scanLogging = deps.resultScanLogging ?? "all";
|
|
64
|
+
let stopped = false;
|
|
65
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
66
|
+
let healthyTimer: ReturnType<typeof setInterval> | undefined;
|
|
67
|
+
let scanning = false;
|
|
68
|
+
|
|
69
|
+
async function scanOnce(): Promise<void> {
|
|
70
|
+
if (stopped || scanning) return;
|
|
71
|
+
scanning = true;
|
|
72
|
+
try {
|
|
73
|
+
const startedAt = Date.now();
|
|
74
|
+
const pendingFiles = listPendingFiles(deps.resultsDir, deps.sessionId);
|
|
75
|
+
const scanDuration = Date.now() - startedAt;
|
|
76
|
+
const slow = scanDuration > SLOW_RESULT_SCAN_MS;
|
|
77
|
+
const foundWork = pendingFiles.length > 0;
|
|
78
|
+
|
|
79
|
+
if (slow && scanLogging !== "off" && (scanLogging === "all" || foundWork)) {
|
|
80
|
+
log(
|
|
81
|
+
`Subagent result scan inspected ${pendingFiles.length} pending marker(s) in ${scanDuration}ms`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const pendingFile of pendingFiles) {
|
|
86
|
+
const runId = path.basename(pendingFile).replace(/\.json$/, "");
|
|
87
|
+
const payload = readAsyncResultFile(deps.resultsDir, runId);
|
|
88
|
+
if (!payload) {
|
|
89
|
+
// Unreadable/corrupt marker — drop it so it never loops.
|
|
90
|
+
try {
|
|
91
|
+
fs.unlinkSync(pendingFile);
|
|
92
|
+
} catch { /* gone */ }
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
deps.notifier({
|
|
96
|
+
runId: payload.runId,
|
|
97
|
+
...(payload.agent ? { agent: payload.agent } : {}),
|
|
98
|
+
...(payload.output !== undefined ? { output: payload.output } : {}),
|
|
99
|
+
...(payload.error ? { error: payload.error } : {}),
|
|
100
|
+
success: payload.success ?? payload.state === "completed",
|
|
101
|
+
state: payload.state,
|
|
102
|
+
timedOut: payload.timedOut,
|
|
103
|
+
durationMs: payload.durationMs,
|
|
104
|
+
});
|
|
105
|
+
removePendingResult(deps.resultsDir, deps.sessionId, runId);
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
scanning = false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Pending markers for this session live under result-pending/<session>/.
|
|
113
|
+
function listPendingFiles(resultsDir: string, sessionId: string): string[] {
|
|
114
|
+
const dir = path.join(resultsDir, "result-pending", sessionId);
|
|
115
|
+
let entries: fs.Dirent[];
|
|
116
|
+
try {
|
|
117
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
118
|
+
} catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
return entries
|
|
122
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
123
|
+
.map((entry) => path.join(dir, entry.name))
|
|
124
|
+
.sort();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function start(): void {
|
|
128
|
+
const interval = deps.intervalMs ?? POLL_INTERVAL_MS;
|
|
129
|
+
timer = setInterval(() => {
|
|
130
|
+
void scanOnce();
|
|
131
|
+
}, interval);
|
|
132
|
+
timer.unref?.();
|
|
133
|
+
const healthyInterval = deps.healthyScanIntervalMs ?? HEALTHY_SCAN_INTERVAL_MS;
|
|
134
|
+
healthyTimer = setInterval(() => {
|
|
135
|
+
void scanOnce();
|
|
136
|
+
}, healthyInterval);
|
|
137
|
+
healthyTimer.unref?.();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
start();
|
|
141
|
+
void scanOnce();
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
scan: scanOnce,
|
|
145
|
+
stop(): void {
|
|
146
|
+
stopped = true;
|
|
147
|
+
if (timer) clearInterval(timer);
|
|
148
|
+
if (healthyTimer) clearInterval(healthyTimer);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Persist a nonBlocking wake subscription: a pending marker for a run that
|
|
154
|
+
* has not completed yet. The watcher delivers when the result lands. */
|
|
155
|
+
export function writePendingSubscription(resultsDir: string, sessionId: string, runId: string): void {
|
|
156
|
+
const dir = path.join(resultsDir, "result-pending", sessionId);
|
|
157
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
158
|
+
const file = path.join(dir, `${runId}.json`);
|
|
159
|
+
if (!fs.existsSync(file)) {
|
|
160
|
+
fs.writeFileSync(file, JSON.stringify({ runId, sessionId, subscribedAt: Date.now() }) + "\n", { mode: 0o600 });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Retention cleanup (ported essence of async-retention.ts): remove terminal
|
|
166
|
+
* async run dirs + result payloads older than maxAgeMs. Only terminal runs
|
|
167
|
+
* are eligible; a missing status.json is treated as terminal after the age
|
|
168
|
+
* window (orphan cleanup).
|
|
169
|
+
*/
|
|
170
|
+
export function cleanupAsyncRetention(
|
|
171
|
+
asyncDir: string,
|
|
172
|
+
resultsDir: string,
|
|
173
|
+
now = Date.now(),
|
|
174
|
+
maxAgeMs = 24 * 60 * 60 * 1000,
|
|
175
|
+
): { runsRemoved: number; resultsRemoved: number } {
|
|
176
|
+
let runsRemoved = 0;
|
|
177
|
+
let resultsRemoved = 0;
|
|
178
|
+
|
|
179
|
+
let entries: fs.Dirent[];
|
|
180
|
+
try {
|
|
181
|
+
entries = fs.readdirSync(asyncDir, { withFileTypes: true });
|
|
182
|
+
} catch {
|
|
183
|
+
return { runsRemoved, resultsRemoved };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const entry of entries) {
|
|
187
|
+
if (!entry.isDirectory()) continue;
|
|
188
|
+
const runDir = path.join(asyncDir, entry.name);
|
|
189
|
+
try {
|
|
190
|
+
const stat = fs.statSync(runDir);
|
|
191
|
+
if (now - stat.mtimeMs <= maxAgeMs) continue;
|
|
192
|
+
const statusFile = path.join(runDir, "status.json");
|
|
193
|
+
let terminal = true;
|
|
194
|
+
if (fs.existsSync(statusFile)) {
|
|
195
|
+
try {
|
|
196
|
+
const status = JSON.parse(fs.readFileSync(statusFile, "utf8")) as { status?: string };
|
|
197
|
+
terminal = !["queued", "running"].includes(String(status.status));
|
|
198
|
+
} catch {
|
|
199
|
+
terminal = true; // unreadable = treat as terminal after age window
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!terminal) continue;
|
|
203
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
204
|
+
runsRemoved++;
|
|
205
|
+
// Remove the associated result payload + indexes if present.
|
|
206
|
+
try {
|
|
207
|
+
const payload = readAsyncResultFile(resultsDir, entry.name);
|
|
208
|
+
if (payload) {
|
|
209
|
+
removeResultIndex(resultsDir, payload.sessionId, entry.name);
|
|
210
|
+
resultsRemoved++;
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// best effort
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
// raced away
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { runsRemoved, resultsRemoved };
|
|
221
|
+
}
|