@narumitw/pi-subagents 0.49.3 → 0.51.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.
Files changed (81) hide show
  1. package/README.md +313 -53
  2. package/package.json +10 -7
  3. package/src/adaptive-scheduler.ts +196 -0
  4. package/src/admission-benchmark.ts +95 -0
  5. package/src/admission-policy.ts +78 -0
  6. package/src/agent-projection.ts +53 -0
  7. package/src/agents.ts +58 -1
  8. package/src/auto-transport.ts +114 -0
  9. package/src/blocking-status.ts +63 -0
  10. package/src/capabilities.ts +145 -0
  11. package/src/capability-grant.ts +115 -0
  12. package/src/capability-router.ts +107 -0
  13. package/src/completion-delivery.ts +257 -0
  14. package/src/config-status.ts +221 -0
  15. package/src/config-ui.ts +215 -236
  16. package/src/consult-resources.ts +4 -27
  17. package/src/consult.ts +9 -1
  18. package/src/create-stateful-transport.ts +55 -0
  19. package/src/delegation-contract.ts +417 -0
  20. package/src/execution-plan.ts +322 -0
  21. package/src/execution-profiles.ts +95 -0
  22. package/src/execution-ui.ts +320 -0
  23. package/src/execution.ts +848 -158
  24. package/src/in-process-transport.ts +269 -25
  25. package/src/inspect-render.ts +101 -1
  26. package/src/inspect.ts +296 -3
  27. package/src/integration-controller.ts +98 -0
  28. package/src/limits.ts +3 -0
  29. package/src/orchestration-metrics.ts +78 -0
  30. package/src/outcome.ts +61 -0
  31. package/src/panel-child-group.ts +35 -0
  32. package/src/panel-contract.ts +343 -0
  33. package/src/panel-evidence.ts +59 -0
  34. package/src/panel-execution.ts +772 -0
  35. package/src/panel-failure.ts +56 -0
  36. package/src/panel-planning.ts +175 -0
  37. package/src/panel-prompts.ts +132 -0
  38. package/src/panel-reconciliation.ts +57 -0
  39. package/src/panel-render.ts +103 -0
  40. package/src/parallel-limit-ui.ts +112 -0
  41. package/src/params.ts +172 -3
  42. package/src/persistence.ts +182 -32
  43. package/src/prompt-resources.ts +38 -0
  44. package/src/registry-types.ts +175 -0
  45. package/src/registry.ts +466 -143
  46. package/src/render.ts +72 -6
  47. package/src/result-contract.ts +416 -0
  48. package/src/retained-semantic-state.ts +100 -0
  49. package/src/rpc-timeout-finalization.ts +207 -0
  50. package/src/rpc-transport-metadata.ts +65 -0
  51. package/src/rpc-transport.ts +990 -0
  52. package/src/rpc-turn-capture.ts +142 -0
  53. package/src/runner-result.ts +55 -0
  54. package/src/runner-usage.ts +48 -0
  55. package/src/runner.ts +325 -73
  56. package/src/semantic-snapshot.ts +214 -0
  57. package/src/settings.ts +254 -35
  58. package/src/spawn-idempotency.ts +61 -0
  59. package/src/stateful-config.ts +13 -0
  60. package/src/stateful-guidance.ts +1 -0
  61. package/src/stateful-lifecycle.ts +45 -2
  62. package/src/stateful-limit-ui.ts +246 -0
  63. package/src/stateful-limits.ts +96 -0
  64. package/src/stateful-prompt.ts +11 -2
  65. package/src/stateful-render.ts +48 -3
  66. package/src/stateful.ts +467 -357
  67. package/src/subagents.ts +114 -46
  68. package/src/subprocess-transport.ts +64 -5
  69. package/src/supervision.ts +103 -0
  70. package/src/timeout-checkpoint.ts +305 -0
  71. package/src/timeout-finalization.ts +75 -0
  72. package/src/transport-types.ts +68 -0
  73. package/src/transport-ui.ts +169 -0
  74. package/src/transport.ts +16 -4
  75. package/src/turn-budget.ts +109 -0
  76. package/src/verification-policy.ts +17 -0
  77. package/src/work-item-ledger.ts +682 -0
  78. package/src/work-item-persistence.ts +218 -0
  79. package/src/workflow-planning.ts +150 -0
  80. package/src/workflow-ui.ts +61 -0
  81. package/src/workspace.ts +69 -12
