pi-better-subagents 0.2.0 → 0.4.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/README.md +2 -2
- package/docs/failure-observations.md +11 -0
- package/failures.ts +188 -0
- package/finalization.ts +14 -0
- package/index.ts +130 -40
- package/list.mjs +4 -1
- package/package.json +7 -6
- package/permission-policy.ts +104 -0
- package/registry.ts +17 -3
- package/sandbox.ts +26 -5
- package/shared-callback-batcher.ts +42 -16
- package/shared-failure-observations.ts +205 -0
- package/shared-sandbox-core.ts +309 -14
- package/shared-task-files.ts +246 -0
- package/shared-task-sandbox.ts +189 -0
- package/task-guard.ts +47 -0
- package/task-policy.ts +97 -0
- package/task-runtime.mjs +39 -0
- package/tools.ts +9 -4
package/index.ts
CHANGED
|
@@ -37,11 +37,15 @@ import { parseRun, readRunTranscript, resetParseRunCursor, type Usage } from "./
|
|
|
37
37
|
import { finalizeRun as finalizeRunCore } from "./finalization.ts";
|
|
38
38
|
import { loadConfig, normalizeTools, resolveExtensionPath, SAFE_DEFAULT_TOOLS, SAFE_CLEAN_TOOLS, DEFAULT_MAX_CONCURRENT } from "./config.ts";
|
|
39
39
|
import { resolveExtensions, extensionArgs } from "./extensions.ts";
|
|
40
|
-
import {
|
|
40
|
+
import { prepareTaskRuntime } from "./task-policy.ts";
|
|
41
|
+
import { canonicalizePath } from "./shared-sandbox-core.ts";
|
|
42
|
+
import { TASK_BUILTINS } from "./shared-task-sandbox.ts";
|
|
43
|
+
import { observeSandboxPermissions, resolveSubagentPermissions } from "./permission-policy.ts";
|
|
41
44
|
import { resolveSubagentWorkspace } from "./git-workspace.ts";
|
|
42
|
-
import { homedir } from "node:os";
|
|
43
45
|
import { join } from "node:path";
|
|
44
46
|
import {
|
|
47
|
+
baseDir,
|
|
48
|
+
taskWorkspaceDir,
|
|
45
49
|
sessionsDir,
|
|
46
50
|
runDir,
|
|
47
51
|
logPathFor,
|
|
@@ -91,6 +95,8 @@ import {
|
|
|
91
95
|
} from "./capacity.mjs";
|
|
92
96
|
import { buildHealthCallbackDelivery } from "./completion.ts";
|
|
93
97
|
import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
|
|
98
|
+
import { collectRunFailures, failurePath, failureSummary, formatFailureSummary, markFailureAttentionDelivered, pendingFailureAttention, prependFailureSummary } from "./failures.ts";
|
|
99
|
+
import { failureAttentionHandled, observeFailures } from "./shared-failure-observations.ts";
|
|
94
100
|
import {
|
|
95
101
|
text,
|
|
96
102
|
subagentListTool,
|
|
@@ -334,11 +340,15 @@ function enqueueCompletionCallback(pi: ExtensionAPI, id: string): void {
|
|
|
334
340
|
|| meta.completionCallbackSentAt !== undefined
|
|
335
341
|
|| meta.completionCallbackSuppressedAt !== undefined) return;
|
|
336
342
|
const label = meta.name ? `${meta.name} (${id})` : id;
|
|
343
|
+
const observations = Object.values(collectRunFailures(id, meta.cwd, true).observations);
|
|
344
|
+
const unresolved = observations.filter((observation) => observation.status === "unresolved");
|
|
345
|
+
const observationStatus = unresolved.some((observation) => observation.category === "observation-incomplete")
|
|
346
|
+
? "observation incomplete" : unresolved.length ? "unresolved failure observations" : undefined;
|
|
337
347
|
getCallbackBatcher(pi).enqueue({
|
|
338
348
|
source: "subagent",
|
|
339
349
|
id,
|
|
340
350
|
label,
|
|
341
|
-
status: meta.status,
|
|
351
|
+
status: observationStatus ? `${meta.status}; ${observationStatus}` : meta.status,
|
|
342
352
|
detailTool: "subagent_result",
|
|
343
353
|
callback: true,
|
|
344
354
|
isDelivered: () => {
|
|
@@ -348,10 +358,18 @@ function enqueueCompletionCallback(pi: ExtensionAPI, id: string): void {
|
|
|
348
358
|
},
|
|
349
359
|
getSuppressionReason: () => {
|
|
350
360
|
const current = readMeta(id);
|
|
351
|
-
if (!current)
|
|
361
|
+
if (!current) throw new Error("Subagent metadata is unavailable; defer completion");
|
|
352
362
|
return callbackSuppressionReason(current);
|
|
353
363
|
},
|
|
354
|
-
onDelivered: (at) =>
|
|
364
|
+
onDelivered: (at) => {
|
|
365
|
+
const state = collectRunFailures(id, meta.cwd, true);
|
|
366
|
+
const due = pendingFailureAttention(state, at, { terminal: true });
|
|
367
|
+
if (due) {
|
|
368
|
+
markFailureAttentionDelivered(failurePath(id), due, at);
|
|
369
|
+
if (due.incidents.some((incident) => pendingFailureAttention(collectRunFailures(id, meta.cwd, true), at, { terminal: true })?.incidents.includes(incident))) return;
|
|
370
|
+
}
|
|
371
|
+
markCompletionCallbackSent(id, at);
|
|
372
|
+
},
|
|
355
373
|
onSuppressed: (reason, at) => markCompletionCallbackSuppressed(id, reason, at),
|
|
356
374
|
});
|
|
357
375
|
}
|
|
@@ -364,6 +382,27 @@ function recoverCompletionCallbacks(pi: ExtensionAPI): void {
|
|
|
364
382
|
}
|
|
365
383
|
}
|
|
366
384
|
|
|
385
|
+
function deliverFailureAttention(pi: ExtensionAPI | undefined, meta: RunMeta, now: number): void {
|
|
386
|
+
if (!pi || meta.callback === false || callbackSuppressionReason(meta)) return;
|
|
387
|
+
if ((meta.status === "orphaned" || meta.status === "lost") && !isHealthCallbackHandled(meta, meta.status)) return;
|
|
388
|
+
const state = collectRunFailures(meta.id, meta.cwd, meta.status !== "running" && meta.status !== "orphaned");
|
|
389
|
+
const pending = pendingFailureAttention(state, now);
|
|
390
|
+
if (!pending || (meta.status !== "running" && meta.status !== "orphaned" && meta.completionCallbackPendingAt !== undefined)) return;
|
|
391
|
+
const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
|
|
392
|
+
void getCallbackBatcher(pi).deliverUrgent({
|
|
393
|
+
source: "subagent", id: meta.id, label, status: `failure:${pending.key}`,
|
|
394
|
+
customType: "subagent-failure",
|
|
395
|
+
content: `${formatFailureSummary(state)}\nInspect: subagent_output id=${JSON.stringify(meta.id)}`,
|
|
396
|
+
isDelivered: () => failureAttentionHandled(collectRunFailures(meta.id, meta.cwd), pending.incidents),
|
|
397
|
+
getSuppressionReason: () => {
|
|
398
|
+
const current = readMeta(meta.id);
|
|
399
|
+
if (!current) throw new Error("Subagent metadata is unavailable; defer failure notification");
|
|
400
|
+
return callbackSuppressionReason(current);
|
|
401
|
+
},
|
|
402
|
+
onDelivered: (at) => { markFailureAttentionDelivered(failurePath(meta.id), pending, at); },
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
367
406
|
function markHealthCallbackSuppressed(meta: RunMeta, status: "orphaned" | "lost", reason: string, now: number): void {
|
|
368
407
|
if (status === "orphaned") {
|
|
369
408
|
if (meta.orphanedCallbackSuppressedAt !== undefined) return;
|
|
@@ -532,6 +571,9 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
|
|
|
532
571
|
|
|
533
572
|
const callback = meta.callback !== false;
|
|
534
573
|
const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
|
|
574
|
+
const failureState = collectRunFailures(meta.id, meta.cwd, status === "lost");
|
|
575
|
+
const failureText = formatFailureSummary(failureState);
|
|
576
|
+
const attention = pendingFailureAttention(failureState, now, { terminal: status === "lost" });
|
|
535
577
|
const delivery = buildHealthCallbackDelivery({ id: meta.id, label, status, callback });
|
|
536
578
|
if (!delivery) {
|
|
537
579
|
// callback:false — model follow-up suppressed; mark handled so recovery
|
|
@@ -547,19 +589,24 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
|
|
|
547
589
|
label,
|
|
548
590
|
status,
|
|
549
591
|
customType: "subagent-health",
|
|
550
|
-
content: delivery.content,
|
|
592
|
+
content: prependFailureSummary(delivery.content, failureText),
|
|
551
593
|
isDelivered: () => {
|
|
552
594
|
const current = readMeta(meta.id);
|
|
553
|
-
|
|
595
|
+
if (!current) throw new Error("Subagent metadata is unavailable; defer health notification");
|
|
596
|
+
return isHealthCallbackHandled(current, status);
|
|
554
597
|
},
|
|
555
598
|
getSuppressionReason: () => {
|
|
556
599
|
const current = readMeta(meta.id);
|
|
557
|
-
if (!current)
|
|
600
|
+
if (!current) throw new Error("Subagent metadata is unavailable; defer health notification");
|
|
558
601
|
return callbackSuppressionReason(current);
|
|
559
602
|
},
|
|
560
603
|
onDelivered: (at) => {
|
|
561
604
|
const current = readMeta(meta.id);
|
|
562
605
|
if (!current || isHealthCallbackHandled(current, status)) return;
|
|
606
|
+
if (attention) {
|
|
607
|
+
markFailureAttentionDelivered(failurePath(meta.id), attention, at);
|
|
608
|
+
if (attention.incidents.some((incident) => pendingFailureAttention(collectRunFailures(meta.id, meta.cwd), at, { terminal: true })?.incidents.includes(incident))) return;
|
|
609
|
+
}
|
|
563
610
|
if (status === "orphaned") current.orphanedCallbackSentAt = at;
|
|
564
611
|
else current.lostCallbackSentAt = at;
|
|
565
612
|
writeMeta(current);
|
|
@@ -586,12 +633,25 @@ function reconcileHealth(): void {
|
|
|
586
633
|
if (!meta) continue;
|
|
587
634
|
if (meta.status !== "running" && meta.status !== "orphaned" && meta.status !== "lost") continue;
|
|
588
635
|
const now = Date.now();
|
|
636
|
+
if (meta.status === "orphaned" || meta.status === "lost") {
|
|
637
|
+
observeFailures(failurePath(meta.id), [{ id: `supervision:${meta.status}`, operation: `supervision:${meta.status}`, kind: "incomplete",
|
|
638
|
+
summary: meta.status === "lost" ? "Child supervision was lost; outcome is unknown" : "Child supervision interrupted; related work may still be alive" }], now);
|
|
639
|
+
}
|
|
640
|
+
deliverFailureAttention(pi, meta, now);
|
|
589
641
|
|
|
590
642
|
if (meta.status === "running" || meta.status === "orphaned") {
|
|
591
643
|
const result = reconcileRun(meta, realProcessProbe, now);
|
|
592
644
|
if (result.changed) {
|
|
593
645
|
Object.assign(meta, result.patch, { status: result.status });
|
|
594
646
|
writeMeta(meta);
|
|
647
|
+
if (result.status === "lost") {
|
|
648
|
+
observeFailures(failurePath(meta.id), [
|
|
649
|
+
{ id: "supervision:orphaned-resolved", operation: "supervision:orphaned", kind: "recovered", incidents: ["supervision:orphaned"] },
|
|
650
|
+
{ id: "supervision:lost", operation: "supervision:lost", kind: "incomplete", summary: "Child supervision was lost; outcome is unknown" },
|
|
651
|
+
], now);
|
|
652
|
+
} else if (result.status === "orphaned") {
|
|
653
|
+
observeFailures(failurePath(meta.id), [{ id: "supervision:orphaned", operation: "supervision:orphaned", kind: "incomplete", summary: "Child supervision interrupted; related work may still be alive" }], now);
|
|
654
|
+
}
|
|
595
655
|
if (result.transition) {
|
|
596
656
|
// Human-visible health (always) on fresh transitions.
|
|
597
657
|
if (!callbackSuppressionReason(meta)) {
|
|
@@ -612,8 +672,13 @@ function reconcileHealth(): void {
|
|
|
612
672
|
deliverHealthCallback(pi, meta, meta.status, now);
|
|
613
673
|
}
|
|
614
674
|
}
|
|
615
|
-
//
|
|
616
|
-
|
|
675
|
+
// Completed runs can still have failed completion handoffs to retry.
|
|
676
|
+
for (const summary of listMetasForParent(process.pid)) {
|
|
677
|
+
if (!pi || !ownedByThisParent(summary) || summary.status === "running" || summary.status === "orphaned" || summary.status === "lost") continue;
|
|
678
|
+
const meta = readMeta(summary.id);
|
|
679
|
+
if (meta && meta.completionCallbackPendingAt !== undefined && meta.completionCallbackSentAt === undefined && meta.completionCallbackSuppressedAt === undefined) enqueueCompletionCallback(pi!, meta.id);
|
|
680
|
+
}
|
|
681
|
+
if (!needsMonitoring(listMetasForParent(process.pid)) && !hasPendingFailureCallbacks()) stopHealthTicker();
|
|
617
682
|
}
|
|
618
683
|
|
|
619
684
|
/**
|
|
@@ -669,6 +734,12 @@ function reconcileAbandonedRuns(now: number = Date.now()): number {
|
|
|
669
734
|
return adopted;
|
|
670
735
|
}
|
|
671
736
|
|
|
737
|
+
function hasPendingFailureCallbacks(): boolean {
|
|
738
|
+
return listMetasForParent(process.pid).some((m) => ownedByThisParent(m) && m.callback !== false &&
|
|
739
|
+
m.completionCallbackPendingAt !== undefined && m.completionCallbackSentAt === undefined && m.completionCallbackSuppressedAt === undefined &&
|
|
740
|
+
!callbackSuppressionReason(m));
|
|
741
|
+
}
|
|
742
|
+
|
|
672
743
|
/** Start the reconciliation loop if it isn't already running. */
|
|
673
744
|
function ensureHealthTicker(): void {
|
|
674
745
|
if (healthTicker) return;
|
|
@@ -847,6 +918,8 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
|
|
|
847
918
|
if (row.model) bits.push(row.effort ? `${row.model} ${row.effort}` : row.model);
|
|
848
919
|
if (row.tool) bits.push(row.tool);
|
|
849
920
|
if (row.spend) bits.push(row.spend);
|
|
921
|
+
const failure = failureSummary(row.id, metaById.get(row.id)?.cwd ?? "", row.status !== "running" && row.status !== "orphaned");
|
|
922
|
+
const firstFailure = failure.split("\n")[0] || "";
|
|
850
923
|
return {
|
|
851
924
|
providerId: "subagents",
|
|
852
925
|
id: row.id,
|
|
@@ -859,7 +932,8 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
|
|
|
859
932
|
statusTone: statusTone(row.status),
|
|
860
933
|
kind: "subagent",
|
|
861
934
|
elapsed: row.elapsed,
|
|
862
|
-
primary: bits.join(" · ") || "subagent run",
|
|
935
|
+
primary: firstFailure || bits.join(" · ") || "subagent run",
|
|
936
|
+
secondary: firstFailure ? bits.join(" · ") : undefined,
|
|
863
937
|
facts: row.healthFacts,
|
|
864
938
|
sortStartedAt: metaById.get(row.id)?.startedAt ?? now,
|
|
865
939
|
expiresAt: (() => {
|
|
@@ -909,7 +983,9 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
|
|
|
909
983
|
if (!detail) return null;
|
|
910
984
|
void options;
|
|
911
985
|
const transcript = readRunTranscript(id);
|
|
986
|
+
const failure = failureSummary(id, readMeta(id)?.cwd ?? "", detail.status !== "running" && detail.status !== "orphaned");
|
|
912
987
|
const metadata = [
|
|
988
|
+
...(failure ? [{ label: "failure", value: failure.split("\n")[0]! }] : []),
|
|
913
989
|
{ label: "provider", value: "Subagents" },
|
|
914
990
|
{ label: "id", value: detail.id },
|
|
915
991
|
...(detail.role ? [{ label: "role", value: String(detail.role) }] : []),
|
|
@@ -926,11 +1002,11 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
|
|
|
926
1002
|
title: detail.name || detail.id,
|
|
927
1003
|
status: detail.status,
|
|
928
1004
|
statusTone: statusTone(detail.status),
|
|
929
|
-
subtitle: detail.currentTool ? `current tool ${detail.currentTool}` : undefined,
|
|
1005
|
+
subtitle: failure.split("\n")[0] || (detail.currentTool ? `current tool ${detail.currentTool}` : undefined),
|
|
930
1006
|
metadata,
|
|
931
|
-
evidence: { label: "transcript", text: detail.output || "(no transcript yet)" },
|
|
1007
|
+
evidence: { label: "transcript", text: prependFailureSummary(detail.output || "(no transcript yet)", failure) },
|
|
932
1008
|
transcript: transcript.entries,
|
|
933
|
-
transcriptDiagnostic: transcript.diagnostic,
|
|
1009
|
+
transcriptDiagnostic: failure ? prependFailureSummary(transcript.diagnostic ?? "", failure) : transcript.diagnostic,
|
|
934
1010
|
footerActions: [detail.status === "running" || detail.status === "orphaned" ? "x stop" : "x dismiss"],
|
|
935
1011
|
};
|
|
936
1012
|
}
|
|
@@ -1113,13 +1189,14 @@ function finalizeRun(pi: ExtensionAPI, ctx: ExtensionContext, id: string, code:
|
|
|
1113
1189
|
// Host-facing wrapper around first-party finalizer (finalization.ts).
|
|
1114
1190
|
// Coherent child-exit evidence may supersede provisional orphaned/lost
|
|
1115
1191
|
// reconciliation; finalization.ts enforces canExitFinalize + lifecycle authority.
|
|
1116
|
-
finalizeRunCore(id, code, {
|
|
1192
|
+
const result = finalizeRunCore(id, code, {
|
|
1117
1193
|
renderWidget,
|
|
1118
1194
|
notify: (message, level) => {
|
|
1119
1195
|
try { ctx.ui.notify(message, level); } catch { /* ignore */ }
|
|
1120
1196
|
},
|
|
1121
1197
|
sendMessage: () => enqueueCompletionCallback(pi, id),
|
|
1122
1198
|
});
|
|
1199
|
+
if (result.applied && hasPendingFailureCallbacks()) ensureHealthTicker();
|
|
1123
1200
|
}
|
|
1124
1201
|
|
|
1125
1202
|
/** String for one role, or an array when the caller assigns more than one. Arrays reach clarification instead of being rejected. */
|
|
@@ -1138,6 +1215,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1138
1215
|
healthPi = pi;
|
|
1139
1216
|
ensureSubagentProvider();
|
|
1140
1217
|
registerSubagentsGoalProvider(pi);
|
|
1218
|
+
observeSandboxPermissions(pi);
|
|
1141
1219
|
let acceptanceResultToolRef: { execute: (toolCallId: string, params: { id: string }) => Promise<unknown> } | undefined;
|
|
1142
1220
|
function publishAcceptanceHooks(tool?: NonNullable<typeof acceptanceResultToolRef>): void {
|
|
1143
1221
|
if (process.env.PI_CATALOG_ACCEPTANCE_PROBE !== "1") return;
|
|
@@ -1238,6 +1316,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1238
1316
|
sandboxDir?: string;
|
|
1239
1317
|
}> {
|
|
1240
1318
|
assertThinkingLevel(p.thinking);
|
|
1319
|
+
const permissionPlan = resolveSubagentPermissions(pi, p.sandbox);
|
|
1241
1320
|
const cfg = loadConfig();
|
|
1242
1321
|
let model: string | undefined;
|
|
1243
1322
|
let thinking: ThinkingLevel | undefined;
|
|
@@ -1268,17 +1347,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
1268
1347
|
// Sandbox is ON by default. sandbox_dir moves the confinement + working
|
|
1269
1348
|
// dir elsewhere. git_clone_workspace prepares a disposable clone with
|
|
1270
1349
|
// .git/ inside the writable root for Git-mutating sandboxed subagents.
|
|
1271
|
-
const
|
|
1272
|
-
const sandboxEnabled = p.sandbox !== false; // default on
|
|
1350
|
+
const sandboxEnabled = permissionPlan.sandboxEnabled;
|
|
1273
1351
|
|
|
1274
|
-
mkdirSync(sessionsDir(), { recursive: true });
|
|
1275
1352
|
const id = nextRunId();
|
|
1353
|
+
const childSessionDir = sandboxEnabled ? join(sessionsDir(), id) : sessionsDir();
|
|
1354
|
+
mkdirSync(childSessionDir, { recursive: true });
|
|
1276
1355
|
mkdirSync(runDir(id), { recursive: true });
|
|
1277
1356
|
|
|
1278
1357
|
const workspace = resolveSubagentWorkspace({
|
|
1279
1358
|
ctxCwd: ctx.cwd,
|
|
1280
1359
|
cwd: p.cwd,
|
|
1281
|
-
sandboxDir: p.sandbox_dir,
|
|
1360
|
+
sandboxDir: p.sandbox_dir ?? (sandboxEnabled && p.git_clone_workspace ? taskWorkspaceDir(id) : undefined),
|
|
1282
1361
|
gitCloneWorkspace: p.git_clone_workspace,
|
|
1283
1362
|
runId: id,
|
|
1284
1363
|
runDirPath: runDir(id),
|
|
@@ -1302,9 +1381,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
1302
1381
|
}
|
|
1303
1382
|
|
|
1304
1383
|
const resolution = resolveExtensions({
|
|
1305
|
-
tools: allow,
|
|
1384
|
+
tools: sandboxEnabled ? allow.split(",").filter((name) => (TASK_BUILTINS as readonly string[]).includes(name)).join(",") : allow,
|
|
1385
|
+
model, clean, allowNested: sandboxEnabled ? false : p.allow_nested, config: cfg,
|
|
1306
1386
|
});
|
|
1307
|
-
const { args:
|
|
1387
|
+
const { args: resolvedExtArgs, missing } = extensionArgs(resolution, resolveExtensionPath);
|
|
1388
|
+
const extArgs = resolvedExtArgs.map((value, index) => sandboxEnabled && resolvedExtArgs[index - 1] === "--extension" ? canonicalizePath(value) : value);
|
|
1389
|
+
if (sandboxEnabled && resolution.mode === "inherit") {
|
|
1390
|
+
throw new Error("Task confinement requires explicit extensions; inheritExtensions is unsupported while sandboxing is enabled.");
|
|
1391
|
+
}
|
|
1308
1392
|
if (missing.length) {
|
|
1309
1393
|
throw new Error(
|
|
1310
1394
|
`Subagent needs extension(s) that are not installed: ${missing.join(", ")}. ` +
|
|
@@ -1320,26 +1404,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
1320
1404
|
|
|
1321
1405
|
const args = [
|
|
1322
1406
|
"-p", "--mode", "json",
|
|
1323
|
-
"--session-dir",
|
|
1407
|
+
"--session-dir", childSessionDir,
|
|
1324
1408
|
"--session-id", id,
|
|
1325
1409
|
...extArgs,
|
|
1326
1410
|
...(model ? ["--model", model] : []),
|
|
1327
1411
|
...(thinking ? ["--thinking", thinking] : []),
|
|
1328
|
-
...(allow ? ["--tools", allow] : []),
|
|
1412
|
+
...(allow && !sandboxEnabled ? ["--tools", allow] : []),
|
|
1329
1413
|
...(excludes.size ? ["--exclude-tools", [...excludes].join(",")] : []),
|
|
1330
|
-
...(p.approve ? ["--approve"] : []),
|
|
1414
|
+
...(sandboxEnabled ? ["--no-builtin-tools", "--no-approve"] : p.approve ? ["--approve"] : []),
|
|
1331
1415
|
p.prompt,
|
|
1332
1416
|
];
|
|
1333
1417
|
|
|
1334
1418
|
const piBin = resolvePiBinary();
|
|
1335
|
-
const
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1419
|
+
const selectedTools = allow.split(",").filter((name) => name && !excludes.has(name));
|
|
1420
|
+
const unavailableTools = sandboxEnabled ? selectedTools.filter((name) => !(TASK_BUILTINS as readonly string[]).includes(name)) : [];
|
|
1421
|
+
if (sandboxEnabled && selectedTools.length && unavailableTools.length === selectedTools.length) {
|
|
1422
|
+
throw new Error(`No requested tool has a verified task sandbox adapter: ${unavailableTools.join(", ")}.`);
|
|
1423
|
+
}
|
|
1424
|
+
const taskRuntime = sandboxEnabled && requestedSandboxDir ? prepareTaskRuntime({
|
|
1425
|
+
root: requestedSandboxDir, controlDir: join(runDir(id), "control"), piBin,
|
|
1426
|
+
tools: selectedTools, permissions: permissionPlan.permissions,
|
|
1427
|
+
extensionPaths: extArgs.flatMap((arg, index) => arg === "--extension" && extArgs[index + 1] ? [extArgs[index + 1]!] : []),
|
|
1428
|
+
runtimeRoots: [baseDir()],
|
|
1429
|
+
}) : undefined;
|
|
1430
|
+
if (sandboxEnabled && !taskRuntime) throw new Error("Task sandbox has no workspace; refusing an unconfined child.");
|
|
1431
|
+
const cmd = taskRuntime ? { file: taskRuntime.file, fileArgs: [...taskRuntime.fileArgs, ...args] } : { file: piBin, fileArgs: args };
|
|
1432
|
+
const sandboxDir = taskRuntime ? requestedSandboxDir : undefined;
|
|
1343
1433
|
|
|
1344
1434
|
const spawned = spawnDetached({ file: cmd.file, fileArgs: cmd.fileArgs, cwd, logPath: logPathFor(id) });
|
|
1345
1435
|
// Record process identity (pgid, start-time token) so health
|
|
@@ -1357,7 +1447,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1357
1447
|
promptPreview: p.prompt.slice(0, 200),
|
|
1358
1448
|
startedAt: Date.now(), logPath: logPathFor(id), sessionId: id,
|
|
1359
1449
|
callbackOrigin,
|
|
1360
|
-
sandbox: sandboxDir, callback: p.callback !== false,
|
|
1450
|
+
sandbox: sandboxDir, taskRuntime: Boolean(taskRuntime), taskScratch: taskRuntime?.policy.scratch, callback: p.callback !== false,
|
|
1361
1451
|
...batchInfo,
|
|
1362
1452
|
// The launch record is JSON. Registry freezes that value; it does not
|
|
1363
1453
|
// require the resolver's nominal type to carry an index signature.
|
|
@@ -1379,11 +1469,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1379
1469
|
: resolution.specs.length
|
|
1380
1470
|
? `Runtime: isolated · extensions ${resolution.specs.join(", ")}\n`
|
|
1381
1471
|
: `Runtime: isolated · built-in tools only\n`;
|
|
1382
|
-
const warn = resolution.unmapped.length
|
|
1472
|
+
const warn = (unavailableTools.length ? `Task sandbox: unavailable adapters for ${unavailableTools.join(", ")}; these tools are disabled.\n` : "") + (resolution.unmapped.length
|
|
1383
1473
|
? `NOTE: no extension mapped for ${resolution.unmapped.join(", ")} — ` +
|
|
1384
1474
|
`${resolution.unmapped.length > 1 ? "these tools" : "this tool"} will NOT exist in the child. ` +
|
|
1385
1475
|
`Add a toolExtensions entry in config.json.\n`
|
|
1386
|
-
: "";
|
|
1476
|
+
: "");
|
|
1387
1477
|
return { id, meta, spawned, runtime, warn, sandboxDir };
|
|
1388
1478
|
}
|
|
1389
1479
|
|
|
@@ -1403,7 +1493,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1403
1493
|
...SUBAGENT_ORCHESTRATION_GUIDELINES,
|
|
1404
1494
|
"The tools param is both the tool allowlist AND what determines which extensions load in the child (e.g. tools='read,bash,web_fetch' loads only the web-tools package). Ask for the tools the task needs and nothing more; clean:true gives a built-ins-only child. Pick a model with the model param (e.g. 'xai/grok-4.5@high'); providerless model patterns are resolved by Pi, while provider/model is deterministic and loads mapped provider extensions.",
|
|
1405
1495
|
...CATALOG_GUIDELINES,
|
|
1406
|
-
"By default the subagent is sandboxed
|
|
1496
|
+
"By default the subagent is sandboxed. Human settings in /sandbox control file, credential-file, command, and network permissions; sandbox:false cannot override an enabled human profile. Without published settings, legacy write confinement applies. Set callback:false to finish quietly — then read the result on demand via subagent_result.",
|
|
1407
1497
|
"Use git_clone_workspace:true when the subagent will mutate Git in a sandbox. The parent prepares a disposable, self-contained clone with a real .git/ directory inside the sandbox root, so linked-worktree metadata outside the sandbox cannot stall the child.",
|
|
1408
1498
|
],
|
|
1409
1499
|
parameters: Type.Object({
|
|
@@ -1417,7 +1507,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1417
1507
|
tools: Type.Optional(Type.String({ description: "Tool allowlist: comma-separated names the child may use (e.g. 'read,bash,web_fetch'). This ALSO selects which extensions load — only packages backing a requested tool are loaded. Defaults to the configured safe set." })),
|
|
1418
1508
|
exclude_tools: Type.Optional(Type.String({ description: "Comma-separated tool denylist, applied on top of the allowlist." })),
|
|
1419
1509
|
clean: Type.Optional(Type.Boolean({ description: "Run a hermetic child with NO extensions at all (only built-ins: read, bash, edit, write). Default false — the extensions backing the requested tools load, so web_fetch and model auth (e.g. xai) work." })),
|
|
1420
|
-
sandbox: Type.Optional(Type.Boolean({ description: "
|
|
1510
|
+
sandbox: Type.Optional(Type.Boolean({ description: "Use the human Subagents profile from /sandbox. An enabled human profile cannot be bypassed with false. Without published settings, defaults to kernel write confinement; false opts out of that legacy default." })),
|
|
1421
1511
|
sandbox_dir: Type.Optional(Type.String({ description: "Confine writes to (and run the child in) this directory instead of the working dir. Created if missing." })),
|
|
1422
1512
|
callback: Type.Optional(Type.Boolean({ description: "Default TRUE: on completion, trigger a turn that calls subagent_result and presents the result. Set false to finish quietly — the result is then read on demand via subagent_result." })),
|
|
1423
1513
|
cwd: Type.Optional(Type.String({ description: "Working directory (default: current)." })),
|
|
@@ -1475,7 +1565,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1475
1565
|
(p.callback === false
|
|
1476
1566
|
? `Running in the background; the foreground is free. It will finish quietly — read the result with subagent_result id=${id}.\n`
|
|
1477
1567
|
: `Running in the background; the foreground is free. Its result will be posted back here when it finishes.\n`) +
|
|
1478
|
-
(sandboxDir ? `Sandboxed:
|
|
1568
|
+
(sandboxDir ? `Sandboxed: project root ${sandboxDir}; launch permissions apply.\n` : "") +
|
|
1479
1569
|
runtime + warn +
|
|
1480
1570
|
`Log: ${logPathFor(id)}`,
|
|
1481
1571
|
);
|
|
@@ -1518,7 +1608,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1518
1608
|
thinking: Type.Optional(Type.String({ description: "Reasoning effort applied to every job: off, minimal, low, medium, high, xhigh, or max." })),
|
|
1519
1609
|
tools: Type.Optional(Type.String({ description: "Tool allowlist applied to every job." })),
|
|
1520
1610
|
exclude_tools: Type.Optional(Type.String({ description: "Comma-separated tool denylist applied to every job." })),
|
|
1521
|
-
sandbox: Type.Optional(Type.Boolean({ description: "
|
|
1611
|
+
sandbox: Type.Optional(Type.Boolean({ description: "Use the human Subagents profile; false cannot override an enabled profile. Legacy default is write confinement." })),
|
|
1522
1612
|
sandbox_dir: Type.Optional(Type.String({ description: "Writable root for every job." })),
|
|
1523
1613
|
callback: Type.Optional(Type.Boolean({ description: "Default TRUE: post result back on completion." })),
|
|
1524
1614
|
clean: Type.Optional(Type.Boolean({ description: "Hermetic builtins-only child; no extensions load." })),
|
|
@@ -1810,7 +1900,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1810
1900
|
// Resume supervision reconciliation + durable health-callback recovery
|
|
1811
1901
|
// across /reload while current-parent work still needs the ticker
|
|
1812
1902
|
// (running/orphaned, or unmarked lost); it stops itself when idle.
|
|
1813
|
-
if (needsMonitoring(listMetasForParent(process.pid))) ensureHealthTicker();
|
|
1903
|
+
if (needsMonitoring(listMetasForParent(process.pid)) || hasPendingFailureCallbacks()) ensureHealthTicker();
|
|
1814
1904
|
});
|
|
1815
1905
|
|
|
1816
1906
|
pi.on("session_before_switch", () => {
|
package/list.mjs
CHANGED
|
@@ -74,10 +74,11 @@ export function formatSubagentListRow(meta, p) {
|
|
|
74
74
|
const name = meta.name ? `${meta.name} ` : "";
|
|
75
75
|
const stat = `${elapsed}${spend ? ` · ${spend}` : ""}`;
|
|
76
76
|
const health = formatListHealthSuffix(p.health);
|
|
77
|
+
const failure = p.failure ? `\n ${p.failure.replace(/\n/g, "\n ")}` : "";
|
|
77
78
|
const batch = meta.batchId
|
|
78
79
|
? ` [batch: ${meta.batchName ? `${meta.batchName} ` : ""}${meta.batchId}]`
|
|
79
80
|
: "";
|
|
80
|
-
return `• ${name}${meta.id} [${status}] ${meta.model ?? "?"} ${stat}${health}${batch}\n ${promptPreview(meta)}`;
|
|
81
|
+
return `• ${name}${meta.id} [${status}] ${meta.model ?? "?"} ${stat}${health}${batch}${failure}\n ${promptPreview(meta)}`;
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
export function buildSubagentList(p) {
|
|
@@ -87,6 +88,7 @@ export function buildSubagentList(p) {
|
|
|
87
88
|
const statusOf = p.statusOf ?? ((meta) => meta.status);
|
|
88
89
|
const usageById = p.usageById ?? (() => undefined);
|
|
89
90
|
const healthById = p.healthById ?? (() => undefined);
|
|
91
|
+
const failureById = p.failureById ?? (() => "");
|
|
90
92
|
|
|
91
93
|
const scoped = (p.metas ?? [])
|
|
92
94
|
.filter((meta) => options.all || meta.spawnPid === parentPid)
|
|
@@ -110,6 +112,7 @@ export function buildSubagentList(p) {
|
|
|
110
112
|
now,
|
|
111
113
|
usage: usageById(row.meta.id),
|
|
112
114
|
health: healthById(row.meta.id),
|
|
115
|
+
failure: failureById(row.meta.id),
|
|
113
116
|
})));
|
|
114
117
|
|
|
115
118
|
if (matching.length > displayed.length) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
|
-
"pretest": "node ../../scripts/sync-shared-log-utils.mjs && node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
34
|
-
"prepack": "node ../../scripts/sync-shared-log-utils.mjs && node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
35
|
-
"typecheck": "
|
|
33
|
+
"pretest": "node ../../scripts/sync-shared-log-utils.mjs && node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
|
|
34
|
+
"prepack": "node ../../scripts/sync-shared-log-utils.mjs && node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
|
|
35
|
+
"typecheck": "tsc -p tsconfig.task-runtime.json",
|
|
36
36
|
"test": "node --import tsx --test tests/*.test.mjs",
|
|
37
37
|
"test:cross-session": "node --import tsx --test --test-name-pattern \"callback session isolation\" tests/extension_health_lifecycle.test.mjs",
|
|
38
38
|
"pretest:macos-sandbox": "node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
@@ -51,11 +51,12 @@
|
|
|
51
51
|
"docs/agent-catalog-operations.md",
|
|
52
52
|
"docs/agent-model-resolution.md",
|
|
53
53
|
"docs/agent-catalog-lifecycle.md",
|
|
54
|
-
"docs/agent-catalog-acceptance.md"
|
|
54
|
+
"docs/agent-catalog-acceptance.md",
|
|
55
|
+
"docs/failure-observations.md"
|
|
55
56
|
],
|
|
56
57
|
"peerDependencies": {
|
|
57
58
|
"@earendil-works/pi-ai": "*",
|
|
58
|
-
"@earendil-works/pi-coding-agent": "
|
|
59
|
+
"@earendil-works/pi-coding-agent": ">=0.82.1",
|
|
59
60
|
"@earendil-works/pi-tui": "*"
|
|
60
61
|
},
|
|
61
62
|
"dependencies": {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** Optional sandbox-extension policy, mirrored per Pi event bus at launch time. */
|
|
2
|
+
export const SANDBOX_POLICY_CHANNEL = "pi-better-sandbox:policy";
|
|
3
|
+
export const SANDBOX_POLICY_REQUEST_CHANNEL = "pi-better-sandbox:policy-request";
|
|
4
|
+
|
|
5
|
+
type Access = "off" | "read" | "read-write";
|
|
6
|
+
export type PermissionProfile = Readonly<{
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
projectFiles: Access;
|
|
9
|
+
outsideProject: Access;
|
|
10
|
+
storedCredentials: Access;
|
|
11
|
+
commands: boolean;
|
|
12
|
+
network: boolean;
|
|
13
|
+
}>;
|
|
14
|
+
export type PermissionSnapshot = Readonly<{
|
|
15
|
+
permissions?: PermissionProfile;
|
|
16
|
+
subagentPermissions?: PermissionProfile;
|
|
17
|
+
}>;
|
|
18
|
+
|
|
19
|
+
type EventBus = {
|
|
20
|
+
on(channel: string, handler: (data: unknown) => void): unknown;
|
|
21
|
+
emit(channel: string, data: unknown): void;
|
|
22
|
+
};
|
|
23
|
+
const mirrors = new WeakMap<EventBus, { policy?: PermissionSnapshot; error?: Error }>();
|
|
24
|
+
|
|
25
|
+
function busOf(pi: unknown): EventBus | undefined {
|
|
26
|
+
const bus = (pi as { events?: unknown } | undefined)?.events;
|
|
27
|
+
if (!bus || typeof bus !== "object") return undefined;
|
|
28
|
+
const candidate = bus as Partial<EventBus>;
|
|
29
|
+
return typeof candidate.on === "function" && typeof candidate.emit === "function" ? candidate as EventBus : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function profile(value: unknown): PermissionProfile {
|
|
33
|
+
if (!value || typeof value !== "object") throw new Error("Invalid sandbox permission profile.");
|
|
34
|
+
const p = value as Record<string, unknown>;
|
|
35
|
+
const access = (v: unknown): v is Access => v === "off" || v === "read" || v === "read-write";
|
|
36
|
+
if (typeof p.enabled !== "boolean" || typeof p.commands !== "boolean" || typeof p.network !== "boolean" ||
|
|
37
|
+
!access(p.projectFiles) || !access(p.outsideProject) || !access(p.storedCredentials)) {
|
|
38
|
+
throw new Error("Invalid sandbox permission profile; update permissions in the sandbox UI.");
|
|
39
|
+
}
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
enabled: p.enabled, commands: p.commands, network: p.network,
|
|
42
|
+
projectFiles: p.projectFiles, outsideProject: p.outsideProject, storedCredentials: p.storedCredentials,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readPolicy(data: unknown): PermissionSnapshot | undefined {
|
|
47
|
+
if (!data || typeof data !== "object") return undefined;
|
|
48
|
+
const p = data as Record<string, unknown>;
|
|
49
|
+
if (!["inactive", "enabled", "disabled", "unavailable", "failed"].includes(String(p.state))) return undefined;
|
|
50
|
+
return Object.freeze({
|
|
51
|
+
...(p.permissions === undefined ? {} : { permissions: profile(p.permissions) }),
|
|
52
|
+
...(p.subagentPermissions === undefined ? {} : { subagentPermissions: profile(p.subagentPermissions) }),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Subscribe before launching; request a replay for either extension load order. */
|
|
57
|
+
export function observeSandboxPermissions(pi: unknown): void {
|
|
58
|
+
const bus = busOf(pi);
|
|
59
|
+
if (!bus || mirrors.has(bus)) return;
|
|
60
|
+
const mirror: { policy?: PermissionSnapshot; error?: Error } = {};
|
|
61
|
+
mirrors.set(bus, mirror);
|
|
62
|
+
bus.on(SANDBOX_POLICY_CHANNEL, (data) => {
|
|
63
|
+
try {
|
|
64
|
+
const next = readPolicy(data);
|
|
65
|
+
if (next) { mirror.policy = next; mirror.error = undefined; }
|
|
66
|
+
} catch (error) {
|
|
67
|
+
mirror.error = error as Error;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
bus.emit(SANDBOX_POLICY_REQUEST_CHANNEL, undefined);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function currentSandboxPermissions(pi: unknown): PermissionSnapshot | undefined {
|
|
74
|
+
observeSandboxPermissions(pi);
|
|
75
|
+
const bus = busOf(pi);
|
|
76
|
+
if (!bus) return undefined;
|
|
77
|
+
bus.emit(SANDBOX_POLICY_REQUEST_CHANNEL, undefined);
|
|
78
|
+
const mirror = mirrors.get(bus);
|
|
79
|
+
if (mirror?.error) throw mirror.error;
|
|
80
|
+
return mirror?.policy;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Resolve before allocating a run. Absent settings retain the legacy default-on policy. */
|
|
84
|
+
export function resolveSubagentPermissions(pi: unknown, requestedSandbox: boolean | undefined): {
|
|
85
|
+
sandboxEnabled: boolean;
|
|
86
|
+
enforced: boolean;
|
|
87
|
+
permissions?: Omit<PermissionProfile, "enabled">;
|
|
88
|
+
} {
|
|
89
|
+
const snapshot = currentSandboxPermissions(pi);
|
|
90
|
+
if (snapshot?.permissions?.enabled && !snapshot.permissions.commands) {
|
|
91
|
+
throw new Error("Main sandbox profile disables commands. Enable commands in the sandbox UI before launching a subagent.");
|
|
92
|
+
}
|
|
93
|
+
const child = snapshot?.subagentPermissions;
|
|
94
|
+
if (!child) return { sandboxEnabled: requestedSandbox !== false, enforced: false };
|
|
95
|
+
if (child.enabled && requestedSandbox === false) {
|
|
96
|
+
throw new Error("Subagent sandbox is enforced by the human-enabled profile; sandbox:false cannot bypass it. Change Subagents permissions in the sandbox UI.");
|
|
97
|
+
}
|
|
98
|
+
const sandboxEnabled = child.enabled || requestedSandbox === true;
|
|
99
|
+
if (!sandboxEnabled) return { sandboxEnabled: false, enforced: false };
|
|
100
|
+
// Starting Pi is runtime work. These capabilities constrain task tools;
|
|
101
|
+
// they do not disable provider transport or the fixed file-operation worker.
|
|
102
|
+
const { enabled: _enabled, ...permissions } = child;
|
|
103
|
+
return { sandboxEnabled: true, enforced: true, permissions };
|
|
104
|
+
}
|
package/registry.ts
CHANGED
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
|
|
15
15
|
import { execFileSync } from "node:child_process";
|
|
16
16
|
import { createHash, randomBytes } from "node:crypto";
|
|
17
|
-
import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { createRequire } from "node:module";
|
|
19
19
|
import { tmpdir } from "node:os";
|
|
20
|
-
import { dirname, join } from "node:path";
|
|
20
|
+
import { basename, dirname, join } from "node:path";
|
|
21
21
|
import { processExists } from "./spawn.ts";
|
|
22
22
|
import type { LifecycleClassification } from "./lifecycle.ts";
|
|
23
23
|
|
|
@@ -149,6 +149,9 @@ export interface RunMeta {
|
|
|
149
149
|
lostCallbackSuppressedReason?: string;
|
|
150
150
|
/** Writable dir the child is OS-sandboxed to, if any. */
|
|
151
151
|
sandbox?: string;
|
|
152
|
+
/** Pi is trusted; model task operations use an immutable kernel policy. */
|
|
153
|
+
taskRuntime?: boolean;
|
|
154
|
+
taskScratch?: string;
|
|
152
155
|
/** Whether completion posts the result back to the main session (default true). */
|
|
153
156
|
callback?: boolean;
|
|
154
157
|
/** Batch ID for runs launched via subagent_spawn_batch. */
|
|
@@ -176,11 +179,16 @@ export interface RunMeta {
|
|
|
176
179
|
|
|
177
180
|
/** Root runtime dir, deliberately OUTSIDE any repo. */
|
|
178
181
|
export function baseDir(): string {
|
|
179
|
-
return join(tmpdir(), "pi-better-subagents");
|
|
182
|
+
return join(realpathSync(tmpdir()), "pi-better-subagents");
|
|
180
183
|
}
|
|
181
184
|
export function sessionsDir(): string {
|
|
182
185
|
return join(baseDir(), "sessions");
|
|
183
186
|
}
|
|
187
|
+
export function taskWorkspaceDir(id: string): string {
|
|
188
|
+
if (!/^sa_[a-z0-9]+_[a-z0-9]+$/i.test(id)) throw new Error("Invalid task workspace run ID.");
|
|
189
|
+
return join(realpathSync(tmpdir()), "pi-better-subagent-workspaces", id);
|
|
190
|
+
}
|
|
191
|
+
|
|
184
192
|
export function runDir(id: string): string {
|
|
185
193
|
return join(baseDir(), "runs", id);
|
|
186
194
|
}
|
|
@@ -837,6 +845,12 @@ export function readMeta(id: string): RunMeta | undefined {
|
|
|
837
845
|
export function removeMetaArtifacts(meta: RunMeta): boolean {
|
|
838
846
|
try {
|
|
839
847
|
rmSync(runDir(meta.id), { recursive: true, force: true });
|
|
848
|
+
if (meta.taskRuntime && /^sa_[a-z0-9]+_[a-z0-9]+$/i.test(meta.id) && meta.cwd === taskWorkspaceDir(meta.id)) {
|
|
849
|
+
rmSync(taskWorkspaceDir(meta.id), { recursive: true, force: true });
|
|
850
|
+
}
|
|
851
|
+
if (meta.taskRuntime && meta.taskScratch && dirname(meta.taskScratch) === realpathSync(tmpdir()) && /^pi-task-scratch-[a-z0-9]{6}$/i.test(basename(meta.taskScratch))) {
|
|
852
|
+
rmSync(meta.taskScratch, { recursive: true, force: true });
|
|
853
|
+
}
|
|
840
854
|
metaCache.delete(meta.id);
|
|
841
855
|
removeIndexEntry(join(baseDir(), "by-parent", String(meta.spawnPid)), meta.id);
|
|
842
856
|
removeIndexEntry(join(baseDir(), "by-parent-active", String(meta.spawnPid)), meta.id);
|