pi-subagents 0.31.0 → 0.32.0
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 +48 -0
- package/README.md +170 -8
- package/package.json +1 -1
- package/skills/pi-subagents/SKILL.md +6 -1
- package/src/agents/agent-management.ts +6 -1
- package/src/agents/agents.ts +55 -11
- package/src/extension/companion-suggestions.ts +359 -0
- package/src/extension/config.ts +27 -4
- package/src/extension/doctor.ts +2 -0
- package/src/extension/fanout-child.ts +1 -0
- package/src/extension/index.ts +69 -4
- package/src/extension/schemas.ts +2 -2
- package/src/intercom/intercom-bridge.ts +25 -1
- package/src/profiles/profiles.ts +637 -0
- package/src/runs/background/async-execution.ts +138 -33
- package/src/runs/background/async-job-tracker.ts +77 -1
- package/src/runs/background/async-resume.ts +11 -13
- package/src/runs/background/async-status.ts +41 -9
- package/src/runs/background/chain-root-attachment.ts +34 -4
- package/src/runs/background/control-channel.ts +227 -0
- package/src/runs/background/run-status.ts +1 -0
- package/src/runs/background/stale-run-reconciler.ts +28 -1
- package/src/runs/background/subagent-runner.ts +459 -113
- package/src/runs/foreground/chain-execution.ts +29 -7
- package/src/runs/foreground/execution.ts +24 -6
- package/src/runs/foreground/subagent-executor.ts +240 -44
- package/src/runs/shared/acceptance.ts +45 -22
- package/src/runs/shared/dynamic-fanout.ts +1 -1
- package/src/runs/shared/model-fallback.ts +4 -0
- package/src/runs/shared/nested-events.ts +58 -0
- package/src/runs/shared/parallel-utils.ts +49 -1
- package/src/runs/shared/pi-args.ts +5 -3
- package/src/runs/shared/pi-spawn.ts +52 -20
- package/src/runs/shared/single-output.ts +2 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +3 -2
- package/src/runs/shared/worktree.ts +28 -5
- package/src/shared/artifacts.ts +15 -1
- package/src/shared/fork-context.ts +133 -22
- package/src/shared/types.ts +82 -3
- package/src/shared/utils.ts +99 -14
- package/src/slash/slash-commands.ts +726 -40
- package/src/tui/render.ts +16 -4
|
@@ -710,13 +710,15 @@ export function aggregateAcceptanceReport(input: {
|
|
|
710
710
|
};
|
|
711
711
|
}
|
|
712
712
|
|
|
713
|
-
function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string): Promise<AcceptanceVerifyResult> {
|
|
713
|
+
function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string, options: { signal?: AbortSignal; abortMessage?: string } = {}): Promise<AcceptanceVerifyResult> {
|
|
714
714
|
return new Promise((resolve) => {
|
|
715
715
|
const startedAt = Date.now();
|
|
716
716
|
const cwd = command.cwd ? path.resolve(defaultCwd, command.cwd) : defaultCwd;
|
|
717
717
|
let stdout = "";
|
|
718
718
|
let stderr = "";
|
|
719
719
|
let timedOut = false;
|
|
720
|
+
let settled = false;
|
|
721
|
+
let hardKill: NodeJS.Timeout | undefined;
|
|
720
722
|
const child = spawn(command.command, {
|
|
721
723
|
cwd,
|
|
722
724
|
env: { ...process.env, ...(command.env ?? {}) },
|
|
@@ -724,12 +726,39 @@ function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string):
|
|
|
724
726
|
stdio: ["ignore", "pipe", "pipe"],
|
|
725
727
|
windowsHide: true,
|
|
726
728
|
});
|
|
727
|
-
const
|
|
729
|
+
const finish = (result: Omit<AcceptanceVerifyResult, "id" | "command" | "cwd" | "durationMs">) => {
|
|
730
|
+
if (settled) return;
|
|
731
|
+
settled = true;
|
|
732
|
+
clearTimeout(timeout);
|
|
733
|
+
if (hardKill) clearTimeout(hardKill);
|
|
734
|
+
options.signal?.removeEventListener("abort", abortVerification);
|
|
735
|
+
resolve({
|
|
736
|
+
id: command.id,
|
|
737
|
+
command: command.command,
|
|
738
|
+
cwd,
|
|
739
|
+
durationMs: Date.now() - startedAt,
|
|
740
|
+
...result,
|
|
741
|
+
});
|
|
742
|
+
};
|
|
743
|
+
const abortVerification = () => {
|
|
744
|
+
if (settled || timedOut) return;
|
|
728
745
|
timedOut = true;
|
|
729
746
|
child.kill("SIGTERM");
|
|
730
|
-
setTimeout(() =>
|
|
731
|
-
|
|
747
|
+
hardKill = setTimeout(() => {
|
|
748
|
+
child.kill("SIGKILL");
|
|
749
|
+
finish({
|
|
750
|
+
exitCode: null,
|
|
751
|
+
status: "timed-out",
|
|
752
|
+
stdout: trimOutput(stdout),
|
|
753
|
+
stderr: trimOutput(stderr || options.abortMessage || "Acceptance verification timed out."),
|
|
754
|
+
});
|
|
755
|
+
}, 1000);
|
|
756
|
+
hardKill.unref?.();
|
|
757
|
+
};
|
|
758
|
+
const timeout = setTimeout(abortVerification, command.timeoutMs ?? 120_000);
|
|
732
759
|
timeout.unref?.();
|
|
760
|
+
if (options.signal?.aborted) abortVerification();
|
|
761
|
+
else options.signal?.addEventListener("abort", abortVerification, { once: true });
|
|
733
762
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
734
763
|
stdout += chunk.toString();
|
|
735
764
|
});
|
|
@@ -737,30 +766,19 @@ function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string):
|
|
|
737
766
|
stderr += chunk.toString();
|
|
738
767
|
});
|
|
739
768
|
child.on("close", (exitCode) => {
|
|
740
|
-
clearTimeout(timeout);
|
|
741
|
-
const durationMs = Date.now() - startedAt;
|
|
742
769
|
const passed = exitCode === 0 && !timedOut;
|
|
743
|
-
|
|
744
|
-
id: command.id,
|
|
745
|
-
command: command.command,
|
|
746
|
-
cwd,
|
|
770
|
+
finish({
|
|
747
771
|
exitCode,
|
|
748
772
|
status: timedOut ? "timed-out" : passed ? "passed" : command.allowFailure ? "allowed-failure" : "failed",
|
|
749
773
|
stdout: trimOutput(stdout),
|
|
750
|
-
stderr: trimOutput(stderr),
|
|
751
|
-
durationMs,
|
|
774
|
+
stderr: trimOutput(stderr || (timedOut ? options.abortMessage ?? "" : "")),
|
|
752
775
|
});
|
|
753
776
|
});
|
|
754
777
|
child.on("error", (error) => {
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
cwd,
|
|
760
|
-
exitCode: 1,
|
|
761
|
-
status: command.allowFailure ? "allowed-failure" : "failed",
|
|
762
|
-
stderr: error instanceof Error ? error.message : String(error),
|
|
763
|
-
durationMs: Date.now() - startedAt,
|
|
778
|
+
finish({
|
|
779
|
+
exitCode: timedOut ? null : 1,
|
|
780
|
+
status: timedOut ? "timed-out" : command.allowFailure ? "allowed-failure" : "failed",
|
|
781
|
+
stderr: timedOut ? trimOutput(stderr || options.abortMessage || "Acceptance verification timed out.") : error instanceof Error ? error.message : String(error),
|
|
764
782
|
});
|
|
765
783
|
});
|
|
766
784
|
});
|
|
@@ -772,6 +790,8 @@ export async function evaluateAcceptance(input: {
|
|
|
772
790
|
cwd: string;
|
|
773
791
|
report?: AcceptanceReport;
|
|
774
792
|
reviewResult?: AcceptanceReviewResult;
|
|
793
|
+
signal?: AbortSignal;
|
|
794
|
+
abortMessage?: string;
|
|
775
795
|
}): Promise<AcceptanceLedger> {
|
|
776
796
|
const acceptance = input.acceptance;
|
|
777
797
|
const ledger: AcceptanceLedger = {
|
|
@@ -815,7 +835,10 @@ export async function evaluateAcceptance(input: {
|
|
|
815
835
|
return ledger;
|
|
816
836
|
}
|
|
817
837
|
ledger.verifyRuns = [];
|
|
818
|
-
for (const command of acceptance.verify)
|
|
838
|
+
for (const command of acceptance.verify) {
|
|
839
|
+
ledger.verifyRuns.push(await runVerifyCommand(command, input.cwd, { signal: input.signal, abortMessage: input.abortMessage }));
|
|
840
|
+
if (input.signal?.aborted) break;
|
|
841
|
+
}
|
|
819
842
|
if (ledger.verifyRuns.some((run) => run.status === "failed" || run.status === "timed-out")) {
|
|
820
843
|
ledger.status = "rejected";
|
|
821
844
|
return ledger;
|
|
@@ -42,7 +42,7 @@ const ITEM_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
|
42
42
|
const ITEM_REF_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)(?:\.([^{}]+))?\}/g;
|
|
43
43
|
const RESERVED_TEMPLATE_NAMES = new Set(["task", "previous", "chain_dir", "outputs"]);
|
|
44
44
|
const DYNAMIC_STEP_KEYS = new Set(["expand", "parallel", "collect", "concurrency", "failFast", "phase", "label", "acceptance"]);
|
|
45
|
-
const RUNNER_DYNAMIC_STEP_KEYS = new Set([...DYNAMIC_STEP_KEYS, "effectiveAcceptance"]);
|
|
45
|
+
const RUNNER_DYNAMIC_STEP_KEYS = new Set([...DYNAMIC_STEP_KEYS, "effectiveAcceptance", "sessionFiles", "thinkingOverrides"]);
|
|
46
46
|
const DYNAMIC_EXPAND_KEYS = new Set(["from", "item", "key", "maxItems", "onEmpty"]);
|
|
47
47
|
const DYNAMIC_EXPAND_FROM_KEYS = new Set(["output", "path"]);
|
|
48
48
|
const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "acceptance"]);
|
|
@@ -126,6 +126,10 @@ const RETRYABLE_MODEL_FAILURE_PATTERNS = [
|
|
|
126
126
|
/\b502\b/,
|
|
127
127
|
/\b503\b/,
|
|
128
128
|
/\b504\b/,
|
|
129
|
+
/cold.?start/i,
|
|
130
|
+
/empty response/i,
|
|
131
|
+
/no output/i,
|
|
132
|
+
/model.*(?:load|fail|error)/i,
|
|
129
133
|
];
|
|
130
134
|
|
|
131
135
|
export function isRetryableModelFailure(error: string | undefined): boolean {
|
|
@@ -184,6 +184,17 @@ function sanitizeTokenUsage(value: unknown): NestedRunSummary["totalTokens"] | u
|
|
|
184
184
|
: undefined;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
function sanitizeCost(value: unknown): NestedRunSummary["totalCost"] | undefined {
|
|
188
|
+
if (!value || typeof value !== "object") return undefined;
|
|
189
|
+
const raw = value as Record<string, unknown>;
|
|
190
|
+
const inputTokens = clampNumber(raw.inputTokens);
|
|
191
|
+
const outputTokens = clampNumber(raw.outputTokens);
|
|
192
|
+
const costUsd = clampNumber(raw.costUsd);
|
|
193
|
+
return inputTokens !== undefined && outputTokens !== undefined && costUsd !== undefined
|
|
194
|
+
? { inputTokens, outputTokens, costUsd }
|
|
195
|
+
: undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
187
198
|
function sanitizeState(value: unknown, fallback: NestedRunState): NestedRunState {
|
|
188
199
|
return value === "queued" || value === "running" || value === "complete" || value === "failed" || value === "paused"
|
|
189
200
|
? value
|
|
@@ -212,6 +223,7 @@ function sanitizeStep(input: unknown, depth: number): NestedStepSummary | undefi
|
|
|
212
223
|
...(clampNumber(raw.startedAt) !== undefined ? { startedAt: clampNumber(raw.startedAt) } : {}),
|
|
213
224
|
...(clampNumber(raw.endedAt) !== undefined ? { endedAt: clampNumber(raw.endedAt) } : {}),
|
|
214
225
|
...(stringValue(raw.error, 1024) ? { error: stringValue(raw.error, 1024) } : {}),
|
|
226
|
+
...(raw.timedOut === true ? { timedOut: true } : {}),
|
|
215
227
|
...(depth < MAX_DEPTH && Array.isArray(raw.children) ? { children: raw.children.map((child) => sanitizeSummary(child, depth + 1)).filter((child): child is NestedRunSummary => Boolean(child)).slice(0, MAX_CHILDREN) } : {}),
|
|
216
228
|
};
|
|
217
229
|
}
|
|
@@ -225,6 +237,7 @@ export function sanitizeSummary(input: unknown, depth = 0): NestedRunSummary | u
|
|
|
225
237
|
? raw.steps.map((step) => sanitizeStep(step, depth + 1)).filter((step): step is NestedStepSummary => Boolean(step)).slice(0, MAX_STEPS)
|
|
226
238
|
: undefined;
|
|
227
239
|
const totalTokens = sanitizeTokenUsage(raw.totalTokens);
|
|
240
|
+
const totalCost = sanitizeCost(raw.totalCost);
|
|
228
241
|
return {
|
|
229
242
|
id: raw.id,
|
|
230
243
|
parentRunId: raw.parentRunId,
|
|
@@ -256,9 +269,13 @@ export function sanitizeSummary(input: unknown, depth = 0): NestedRunSummary | u
|
|
|
256
269
|
...(clampNumber(raw.turnCount) !== undefined ? { turnCount: clampNumber(raw.turnCount) } : {}),
|
|
257
270
|
...(clampNumber(raw.toolCount) !== undefined ? { toolCount: clampNumber(raw.toolCount) } : {}),
|
|
258
271
|
...(totalTokens ? { totalTokens } : {}),
|
|
272
|
+
...(totalCost ? { totalCost } : {}),
|
|
259
273
|
...(clampNumber(raw.startedAt) !== undefined ? { startedAt: clampNumber(raw.startedAt) } : {}),
|
|
260
274
|
...(clampNumber(raw.endedAt) !== undefined ? { endedAt: clampNumber(raw.endedAt) } : {}),
|
|
261
275
|
...(clampNumber(raw.lastUpdate) !== undefined ? { lastUpdate: clampNumber(raw.lastUpdate) } : {}),
|
|
276
|
+
...(clampNumber(raw.timeoutMs) !== undefined ? { timeoutMs: clampNumber(raw.timeoutMs) } : {}),
|
|
277
|
+
...(clampNumber(raw.deadlineAt) !== undefined ? { deadlineAt: clampNumber(raw.deadlineAt) } : {}),
|
|
278
|
+
...(raw.timedOut === true ? { timedOut: true } : {}),
|
|
262
279
|
...(stringValue(raw.error, 1024) ? { error: stringValue(raw.error, 1024) } : {}),
|
|
263
280
|
...(steps && steps.length > 0 ? { steps } : {}),
|
|
264
281
|
...(depth < MAX_DEPTH && Array.isArray(raw.children) ? { children: raw.children.map((child) => sanitizeSummary(child, depth + 1)).filter((child): child is NestedRunSummary => Boolean(child)).slice(0, MAX_CHILDREN) } : {}),
|
|
@@ -394,6 +411,42 @@ export function findNestedRouteForRootId(rootRunId: string): NestedRoute | undef
|
|
|
394
411
|
return undefined;
|
|
395
412
|
}
|
|
396
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Scan the nested-events directory once and index every route by its root run
|
|
416
|
+
* id. Use this when resolving routes for many runs (e.g. listAsyncRuns) so the
|
|
417
|
+
* cost is O(routes) total instead of O(runs * routes) from calling
|
|
418
|
+
* findNestedRouteForRootId per run.
|
|
419
|
+
*/
|
|
420
|
+
export function buildNestedRouteIndex(): Map<string, NestedRoute> {
|
|
421
|
+
let entries: string[];
|
|
422
|
+
try {
|
|
423
|
+
entries = fs.readdirSync(NESTED_EVENTS_DIR);
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Map();
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
const index = new Map<string, NestedRoute>();
|
|
429
|
+
for (const entry of entries) {
|
|
430
|
+
const routeRoot = path.join(NESTED_EVENTS_DIR, entry);
|
|
431
|
+
try {
|
|
432
|
+
const metadata = JSON.parse(fs.readFileSync(path.join(routeRoot, ROUTE_FILE), "utf-8")) as { rootRunId?: unknown; capabilityToken?: unknown };
|
|
433
|
+
if (typeof metadata.rootRunId !== "string" || typeof metadata.capabilityToken !== "string") continue;
|
|
434
|
+
if (index.has(metadata.rootRunId)) continue;
|
|
435
|
+
const route: NestedRoute = {
|
|
436
|
+
rootRunId: metadata.rootRunId,
|
|
437
|
+
eventSink: path.join(routeRoot, "events"),
|
|
438
|
+
controlInbox: path.join(routeRoot, "controls"),
|
|
439
|
+
capabilityToken: metadata.capabilityToken,
|
|
440
|
+
};
|
|
441
|
+
validateRouteShape(route);
|
|
442
|
+
index.set(metadata.rootRunId, route);
|
|
443
|
+
} catch {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return index;
|
|
448
|
+
}
|
|
449
|
+
|
|
397
450
|
export function projectNestedRegistryForRoot(rootRunId: string): NestedRegistry | undefined {
|
|
398
451
|
const route = findNestedRouteForRootId(rootRunId);
|
|
399
452
|
return route ? projectNestedEvents(route) : undefined;
|
|
@@ -778,6 +831,10 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
|
|
|
778
831
|
...(status.turnCount !== undefined ? { turnCount: status.turnCount } : {}),
|
|
779
832
|
...(status.toolCount !== undefined ? { toolCount: status.toolCount } : {}),
|
|
780
833
|
...(status.totalTokens ? { totalTokens: status.totalTokens } : {}),
|
|
834
|
+
...(status.timeoutMs !== undefined ? { timeoutMs: status.timeoutMs } : {}),
|
|
835
|
+
...(status.deadlineAt !== undefined ? { deadlineAt: status.deadlineAt } : {}),
|
|
836
|
+
...(status.timedOut !== undefined ? { timedOut: status.timedOut } : {}),
|
|
837
|
+
...(status.error ? { error: status.error } : {}),
|
|
781
838
|
...(status.startedAt !== undefined ? { startedAt: status.startedAt } : { startedAt: fallback.ts }),
|
|
782
839
|
...(status.endedAt !== undefined ? { endedAt: status.endedAt } : {}),
|
|
783
840
|
lastUpdate: status.lastUpdate ?? fallback.ts,
|
|
@@ -796,6 +853,7 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
|
|
|
796
853
|
...(step.startedAt !== undefined ? { startedAt: step.startedAt } : {}),
|
|
797
854
|
...(step.endedAt !== undefined ? { endedAt: step.endedAt } : {}),
|
|
798
855
|
...(step.error ? { error: step.error } : {}),
|
|
856
|
+
...(step.timedOut !== undefined ? { timedOut: step.timedOut } : {}),
|
|
799
857
|
})).slice(0, MAX_STEPS) } : {}),
|
|
800
858
|
};
|
|
801
859
|
}
|
|
@@ -55,6 +55,8 @@ export interface DynamicRunnerGroup {
|
|
|
55
55
|
failFast?: boolean;
|
|
56
56
|
phase?: string;
|
|
57
57
|
label?: string;
|
|
58
|
+
sessionFiles?: (string | undefined)[];
|
|
59
|
+
thinkingOverrides?: (string | undefined)[];
|
|
58
60
|
effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -82,10 +84,47 @@ export function flattenSteps(steps: RunnerStep[]): RunnerSubagentStep[] {
|
|
|
82
84
|
return flat;
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
export const DEFAULT_GLOBAL_CONCURRENCY_LIMIT = 20;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A promise-based semaphore for limiting concurrent access across multiple
|
|
91
|
+
* mapConcurrent calls within a single run. Enforces a global cap on the total
|
|
92
|
+
* number of subagent tasks executing simultaneously, regardless of each step's
|
|
93
|
+
* per-step concurrency limit.
|
|
94
|
+
*/
|
|
95
|
+
export class Semaphore {
|
|
96
|
+
private available: number;
|
|
97
|
+
private readonly queue: Array<() => void> = [];
|
|
98
|
+
|
|
99
|
+
constructor(limit: number) {
|
|
100
|
+
this.available = Math.max(1, Math.floor(limit) || 1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
acquire(): Promise<void> {
|
|
104
|
+
if (this.available > 0) {
|
|
105
|
+
this.available--;
|
|
106
|
+
return Promise.resolve();
|
|
107
|
+
}
|
|
108
|
+
return new Promise<void>((resolve) => {
|
|
109
|
+
this.queue.push(resolve);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
release(): void {
|
|
114
|
+
const next = this.queue.shift();
|
|
115
|
+
if (next) {
|
|
116
|
+
next();
|
|
117
|
+
} else {
|
|
118
|
+
this.available++;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
85
123
|
export async function mapConcurrent<T, R>(
|
|
86
124
|
items: T[],
|
|
87
125
|
limit: number,
|
|
88
126
|
fn: (item: T, i: number) => Promise<R>,
|
|
127
|
+
globalSemaphore?: Semaphore,
|
|
89
128
|
): Promise<R[]> {
|
|
90
129
|
const safeLimit = Math.max(1, Math.floor(limit) || 1);
|
|
91
130
|
const results: R[] = new Array(items.length);
|
|
@@ -94,7 +133,16 @@ export async function mapConcurrent<T, R>(
|
|
|
94
133
|
async function worker(_workerIndex: number): Promise<void> {
|
|
95
134
|
while (next < items.length) {
|
|
96
135
|
const i = next++;
|
|
97
|
-
|
|
136
|
+
if (globalSemaphore) {
|
|
137
|
+
await globalSemaphore.acquire();
|
|
138
|
+
try {
|
|
139
|
+
results[i] = await fn(items[i], i);
|
|
140
|
+
} finally {
|
|
141
|
+
globalSemaphore.release();
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
results[i] = await fn(items[i], i);
|
|
145
|
+
}
|
|
98
146
|
}
|
|
99
147
|
}
|
|
100
148
|
|
|
@@ -73,10 +73,12 @@ interface BuildPiArgsResult {
|
|
|
73
73
|
tempDir?: string;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
export function applyThinkingSuffix(model: string | undefined, thinking: string | undefined): string | undefined {
|
|
77
|
-
if (!model || !thinking
|
|
76
|
+
export function applyThinkingSuffix(model: string | undefined, thinking: string | undefined, replaceExisting = false): string | undefined {
|
|
77
|
+
if (!model || !thinking) return model;
|
|
78
78
|
const colonIdx = model.lastIndexOf(":");
|
|
79
|
-
if (colonIdx !== -1 && THINKING_LEVELS.includes(model.substring(colonIdx + 1)))
|
|
79
|
+
if (colonIdx !== -1 && THINKING_LEVELS.includes(model.substring(colonIdx + 1))) {
|
|
80
|
+
return replaceExisting ? `${model.slice(0, colonIdx)}:${thinking}` : model;
|
|
81
|
+
}
|
|
80
82
|
return `${model}:${thinking}`;
|
|
81
83
|
}
|
|
82
84
|
|
|
@@ -3,13 +3,18 @@ import * as path from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
5
5
|
export const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
6
|
+
export const PI_SUBAGENT_PI_BINARY_ENV = "PI_SUBAGENT_PI_BINARY";
|
|
6
7
|
|
|
7
|
-
export function findPiPackageRootFromEntry(
|
|
8
|
+
export function findPiPackageRootFromEntry(
|
|
9
|
+
entryPoint: string,
|
|
10
|
+
): string | undefined {
|
|
8
11
|
let dir = path.dirname(entryPoint);
|
|
9
12
|
while (dir !== path.dirname(dir)) {
|
|
10
13
|
const packageJsonPath = path.join(dir, "package.json");
|
|
11
14
|
if (fs.existsSync(packageJsonPath)) {
|
|
12
|
-
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as {
|
|
15
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as {
|
|
16
|
+
name?: unknown;
|
|
17
|
+
};
|
|
13
18
|
if (pkg.name === PI_CODING_AGENT_PACKAGE) return dir;
|
|
14
19
|
}
|
|
15
20
|
dir = path.dirname(dir);
|
|
@@ -18,13 +23,17 @@ export function findPiPackageRootFromEntry(entryPoint: string): string | undefin
|
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
export function resolveInstalledPiPackageRoot(): string | undefined {
|
|
21
|
-
return findPiPackageRootFromEntry(
|
|
26
|
+
return findPiPackageRootFromEntry(
|
|
27
|
+
fileURLToPath(import.meta.resolve(PI_CODING_AGENT_PACKAGE)),
|
|
28
|
+
);
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
export function resolvePiPackageRoot(): string | undefined {
|
|
25
32
|
try {
|
|
26
33
|
const entry = process.argv[1];
|
|
27
|
-
return entry
|
|
34
|
+
return entry
|
|
35
|
+
? findPiPackageRootFromEntry(fs.realpathSync(entry))
|
|
36
|
+
: undefined;
|
|
28
37
|
} catch {
|
|
29
38
|
// process.argv[1] probing is best-effort; callers can fall back to PATH/package resolution.
|
|
30
39
|
return undefined;
|
|
@@ -40,6 +49,7 @@ export interface PiSpawnDeps {
|
|
|
40
49
|
resolvePackageJson?: () => string;
|
|
41
50
|
resolvePackageEntry?: () => string;
|
|
42
51
|
piPackageRoot?: string;
|
|
52
|
+
env?: NodeJS.ProcessEnv;
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
interface PiSpawnCommand {
|
|
@@ -47,7 +57,10 @@ interface PiSpawnCommand {
|
|
|
47
57
|
args: string[];
|
|
48
58
|
}
|
|
49
59
|
|
|
50
|
-
function isRunnableNodeScript(
|
|
60
|
+
function isRunnableNodeScript(
|
|
61
|
+
filePath: string,
|
|
62
|
+
existsSync: (filePath: string) => boolean,
|
|
63
|
+
): boolean {
|
|
51
64
|
if (!existsSync(filePath)) return false;
|
|
52
65
|
return /\.(?:mjs|cjs|js)$/i.test(filePath);
|
|
53
66
|
}
|
|
@@ -56,9 +69,13 @@ function normalizePath(filePath: string): string {
|
|
|
56
69
|
return path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
|
57
70
|
}
|
|
58
71
|
|
|
59
|
-
export function resolveWindowsPiCliScript(
|
|
72
|
+
export function resolveWindowsPiCliScript(
|
|
73
|
+
deps: PiSpawnDeps = {},
|
|
74
|
+
): string | undefined {
|
|
60
75
|
const existsSync = deps.existsSync ?? fs.existsSync;
|
|
61
|
-
const readFileSync =
|
|
76
|
+
const readFileSync =
|
|
77
|
+
deps.readFileSync ??
|
|
78
|
+
((filePath, encoding) => fs.readFileSync(filePath, encoding));
|
|
62
79
|
const argv1 = deps.argv1 ?? process.argv[1];
|
|
63
80
|
|
|
64
81
|
if (argv1) {
|
|
@@ -69,23 +86,29 @@ export function resolveWindowsPiCliScript(deps: PiSpawnDeps = {}): string | unde
|
|
|
69
86
|
}
|
|
70
87
|
|
|
71
88
|
try {
|
|
72
|
-
const resolvePackageJson =
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
89
|
+
const resolvePackageJson =
|
|
90
|
+
deps.resolvePackageJson ??
|
|
91
|
+
(() => {
|
|
92
|
+
const root = deps.piPackageRoot ?? resolvePiPackageRoot();
|
|
93
|
+
if (root) return path.join(root, "package.json");
|
|
94
|
+
const packageRoot = deps.resolvePackageEntry
|
|
95
|
+
? findPiPackageRootFromEntry(deps.resolvePackageEntry())
|
|
96
|
+
: resolveInstalledPiPackageRoot();
|
|
97
|
+
if (!packageRoot)
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Could not resolve ${PI_CODING_AGENT_PACKAGE} package root`,
|
|
100
|
+
);
|
|
101
|
+
return path.join(packageRoot, "package.json");
|
|
102
|
+
});
|
|
81
103
|
const packageJsonPath = resolvePackageJson();
|
|
82
104
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
|
|
83
105
|
bin?: string | Record<string, string>;
|
|
84
106
|
};
|
|
85
107
|
const binField = packageJson.bin;
|
|
86
|
-
const binPath =
|
|
87
|
-
|
|
88
|
-
|
|
108
|
+
const binPath =
|
|
109
|
+
typeof binField === "string"
|
|
110
|
+
? binField
|
|
111
|
+
: (binField?.pi ?? Object.values(binField ?? {})[0]);
|
|
89
112
|
if (!binPath) return undefined;
|
|
90
113
|
const candidate = path.resolve(path.dirname(packageJsonPath), binPath);
|
|
91
114
|
if (isRunnableNodeScript(candidate, existsSync)) {
|
|
@@ -99,7 +122,16 @@ export function resolveWindowsPiCliScript(deps: PiSpawnDeps = {}): string | unde
|
|
|
99
122
|
return undefined;
|
|
100
123
|
}
|
|
101
124
|
|
|
102
|
-
export function getPiSpawnCommand(
|
|
125
|
+
export function getPiSpawnCommand(
|
|
126
|
+
args: string[],
|
|
127
|
+
deps: PiSpawnDeps = {},
|
|
128
|
+
): PiSpawnCommand {
|
|
129
|
+
const env = deps.env ?? process.env;
|
|
130
|
+
const piBinary = env[PI_SUBAGENT_PI_BINARY_ENV]?.trim();
|
|
131
|
+
if (piBinary) {
|
|
132
|
+
return { command: piBinary, args };
|
|
133
|
+
}
|
|
134
|
+
|
|
103
135
|
const platform = deps.platform ?? process.platform;
|
|
104
136
|
if (platform === "win32") {
|
|
105
137
|
const piCliPath = resolveWindowsPiCliScript(deps);
|
|
@@ -22,9 +22,11 @@ export function resolveSingleOutputPath(
|
|
|
22
22
|
output: string | boolean | undefined,
|
|
23
23
|
runtimeCwd: string,
|
|
24
24
|
requestedCwd?: string,
|
|
25
|
+
relativeBaseDir?: string,
|
|
25
26
|
): string | undefined {
|
|
26
27
|
if (typeof output !== "string" || !output || output === "false" || output === "true") return undefined;
|
|
27
28
|
if (path.isAbsolute(output)) return output;
|
|
29
|
+
if (relativeBaseDir) return path.resolve(relativeBaseDir, output);
|
|
28
30
|
const baseCwd = requestedCwd
|
|
29
31
|
? (path.isAbsolute(requestedCwd) ? requestedCwd : path.resolve(runtimeCwd, requestedCwd))
|
|
30
32
|
: runtimeCwd;
|
|
@@ -20,7 +20,7 @@ export const CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS = [
|
|
|
20
20
|
"The parent session owns delegation, orchestration, review fanout, and follow-up worker launches.",
|
|
21
21
|
"Ignore prior parent-only orchestration instructions in inherited conversation history.",
|
|
22
22
|
"Do not propose or run subagents. Complete only your assigned role-specific task with the tools available to you.",
|
|
23
|
-
"If you need to edit files,
|
|
23
|
+
"If you need to edit files, use the available editing tools. Do not print tool-call syntax, patches, or pseudo-tool calls as text.",
|
|
24
24
|
].join("\n");
|
|
25
25
|
|
|
26
26
|
export const CHILD_FANOUT_BOUNDARY_INSTRUCTIONS = [
|
|
@@ -29,12 +29,13 @@ export const CHILD_FANOUT_BOUNDARY_INSTRUCTIONS = [
|
|
|
29
29
|
"You may use the `subagent` tool only for the fanout work explicitly requested in this task.",
|
|
30
30
|
"Do not broaden yourself into general parent orchestration. Do not launch follow-up workers unless the task explicitly asks for that.",
|
|
31
31
|
"The maxSubagentDepth cap still applies and may block further fanout.",
|
|
32
|
-
"If you need to edit files,
|
|
32
|
+
"If you need to edit files, use the available editing tools. Do not print tool-call syntax, patches, or pseudo-tool calls as text.",
|
|
33
33
|
].join("\n");
|
|
34
34
|
|
|
35
35
|
const PARENT_ONLY_CUSTOM_MESSAGE_TYPES = new Set([
|
|
36
36
|
"subagent-orchestration-instructions",
|
|
37
37
|
"subagent-slash-result",
|
|
38
|
+
"subagent-slash-text-result",
|
|
38
39
|
"subagent-notify",
|
|
39
40
|
"subagent_control_notice",
|
|
40
41
|
"subagent-control",
|
|
@@ -43,6 +43,7 @@ interface WorktreeSetupHookConfig {
|
|
|
43
43
|
interface CreateWorktreesOptions {
|
|
44
44
|
agents?: string[];
|
|
45
45
|
setupHook?: WorktreeSetupHookConfig;
|
|
46
|
+
baseDir?: string;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
interface ResolvedWorktreeSetupHook {
|
|
@@ -152,8 +153,26 @@ function buildWorktreeBranch(runId: string, index: number): string {
|
|
|
152
153
|
return `pi-parallel-${runId}-${index}`;
|
|
153
154
|
}
|
|
154
155
|
|
|
155
|
-
function
|
|
156
|
-
|
|
156
|
+
function resolveWorktreeBaseDir(configuredBaseDir: string | undefined, repoRoot: string): string {
|
|
157
|
+
const rawBaseDir = configuredBaseDir ?? process.env.PI_SUBAGENTS_WORKTREE_DIR;
|
|
158
|
+
if (rawBaseDir === undefined) return os.tmpdir();
|
|
159
|
+
|
|
160
|
+
const trimmed = rawBaseDir.trim();
|
|
161
|
+
if (!trimmed) throw new Error("worktree base directory cannot be empty");
|
|
162
|
+
|
|
163
|
+
const expanded = trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(2)) : trimmed;
|
|
164
|
+
const resolved = path.isAbsolute(expanded) ? expanded : path.resolve(repoRoot, expanded);
|
|
165
|
+
try {
|
|
166
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
169
|
+
throw new Error(`failed to create worktree base directory ${resolved}: ${message}`);
|
|
170
|
+
}
|
|
171
|
+
return resolved;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildWorktreePath(baseDir: string, runId: string, index: number): string {
|
|
175
|
+
return path.join(baseDir, `pi-worktree-${runId}-${index}`);
|
|
157
176
|
}
|
|
158
177
|
|
|
159
178
|
function resolveRepoCwdRelative(cwd: string): string {
|
|
@@ -168,9 +187,10 @@ function resolveRepoCwdRelative(cwd: string): string {
|
|
|
168
187
|
return normalizedPrefix === "." ? "" : normalizedPrefix;
|
|
169
188
|
}
|
|
170
189
|
|
|
171
|
-
export function resolveExpectedWorktreeAgentCwd(cwd: string, runId: string, index: number): string {
|
|
190
|
+
export function resolveExpectedWorktreeAgentCwd(cwd: string, runId: string, index: number, baseDir?: string): string {
|
|
172
191
|
const cwdRelative = resolveRepoCwdRelative(cwd);
|
|
173
|
-
const
|
|
192
|
+
const repoRoot = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
|
|
193
|
+
const worktreePath = buildWorktreePath(resolveWorktreeBaseDir(baseDir, repoRoot), runId, index);
|
|
174
194
|
return cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath;
|
|
175
195
|
}
|
|
176
196
|
|
|
@@ -320,9 +340,10 @@ function createSingleWorktree(
|
|
|
320
340
|
baseCommit: string,
|
|
321
341
|
setupHook: ResolvedWorktreeSetupHook | undefined,
|
|
322
342
|
agent: string | undefined,
|
|
343
|
+
baseDir: string,
|
|
323
344
|
): WorktreeInfo {
|
|
324
345
|
const branch = buildWorktreeBranch(runId, index);
|
|
325
|
-
const worktreePath = buildWorktreePath(runId, index);
|
|
346
|
+
const worktreePath = buildWorktreePath(baseDir, runId, index);
|
|
326
347
|
const add = runGit(toplevel, ["worktree", "add", worktreePath, "-b", branch, "HEAD"]);
|
|
327
348
|
if (add.status !== 0) {
|
|
328
349
|
const message = add.stderr.trim() || add.stdout.trim() || `failed to create worktree ${worktreePath}`;
|
|
@@ -492,6 +513,7 @@ function hasWorktreeChanges(diff: WorktreeDiff): boolean {
|
|
|
492
513
|
export function createWorktrees(cwd: string, runId: string, count: number, options?: CreateWorktreesOptions): WorktreeSetup {
|
|
493
514
|
const repo = resolveRepoState(cwd);
|
|
494
515
|
const setupHook = resolveWorktreeSetupHook(repo.toplevel, options?.setupHook);
|
|
516
|
+
const baseDir = resolveWorktreeBaseDir(options?.baseDir, repo.toplevel);
|
|
495
517
|
const worktrees: WorktreeInfo[] = [];
|
|
496
518
|
|
|
497
519
|
try {
|
|
@@ -504,6 +526,7 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
|
|
|
504
526
|
repo.baseCommit,
|
|
505
527
|
setupHook,
|
|
506
528
|
options?.agents?.[index],
|
|
529
|
+
baseDir,
|
|
507
530
|
));
|
|
508
531
|
}
|
|
509
532
|
} catch (error) {
|
package/src/shared/artifacts.ts
CHANGED
|
@@ -3,8 +3,22 @@ import * as path from "node:path";
|
|
|
3
3
|
import { TEMP_ARTIFACTS_DIR, type ArtifactPaths } from "./types.ts";
|
|
4
4
|
import { getAgentDir } from "./utils.ts";
|
|
5
5
|
const CLEANUP_MARKER_FILE = ".last-cleanup";
|
|
6
|
+
const PROJECT_ARTIFACT_ROOT = ".pi-subagents";
|
|
6
7
|
|
|
7
|
-
export function
|
|
8
|
+
export function getProjectSubagentsDir(cwd: string): string {
|
|
9
|
+
return path.join(cwd, PROJECT_ARTIFACT_ROOT);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getProjectArtifactsDir(cwd: string): string {
|
|
13
|
+
return path.join(getProjectSubagentsDir(cwd), "artifacts");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getProjectChainRunsDir(cwd: string): string {
|
|
17
|
+
return path.join(getProjectSubagentsDir(cwd), "chain-runs");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getArtifactsDir(sessionFile: string | null, projectCwd?: string): string {
|
|
21
|
+
if (projectCwd) return getProjectArtifactsDir(projectCwd);
|
|
8
22
|
if (sessionFile) {
|
|
9
23
|
const sessionDir = path.dirname(sessionFile);
|
|
10
24
|
return path.join(sessionDir, "subagent-artifacts");
|