@@ -0,0 +1,218 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
5
+ import { redactPrivateText } from "./context.js";
6
+ import { WorkItemLedger, type WorkItemLedgerSnapshot } from "./work-item-ledger.js";
7
+
8
+ const WORKFLOW_STATE_DIRECTORY = "pi-subagents-workflows";
9
+ const DEFAULT_MAX_STORED_WORKFLOWS = 64;
10
+ const DEFAULT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
11
+ const MAX_WORKFLOW_STATE_BYTES = 1024 * 1024;
12
+
13
+ export interface SessionWorkflowPersistenceOptions {
14
+ stateDir?: string;
15
+ maxStoredWorkflows?: number;
16
+ retentionMs?: number;
17
+ }
18
+
19
+ export interface SessionWorkflowInspection {
20
+ workflows: WorkItemLedgerSnapshot[];
21
+ invalid: number;
22
+ omitted: number;
23
+ }
24
+
25
+ export class WorkItemPersistence {
26
+ constructor(
27
+ readonly filePath: string,
28
+ private readonly afterSave?: () => Promise<void>,
29
+ ) {}
30
+
31
+ async save(snapshot: WorkItemLedgerSnapshot): Promise<void> {
32
+ const filePath = path.resolve(this.filePath);
33
+ const sanitized = sanitizeWorkflowSnapshot(snapshot);
34
+ const content = `${JSON.stringify(sanitized)}\n`;
35
+ if (Buffer.byteLength(content, "utf8") > MAX_WORKFLOW_STATE_BYTES) {
36
+ throw new Error("WorkItem workflow state exceeds the persistence size limit");
37
+ }
38
+ await withFileMutationQueue(filePath, async () => {
39
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
40
+ const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
41
+ try {
42
+ await fs.promises.writeFile(temporary, content, { mode: 0o600 });
43
+ await fs.promises.rename(temporary, filePath);
44
+ } finally {
45
+ await fs.promises.rm(temporary, { force: true });
46
+ }
47
+ });
48
+ await this.afterSave?.();
49
+ }
50
+
51
+ load(): WorkItemLedger | undefined {
52
+ const filePath = path.resolve(this.filePath);
53
+ let source: string;
54
+ try {
55
+ const stat = fs.statSync(filePath);
56
+ if (stat.size > MAX_WORKFLOW_STATE_BYTES)
57
+ throw new Error("workflow state exceeds size limit");
58
+ source = fs.readFileSync(filePath, "utf8");
59
+ } catch (error) {
60
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
61
+ throw error;
62
+ }
63
+ try {
64
+ return WorkItemLedger.restore(JSON.parse(source) as WorkItemLedgerSnapshot);
65
+ } catch {
66
+ const quarantine = `${filePath}.invalid-${Date.now()}`;
67
+ try {
68
+ fs.renameSync(filePath, quarantine);
69
+ } catch {
70
+ // A concurrent owner may already have handled the invalid file.
71
+ }
72
+ return undefined;
73
+ }
74
+ }
75
+ }
76
+
77
+ export function createSessionWorkItemPersistence(
78
+ owner: string,
79
+ workflowId: string,
80
+ options: SessionWorkflowPersistenceOptions = {},
81
+ ): WorkItemPersistence {
82
+ const maxStoredWorkflows = options.maxStoredWorkflows ?? DEFAULT_MAX_STORED_WORKFLOWS;
83
+ const retentionMs = options.retentionMs ?? DEFAULT_RETENTION_MS;
84
+ if (!Number.isSafeInteger(maxStoredWorkflows) || maxStoredWorkflows < 1) {
85
+ throw new Error("maxStoredWorkflows must be a positive safe integer");
86
+ }
87
+ if (!Number.isFinite(retentionMs) || retentionMs <= 0) {
88
+ throw new Error("workflow retentionMs must be a positive finite number");
89
+ }
90
+ const stateDir = resolveStateDirectory(options.stateDir);
91
+ const prefix = sessionPrefix(owner);
92
+ const filePath = path.join(stateDir, `${prefix}-${stableId(workflowId)}.json`);
93
+ return new WorkItemPersistence(filePath, () =>
94
+ pruneSessionWorkflows(stateDir, prefix, maxStoredWorkflows, retentionMs),
95
+ );
96
+ }
97
+
98
+ export function inspectSessionWorkflows(
99
+ owner: string,
100
+ options: SessionWorkflowPersistenceOptions = {},
101
+ ): SessionWorkflowInspection {
102
+ const stateDir = resolveStateDirectory(options.stateDir);
103
+ const prefix = `${sessionPrefix(owner)}-`;
104
+ const limit = options.maxStoredWorkflows ?? DEFAULT_MAX_STORED_WORKFLOWS;
105
+ if (!Number.isSafeInteger(limit) || limit < 1) {
106
+ throw new Error("maxStoredWorkflows must be a positive safe integer");
107
+ }
108
+ let entries: fs.Dirent[];
109
+ try {
110
+ entries = fs.readdirSync(stateDir, { withFileTypes: true });
111
+ } catch (error) {
112
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
113
+ return { workflows: [], invalid: 0, omitted: 0 };
114
+ }
115
+ throw error;
116
+ }
117
+ const candidates = entries
118
+ .filter(
119
+ (entry) => entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith(".json"),
120
+ )
121
+ .map((entry) => {
122
+ const filePath = path.join(stateDir, entry.name);
123
+ return { filePath, modifiedAt: safeModifiedAt(filePath) };
124
+ })
125
+ .sort((left, right) => right.modifiedAt - left.modifiedAt);
126
+ const workflows: WorkItemLedgerSnapshot[] = [];
127
+ let invalid = 0;
128
+ for (const candidate of candidates.slice(0, limit)) {
129
+ try {
130
+ const stat = fs.statSync(candidate.filePath);
131
+ if (stat.size > MAX_WORKFLOW_STATE_BYTES)
132
+ throw new Error("workflow state exceeds size limit");
133
+ const source = fs.readFileSync(candidate.filePath, "utf8");
134
+ workflows.push(
135
+ WorkItemLedger.restore(JSON.parse(source) as WorkItemLedgerSnapshot).snapshot(),
136
+ );
137
+ } catch {
138
+ invalid++;
139
+ }
140
+ }
141
+ return { workflows, invalid, omitted: Math.max(0, candidates.length - limit) };
142
+ }
143
+
144
+ function safeModifiedAt(filePath: string): number {
145
+ try {
146
+ return fs.statSync(filePath).mtimeMs;
147
+ } catch {
148
+ return 0;
149
+ }
150
+ }
151
+
152
+ function resolveStateDirectory(stateDir: string | undefined): string {
153
+ return path.resolve(stateDir ?? path.join(getAgentDir(), WORKFLOW_STATE_DIRECTORY));
154
+ }
155
+
156
+ function sessionPrefix(owner: string): string {
157
+ return stableId(`session:${owner}`);
158
+ }
159
+
160
+ function stableId(value: string): string {
161
+ return createHash("sha256").update(value).digest("hex").slice(0, 24);
162
+ }
163
+
164
+ async function pruneSessionWorkflows(
165
+ stateDir: string,
166
+ prefix: string,
167
+ maxStoredWorkflows: number,
168
+ retentionMs: number,
169
+ ): Promise<void> {
170
+ await withFileMutationQueue(path.join(stateDir, `${prefix}.prune`), async () => {
171
+ const cutoff = Date.now() - retentionMs;
172
+ const entries = (await fs.promises.readdir(stateDir, { withFileTypes: true }))
173
+ .filter(
174
+ (entry) =>
175
+ entry.isFile() && entry.name.startsWith(`${prefix}-`) && entry.name.endsWith(".json"),
176
+ )
177
+ .map((entry) => path.join(stateDir, entry.name));
178
+ const records = await Promise.all(
179
+ entries.map(async (filePath) => ({
180
+ filePath,
181
+ modifiedAt: (await fs.promises.stat(filePath)).mtimeMs,
182
+ })),
183
+ );
184
+ records.sort((left, right) => right.modifiedAt - left.modifiedAt);
185
+ await Promise.all(
186
+ records
187
+ .filter((record, index) => index >= maxStoredWorkflows || record.modifiedAt < cutoff)
188
+ .map((record) => fs.promises.rm(record.filePath, { force: true })),
189
+ );
190
+ });
191
+ }
192
+
193
+ function sanitizeWorkflowSnapshot(snapshot: WorkItemLedgerSnapshot): WorkItemLedgerSnapshot {
194
+ const sanitized = structuredClone(snapshot);
195
+ for (const item of sanitized.items) {
196
+ item.objective = redact(item.objective);
197
+ item.selectedAgentName = item.selectedAgentName ? redact(item.selectedAgentName) : undefined;
198
+ item.requiredCapabilities = item.requiredCapabilities.map(redact);
199
+ item.requiredTools = item.requiredTools.map(redact);
200
+ item.readPaths = item.readPaths.map(redact);
201
+ item.writePaths = item.writePaths.map(redact);
202
+ item.ownershipKeys = item.ownershipKeys.map(redact);
203
+ item.acceptanceCriteria = item.acceptanceCriteria.map(redact);
204
+ item.invalidationReasons = item.invalidationReasons.map(redact);
205
+ item.outcomeReason = item.outcomeReason ? redact(item.outcomeReason) : undefined;
206
+ for (const artifact of [...item.artifacts, ...item.artifactHistory]) {
207
+ artifact.kind = redact(artifact.kind);
208
+ artifact.version = redact(artifact.version);
209
+ artifact.digest = artifact.digest ? redact(artifact.digest) : undefined;
210
+ }
211
+ }
212
+ WorkItemLedger.restore(sanitized);
213
+ return sanitized;
214
+ }
215
+
216
+ function redact(value: string): string {
217
+ return redactPrivateText(value).trim();
218
+ }
@@ -0,0 +1,150 @@
1
+ import type { AgentConfig } from "./agents.js";
2
+ import { routeByCapability } from "./capability-router.js";
3
+ import { normalizeDelegationContract } from "./delegation-contract.js";
4
+ import type { SubagentParams } from "./params.js";
5
+ import { type WorkItemDefinition, WorkItemLedger } from "./work-item-ledger.js";
6
+
7
+ export type WorkflowTask = NonNullable<SubagentParams["workflow"]>["tasks"][number];
8
+ export type ResolvedWorkflowTask = WorkflowTask & { agent: string };
9
+ type Aggregator = NonNullable<SubagentParams["aggregator"]>;
10
+
11
+ type WorkRequest = {
12
+ contract?: unknown;
13
+ inputArtifacts?: string[];
14
+ inputArtifactVersions?: Record<string, string>;
15
+ requiredCapabilities?: string[];
16
+ requiredTools?: string[];
17
+ agent?: string;
18
+ sideEffectPolicy?: "read-only" | "idempotent" | "mutating";
19
+ readPaths?: string[];
20
+ writePaths?: string[];
21
+ ownershipKeys?: string[];
22
+ acceptanceCriteria?: string[];
23
+ integrationOwner?: boolean;
24
+ verifierFor?: string;
25
+ dependencyPolicy?: "completed" | "settled";
26
+ };
27
+
28
+ export function resolveWorkflowTasks(
29
+ params: SubagentParams,
30
+ agents: readonly AgentConfig[],
31
+ ): ResolvedWorkflowTask[] {
32
+ return (params.workflow?.tasks ?? []).map((task) => {
33
+ const contract = normalizeDelegationContract(task.contract);
34
+ const route = routeByCapability(agents, {
35
+ agent: task.agent,
36
+ requiredCapabilities: [
37
+ ...(task.requiredCapabilities ?? []),
38
+ ...(contract?.requestedAuthority?.capabilities ?? []),
39
+ ],
40
+ requiredTools: [
41
+ ...(task.requiredTools ?? []),
42
+ ...(contract?.requestedAuthority?.tools ?? []),
43
+ ],
44
+ requiredVerificationRole: task.requiredVerificationRole,
45
+ requiredSideEffectClass: contract?.sideEffectPolicy === "read-only" ? "read-only" : undefined,
46
+ preferredCostHint: task.preferredCostHint,
47
+ preferredLatencyHint: task.preferredLatencyHint,
48
+ });
49
+ return { ...task, agent: route.agent.name };
50
+ });
51
+ }
52
+
53
+ export function createBlockingWorkLedger(
54
+ params: SubagentParams,
55
+ resolvedWorkflowTasks: ResolvedWorkflowTask[],
56
+ aggregator: Aggregator | undefined,
57
+ ): WorkItemLedger | undefined {
58
+ if (params.agent && params.task) {
59
+ return WorkItemLedger.create({
60
+ workflowId: "blocking-single",
61
+ items: [definition("task-1", params.task, [], params)],
62
+ });
63
+ }
64
+ if (params.chain?.length) {
65
+ return WorkItemLedger.create({
66
+ workflowId: "blocking-chain",
67
+ items: params.chain.map((step, index) =>
68
+ definition(`step-${index + 1}`, step.task, index === 0 ? [] : [`step-${index}`], step),
69
+ ),
70
+ });
71
+ }
72
+ if (params.tasks?.length) {
73
+ const items = params.tasks.map((task, index) =>
74
+ definition(`task-${index + 1}`, task.task, [], task),
75
+ );
76
+ if (aggregator) {
77
+ items.push(
78
+ definition(
79
+ "aggregator",
80
+ aggregator.task,
81
+ params.tasks.map((_task, index) => `task-${index + 1}`),
82
+ { ...aggregator, dependencyPolicy: "settled" },
83
+ ),
84
+ );
85
+ }
86
+ return WorkItemLedger.create({ workflowId: "blocking-parallel", items });
87
+ }
88
+ if (params.workflow && resolvedWorkflowTasks.length > 0) {
89
+ const hasExplicitIntegrationOwner = resolvedWorkflowTasks.some(
90
+ (task) => task.integrationOwner === true,
91
+ );
92
+ return WorkItemLedger.create({
93
+ workflowId: params.workflow.id ?? "blocking-workflow",
94
+ items: resolvedWorkflowTasks.map((task, index) =>
95
+ definition(task.id, task.task, task.dependsOn ?? [], {
96
+ ...task,
97
+ integrationOwner:
98
+ task.integrationOwner ??
99
+ (!hasExplicitIntegrationOwner && index === resolvedWorkflowTasks.length - 1),
100
+ }),
101
+ ),
102
+ });
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ function definition(
108
+ id: string,
109
+ task: string,
110
+ dependencies: string[],
111
+ request: WorkRequest,
112
+ ): WorkItemDefinition {
113
+ const contract = normalizeDelegationContract(request.contract);
114
+ return {
115
+ id,
116
+ objective: contract?.objective ?? task,
117
+ dependencies,
118
+ inputArtifacts: [
119
+ ...(request.inputArtifacts ?? contract?.requiredInputs ?? []),
120
+ ...(contract?.dependencies
121
+ .filter((dependency) => dependency.artifactId)
122
+ .map((dependency) => dependency.artifactId as string) ?? []),
123
+ ],
124
+ inputArtifactVersions: {
125
+ ...Object.fromEntries(
126
+ (contract?.dependencies ?? [])
127
+ .filter((dependency) => dependency.artifactId && dependency.version)
128
+ .map((dependency) => [dependency.artifactId as string, dependency.version as string]),
129
+ ),
130
+ ...(request.inputArtifactVersions ?? {}),
131
+ },
132
+ requiredCapabilities: [
133
+ ...(request.requiredCapabilities ?? []),
134
+ ...(contract?.requestedAuthority?.capabilities ?? []),
135
+ ],
136
+ requiredTools: [
137
+ ...(request.requiredTools ?? []),
138
+ ...(contract?.requestedAuthority?.tools ?? []),
139
+ ],
140
+ selectedAgentName: request.agent,
141
+ sideEffectPolicy: contract?.sideEffectPolicy ?? request.sideEffectPolicy ?? "mutating",
142
+ readPaths: request.readPaths ?? contract?.requestedAuthority?.readPaths ?? [],
143
+ writePaths: request.writePaths ?? contract?.requestedAuthority?.writePaths ?? [],
144
+ ownershipKeys: request.ownershipKeys ?? [],
145
+ acceptanceCriteria: request.acceptanceCriteria ?? contract?.acceptanceCriteria ?? [],
146
+ integrationOwner: request.integrationOwner,
147
+ verifierFor: request.verifierFor,
148
+ dependencyPolicy: request.dependencyPolicy,
149
+ };
150
+ }
@@ -0,0 +1,61 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { DelegationWorkflow } from "./settings.js";
3
+
4
+ export async function showWorkflowPreview(
5
+ ctx: ExtensionCommandContext,
6
+ current: DelegationWorkflow,
7
+ next: DelegationWorkflow,
8
+ requiresReload: boolean,
9
+ signal: AbortSignal,
10
+ ): Promise<boolean> {
11
+ const changes = workflowEffects(current, next);
12
+ return ctx.ui.confirm(
13
+ requiresReload ? "Save delegation change and reload?" : "Save delegation change?",
14
+ [
15
+ `Current: ${workflowLabel(current)}`,
16
+ `New: ${workflowLabel(next)}`,
17
+ "",
18
+ "Effect:",
19
+ ...(changes.length > 0 ? changes : ["Keep the current registered tools"]).map(
20
+ (effect) => `- ${effect}`,
21
+ ),
22
+ `- ${requiresReload ? "Reload the extension to apply this tool surface" : "No reload is needed because the active tools already match"}`,
23
+ ].join("\n"),
24
+ { signal },
25
+ );
26
+ }
27
+
28
+ export function workflowLabel(value: DelegationWorkflow): string {
29
+ switch (value) {
30
+ case "all":
31
+ return "All delegation methods";
32
+ case "async-only":
33
+ return "Async only";
34
+ case "blocking-only":
35
+ return "Blocking only";
36
+ case "disabled":
37
+ return "Delegation disabled";
38
+ }
39
+ }
40
+
41
+ function workflowEffects(current: DelegationWorkflow, next: DelegationWorkflow): string[] {
42
+ const blockingEnabled = (value: DelegationWorkflow) =>
43
+ value === "all" || value === "blocking-only";
44
+ const asyncEnabled = (value: DelegationWorkflow) => value === "all" || value === "async-only";
45
+ const effects: string[] = [];
46
+ if (blockingEnabled(current) !== blockingEnabled(next)) {
47
+ effects.push(
48
+ blockingEnabled(next)
49
+ ? "Add blocking `subagent` and read-only `subagent_consult`"
50
+ : "Remove blocking `subagent` and read-only `subagent_consult`",
51
+ );
52
+ }
53
+ if (asyncEnabled(current) !== asyncEnabled(next)) {
54
+ effects.push(
55
+ asyncEnabled(next)
56
+ ? "Add reusable async lifecycle tools"
57
+ : "Remove reusable async lifecycle tools",
58
+ );
59
+ }
60
+ return effects;
61
+ }
package/src/workspace.ts CHANGED
@@ -14,23 +14,16 @@ export interface IsolatedWorkspace {
14
14
  repositoryRoot: string;
15
15
  }
16
16
 
17
+ export async function assertWorkspaceIsolationReady(cwd: string): Promise<void> {
18
+ await resolveWorkspaceBase(cwd);
19
+ }
20
+
17
21
  export class WorkspaceManager {
18
22
  private readonly owned = new Map<string, IsolatedWorkspace>();
19
23
 
20
24
  async create(ownerId: string, cwd: string): Promise<IsolatedWorkspace> {
21
25
  if (this.owned.has(ownerId)) throw new Error(`Workspace owner already exists: ${ownerId}`);
22
- const resolvedCwd = path.resolve(cwd);
23
- const repositoryRoot = (
24
- await execFileAsync("git", ["-C", resolvedCwd, "rev-parse", "--show-toplevel"])
25
- ).stdout.trim();
26
- const relativeCwd = path.relative(repositoryRoot, resolvedCwd);
27
- if (relativeCwd.startsWith("..") || path.isAbsolute(relativeCwd)) {
28
- throw new Error("Subagent cwd is outside the Git repository");
29
- }
30
- const status = (await execFileAsync("git", ["-C", repositoryRoot, "status", "--porcelain"]))
31
- .stdout;
32
- if (status.trim())
33
- throw new Error("Isolated subagent workspace requires a clean Git repository");
26
+ const { repositoryRoot, relativeCwd } = await resolveWorkspaceBase(cwd);
34
27
  const rootPath = await fs.promises.mkdtemp(path.join(os.tmpdir(), WORKTREE_PREFIX));
35
28
  let registered = false;
36
29
  try {
@@ -70,6 +63,40 @@ export class WorkspaceManager {
70
63
  }
71
64
  }
72
65
 
66
+ async cleanupPersisted(ownerId: string, cwd: string): Promise<void> {
67
+ const rootPath = findGeneratedWorktreeRoot(cwd);
68
+ if (!rootPath || !(await this.isOwned(rootPath, ownerId))) return;
69
+ let repositoryRoot: string | undefined;
70
+ try {
71
+ const commonDirectory = (
72
+ await execFileAsync("git", ["-C", rootPath, "rev-parse", "--git-common-dir"])
73
+ ).stdout.trim();
74
+ const resolvedCommonDirectory = await fs.promises.realpath(
75
+ path.resolve(rootPath, commonDirectory),
76
+ );
77
+ repositoryRoot = path.dirname(resolvedCommonDirectory);
78
+ } catch {
79
+ // The ownership marker still permits bounded filesystem cleanup below.
80
+ }
81
+ if (repositoryRoot) {
82
+ await execFileAsync("git", [
83
+ "-C",
84
+ repositoryRoot,
85
+ "worktree",
86
+ "remove",
87
+ "--force",
88
+ rootPath,
89
+ ]).catch(() => undefined);
90
+ }
91
+ await fs.promises.rm(rootPath, { recursive: true, force: true });
92
+ await fs.promises.rm(`${rootPath}.owner`, { force: true });
93
+ if (repositoryRoot) {
94
+ await execFileAsync("git", ["-C", repositoryRoot, "worktree", "prune"]).catch(
95
+ () => undefined,
96
+ );
97
+ }
98
+ }
99
+
73
100
  async cleanup(ownerId: string): Promise<void> {
74
101
  const workspace = this.owned.get(ownerId);
75
102
  if (!workspace) return;
@@ -115,3 +142,33 @@ export class WorkspaceManager {
115
142
  }
116
143
  }
117
144
  }
145
+
146
+ async function resolveWorkspaceBase(
147
+ cwd: string,
148
+ ): Promise<{ repositoryRoot: string; relativeCwd: string }> {
149
+ const resolvedCwd = await fs.promises.realpath(path.resolve(cwd));
150
+ const reportedRepositoryRoot = (
151
+ await execFileAsync("git", ["-C", resolvedCwd, "rev-parse", "--show-toplevel"])
152
+ ).stdout.trim();
153
+ const repositoryRoot = await fs.promises.realpath(reportedRepositoryRoot);
154
+ const relativeCwd = path.relative(repositoryRoot, resolvedCwd);
155
+ if (relativeCwd.startsWith("..") || path.isAbsolute(relativeCwd)) {
156
+ throw new Error("Subagent cwd is outside the Git repository");
157
+ }
158
+ const status = (await execFileAsync("git", ["-C", repositoryRoot, "status", "--porcelain"]))
159
+ .stdout;
160
+ if (status.trim()) {
161
+ throw new Error("Isolated subagent workspace requires a clean Git repository");
162
+ }
163
+ return { repositoryRoot, relativeCwd };
164
+ }
165
+
166
+ function findGeneratedWorktreeRoot(cwd: string): string | undefined {
167
+ let current = path.resolve(cwd);
168
+ while (true) {
169
+ if (path.basename(current).startsWith(WORKTREE_PREFIX)) return current;
170
+ const parent = path.dirname(current);
171
+ if (parent === current) return undefined;
172
+ current = parent;
173
+ }
174
+ }