@fitlab-ai/agent-infra 0.9.7 → 0.9.9

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 (195) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/bin/internal-cli.js +11 -1
  4. package/dist/lib/agent-clients/adapters/claude-code.js +3 -3
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +60 -22
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +8 -1
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +175 -11
  8. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-context.js +147 -0
  9. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -0
  10. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +86 -69
  11. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +2 -1
  12. package/dist/lib/decide.js +68 -14
  13. package/dist/lib/git/workflow.js +10 -1
  14. package/dist/lib/init.js +1 -1
  15. package/dist/lib/internal/codex-lifecycle.js +19 -5
  16. package/dist/lib/internal/codex-sandbox-controller.js +6 -13
  17. package/dist/lib/internal/git-workflow.js +5 -7
  18. package/dist/lib/internal/platform-pr.js +20 -7
  19. package/dist/lib/internal/release-workflow.js +3 -4
  20. package/dist/lib/internal/sandbox-control.js +79 -2
  21. package/dist/lib/internal/task-activity.js +57 -7
  22. package/dist/lib/internal/task-event.js +57 -4
  23. package/dist/lib/internal/task-finalization.js +54 -0
  24. package/dist/lib/internal/task-ledger.js +59 -5
  25. package/dist/lib/internal/task-lifecycle.js +59 -8
  26. package/dist/lib/internal/task-orchestration.js +161 -288
  27. package/dist/lib/internal/task-override.js +131 -0
  28. package/dist/lib/internal/task-review.js +64 -6
  29. package/dist/lib/internal/task-verify.js +65 -3
  30. package/dist/lib/internal/task-warning.js +57 -4
  31. package/dist/lib/platform/pr-summary.js +53 -13
  32. package/dist/lib/platform/pull-requests.js +205 -41
  33. package/dist/lib/runtime/agent-runtime.js +56 -0
  34. package/dist/lib/sandbox/commands/create.js +389 -311
  35. package/dist/lib/sandbox/commands/rm.js +316 -55
  36. package/dist/lib/sandbox/control/client.js +190 -6
  37. package/dist/lib/sandbox/control/container-identity.js +1 -1
  38. package/dist/lib/sandbox/control/controller-registration.js +273 -0
  39. package/dist/lib/sandbox/control/executor.js +145 -5
  40. package/dist/lib/sandbox/control/lifecycle.js +395 -73
  41. package/dist/lib/sandbox/control/protocol.js +67 -6
  42. package/dist/lib/sandbox/control/state.js +27 -11
  43. package/dist/lib/sandbox/engine.js +14 -7
  44. package/dist/lib/sandbox/index.js +5 -3
  45. package/dist/lib/sandbox/managed-fs.js +5 -0
  46. package/dist/lib/sandbox/mounts.js +20 -5
  47. package/dist/lib/sandbox/recovery.js +78 -10
  48. package/dist/lib/sandbox/task-resolver.js +50 -6
  49. package/dist/lib/sandbox/workspace-identity.js +27 -0
  50. package/dist/lib/sandbox/workspace-view.js +62 -40
  51. package/dist/lib/sandbox/worktree-safety.js +215 -2
  52. package/dist/lib/server/process-state.js +51 -1
  53. package/dist/lib/task/activity-intent.js +4 -1
  54. package/dist/lib/task/codex-orchestration.js +158 -33
  55. package/dist/lib/task/commands/status.js +59 -1
  56. package/dist/lib/task/commit-operation.js +335 -0
  57. package/dist/lib/task/commit-policy.js +18 -0
  58. package/dist/lib/task/decision-intents.js +4 -2
  59. package/dist/lib/task/delegation-receipts.js +279 -17
  60. package/dist/lib/task/events.js +9 -5
  61. package/dist/lib/task/finalization.js +567 -0
  62. package/dist/lib/task/guard-override.js +31 -0
  63. package/dist/lib/task/human-override.js +1240 -0
  64. package/dist/lib/task/ledger-intents.js +6 -3
  65. package/dist/lib/task/lifecycle.js +69 -14
  66. package/dist/lib/task/operation-outcome.js +33 -0
  67. package/dist/lib/task/orchestration.js +199 -719
  68. package/dist/lib/task/review-finalization.js +3 -2
  69. package/dist/lib/task/short-id.js +59 -1
  70. package/dist/lib/task/task-execution-lock.js +6 -1
  71. package/dist/lib/task/verification-engine.js +138 -59
  72. package/dist/lib/task/verification.js +15 -2
  73. package/dist/lib/task/workflow-warning-intents.js +54 -3
  74. package/dist/lib/task/write.js +2 -1
  75. package/dist/lifecycle-build-manifest.json +3 -3
  76. package/lib/agent-clients/adapters/claude-code.ts +3 -3
  77. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +63 -20
  78. package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +12 -0
  79. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +278 -13
  80. package/lib/agent-clients/adapters/codex-lifecycle/controller-context.ts +180 -0
  81. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -0
  82. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +9 -1
  83. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +112 -93
  84. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +2 -1
  85. package/lib/decide.ts +59 -14
  86. package/lib/git/workflow.ts +11 -2
  87. package/lib/init.ts +1 -1
  88. package/lib/internal/codex-lifecycle.ts +18 -5
  89. package/lib/internal/codex-sandbox-controller.ts +6 -12
  90. package/lib/internal/git-workflow.ts +5 -6
  91. package/lib/internal/platform-pr.ts +14 -7
  92. package/lib/internal/release-workflow.ts +3 -4
  93. package/lib/internal/sandbox-control.ts +92 -2
  94. package/lib/internal/task-activity.ts +46 -6
  95. package/lib/internal/task-event.ts +53 -4
  96. package/lib/internal/task-finalization.ts +68 -0
  97. package/lib/internal/task-ledger.ts +48 -4
  98. package/lib/internal/task-lifecycle.ts +56 -12
  99. package/lib/internal/task-orchestration.ts +32 -107
  100. package/lib/internal/task-override.ts +138 -0
  101. package/lib/internal/task-review.ts +51 -6
  102. package/lib/internal/task-verify.ts +52 -3
  103. package/lib/internal/task-warning.ts +44 -3
  104. package/lib/platform/pr-summary.ts +65 -14
  105. package/lib/platform/pull-requests.ts +211 -41
  106. package/lib/runtime/agent-runtime.ts +76 -0
  107. package/lib/sandbox/commands/create.ts +88 -4
  108. package/lib/sandbox/commands/rm.ts +413 -61
  109. package/lib/sandbox/config.ts +2 -1
  110. package/lib/sandbox/control/client.ts +255 -6
  111. package/lib/sandbox/control/container-identity.ts +3 -3
  112. package/lib/sandbox/control/controller-registration.ts +347 -0
  113. package/lib/sandbox/control/executor.ts +172 -6
  114. package/lib/sandbox/control/lifecycle.ts +465 -77
  115. package/lib/sandbox/control/protocol.ts +85 -9
  116. package/lib/sandbox/control/state.ts +35 -18
  117. package/lib/sandbox/engine.ts +20 -9
  118. package/lib/sandbox/index.ts +5 -3
  119. package/lib/sandbox/managed-fs.ts +5 -0
  120. package/lib/sandbox/mounts.ts +23 -5
  121. package/lib/sandbox/recovery.ts +90 -9
  122. package/lib/sandbox/task-resolver.ts +58 -7
  123. package/lib/sandbox/workspace-identity.ts +50 -2
  124. package/lib/sandbox/workspace-view.ts +73 -43
  125. package/lib/sandbox/worktree-safety.ts +236 -2
  126. package/lib/server/process-state.ts +48 -1
  127. package/lib/task/activity-intent.ts +4 -2
  128. package/lib/task/codex-orchestration.ts +167 -33
  129. package/lib/task/commands/status.ts +78 -1
  130. package/lib/task/commit-operation.ts +430 -0
  131. package/lib/task/commit-policy.ts +30 -0
  132. package/lib/task/decision-intents.ts +4 -2
  133. package/lib/task/delegation-receipts.ts +304 -19
  134. package/lib/task/events.ts +10 -5
  135. package/lib/task/finalization.ts +719 -0
  136. package/lib/task/guard-override.ts +50 -0
  137. package/lib/task/human-override.ts +1503 -0
  138. package/lib/task/ledger-intents.ts +6 -3
  139. package/lib/task/lifecycle.ts +71 -14
  140. package/lib/task/operation-outcome.ts +48 -0
  141. package/lib/task/orchestration.ts +239 -922
  142. package/lib/task/review-finalization.ts +6 -2
  143. package/lib/task/short-id.ts +65 -1
  144. package/lib/task/task-execution-lock.ts +10 -1
  145. package/lib/task/verification-engine.ts +145 -64
  146. package/lib/task/verification.ts +18 -3
  147. package/lib/task/workflow-warning-intents.ts +61 -4
  148. package/lib/task/write.ts +4 -1
  149. package/package.json +2 -2
  150. package/templates/.agents/QUICKSTART.en.md +2 -2
  151. package/templates/.agents/QUICKSTART.zh-CN.md +2 -2
  152. package/templates/.agents/hooks/lifecycle-delegation.js +9 -24
  153. package/templates/.agents/rules/lifecycle-orchestration.en.md +4 -3
  154. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +4 -3
  155. package/templates/.agents/rules/task-management.en.md +2 -2
  156. package/templates/.agents/rules/task-management.zh-CN.md +2 -2
  157. package/templates/.agents/skills/block-task/SKILL.en.md +2 -4
  158. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -4
  159. package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -3
  160. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -3
  161. package/templates/.agents/skills/close-codescan/SKILL.en.md +6 -2
  162. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +6 -2
  163. package/templates/.agents/skills/close-dependabot/SKILL.en.md +6 -2
  164. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +6 -2
  165. package/templates/.agents/skills/commit/SKILL.en.md +41 -125
  166. package/templates/.agents/skills/commit/SKILL.zh-CN.md +52 -123
  167. package/templates/.agents/skills/commit/reference/commit-message.en.md +1 -1
  168. package/templates/.agents/skills/commit/reference/commit-message.zh-CN.md +1 -1
  169. package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +14 -43
  170. package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +14 -49
  171. package/templates/.agents/skills/commit/reference/pr-summary-sync.en.md +1 -1
  172. package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +1 -1
  173. package/templates/.agents/skills/commit/reference/task-status-update.en.md +2 -35
  174. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +2 -35
  175. package/templates/.agents/skills/complete-task/SKILL.en.md +31 -38
  176. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +31 -38
  177. package/templates/.agents/skills/complete-task/config/verify.en.json +1 -0
  178. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +1 -0
  179. package/templates/.agents/skills/complete-task/reference/external-delivery.en.md +1 -1
  180. package/templates/.agents/skills/complete-task/reference/external-delivery.zh-CN.md +1 -1
  181. package/templates/.agents/skills/create-pr/SKILL.en.md +6 -4
  182. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +6 -4
  183. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +2 -2
  184. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +2 -2
  185. package/templates/.agents/skills/run-task/SKILL.en.md +5 -5
  186. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +5 -5
  187. package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -1
  188. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -1
  189. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +1 -1
  190. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +1 -1
  191. package/templates/.codex/hooks.json +4 -4
  192. package/dist/lib/task/commit-finalization.js +0 -305
  193. package/dist/lib/task/commit-intent.js +0 -165
  194. package/lib/task/commit-finalization.ts +0 -419
  195. package/lib/task/commit-intent.ts +0 -230
@@ -5,7 +5,14 @@ import { isRemovedHashShortIdInput } from '../task/short-id.ts';
5
5
 
6
6
  const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
7
7
  const SHORT_ID_RE = /^\d+$/;
8
- const WORKSPACE_DIRS = ['active', 'completed', 'blocked', 'archive'];
8
+ export const TASK_WORKSPACE_STATES = ['active', 'completed', 'blocked', 'archive'] as const;
9
+ export type TaskWorkspaceState = typeof TASK_WORKSPACE_STATES[number];
10
+ export type TaskWorkspace = Readonly<{
11
+ taskId: string;
12
+ branch: string;
13
+ state: TaskWorkspaceState;
14
+ taskMd: string;
15
+ }>;
9
16
 
10
17
  function resolveShortIdStrict(arg: string, repoRoot: string): string {
11
18
  const scriptPath = path.join(repoRoot, '.agents', 'scripts', 'task-short-id.js');
@@ -27,16 +34,30 @@ function stripQuotes(value: string): string {
27
34
  return value.replace(/^(["'])(.*)\1$/, '$2');
28
35
  }
29
36
 
30
- function readTaskContent(repoRoot: string, taskId: string): string {
31
- for (const dir of WORKSPACE_DIRS) {
32
- const taskPath = path.join(repoRoot, '.agents', 'workspace', dir, taskId, 'task.md');
37
+ function readTaskContent(repoRoot: string, taskId: string): { content: string; state: TaskWorkspaceState; taskMd: string } {
38
+ for (const state of TASK_WORKSPACE_STATES) {
39
+ const taskPath = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
33
40
  if (fs.existsSync(taskPath)) {
34
- return fs.readFileSync(taskPath, 'utf8');
41
+ return { content: fs.readFileSync(taskPath, 'utf8'), state, taskMd: taskPath };
35
42
  }
36
43
  }
37
44
  throw new Error(`Task not found: ${taskId}`);
38
45
  }
39
46
 
47
+ function taskWorkspaceCandidates(repoRoot: string, taskId: string): TaskWorkspace[] {
48
+ return TASK_WORKSPACE_STATES.flatMap((state) => {
49
+ const taskMd = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
50
+ if (!fs.existsSync(taskMd)) return [];
51
+ const content = fs.readFileSync(taskMd, 'utf8');
52
+ return [{
53
+ taskId,
54
+ branch: resolveBranchFromTaskContent(content, taskId),
55
+ state,
56
+ taskMd
57
+ }];
58
+ });
59
+ }
60
+
40
61
  function resolveBranchFromTaskContent(content: string, taskId: string): string {
41
62
  const frontmatterBranch = content.match(/^branch:\s*(.+)$/m);
42
63
  if (frontmatterBranch?.[1]?.trim()) {
@@ -57,12 +78,42 @@ export function resolveTaskBranch(arg: string, repoRoot: string): string {
57
78
  }
58
79
  if (SHORT_ID_RE.test(arg)) {
59
80
  const taskId = resolveShortIdStrict(arg, repoRoot);
60
- const content = readTaskContent(repoRoot, taskId);
81
+ const { content } = readTaskContent(repoRoot, taskId);
61
82
  return resolveBranchFromTaskContent(content, taskId);
62
83
  }
63
84
  if (!TASK_ID_RE.test(arg)) {
64
85
  return arg;
65
86
  }
66
- const content = readTaskContent(repoRoot, arg);
87
+ const { content } = readTaskContent(repoRoot, arg);
67
88
  return resolveBranchFromTaskContent(content, arg);
68
89
  }
90
+
91
+ export function resolveTaskWorkspace(taskId: string, repoRoot: string): TaskWorkspace {
92
+ if (!TASK_ID_RE.test(taskId)) throw new Error(`Invalid task id: ${taskId}`);
93
+ const matches = taskWorkspaceCandidates(repoRoot, taskId);
94
+ if (matches.length === 0) throw new Error(`Task not found: ${taskId}`);
95
+ if (matches.length !== 1) {
96
+ throw new Error(
97
+ `SANDBOX_TASK_STATE_AMBIGUOUS: task ${taskId} exists in ${matches.map((match) => match.state).join(', ')}`
98
+ );
99
+ }
100
+ return matches[0]!;
101
+ }
102
+
103
+ export function listTaskWorkspaces(repoRoot: string, state: TaskWorkspaceState): TaskWorkspace[] {
104
+ const stateRoot = path.join(repoRoot, '.agents', 'workspace', state);
105
+ if (!fs.existsSync(stateRoot)) return [];
106
+ return fs.readdirSync(stateRoot, { withFileTypes: true })
107
+ .filter((entry) => entry.isDirectory() && TASK_ID_RE.test(entry.name))
108
+ .flatMap((entry) => {
109
+ const taskMd = path.join(stateRoot, entry.name, 'task.md');
110
+ if (!fs.existsSync(taskMd)) return [];
111
+ const content = fs.readFileSync(taskMd, 'utf8');
112
+ return [{
113
+ taskId: entry.name,
114
+ branch: resolveBranchFromTaskContent(content, entry.name),
115
+ state,
116
+ taskMd
117
+ }];
118
+ });
119
+ }
@@ -1,6 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { isRemovedHashShortIdInput } from '../task/short-id.ts';
4
+ import {
5
+ resolveTaskWorkspace,
6
+ type TaskWorkspaceState
7
+ } from './task-resolver.ts';
4
8
 
5
9
  const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
6
10
  const SHORT_ID_RE = /^\d+$/;
@@ -9,6 +13,17 @@ export type SandboxWorkspaceIdentity =
9
13
  | Readonly<{ mode: 'task-bound'; taskId: string; shortId: string }>
10
14
  | Readonly<{ mode: 'branch-only' }>;
11
15
 
16
+ export type SandboxWorkspaceKey =
17
+ | Readonly<{ mode: 'task-bound'; taskId: string }>
18
+ | Readonly<{ mode: 'branch-only' }>;
19
+
20
+ export type SandboxCleanupTarget = Readonly<{
21
+ requestedRef: string;
22
+ branch: string;
23
+ workspace: SandboxWorkspaceKey;
24
+ taskState: TaskWorkspaceState | 'branch-only';
25
+ }>;
26
+
12
27
  export type SandboxContainerWorkspaceIdentity =
13
28
  | Readonly<{ mode: 'task-bound'; taskId: string }>
14
29
  | Readonly<{ mode: 'branch-only' }>
@@ -101,6 +116,39 @@ export function resolveSandboxTarget(requestedRef: string, repoRoot: string): Sa
101
116
  return { requestedRef, branch: requestedRef, workspace: { mode: 'branch-only' } };
102
117
  }
103
118
 
119
+ export function resolveSandboxCleanupTarget(
120
+ requestedRef: string,
121
+ repoRoot: string,
122
+ options: Readonly<{ allowProtected?: boolean }> = {}
123
+ ): SandboxCleanupTarget {
124
+ if (isRemovedHashShortIdInput(requestedRef)) {
125
+ throw new Error(`Invalid task short id '${requestedRef}': task short ids must use bare digits`);
126
+ }
127
+
128
+ if (TASK_ID_RE.test(requestedRef)) {
129
+ const task = resolveTaskWorkspace(requestedRef, repoRoot);
130
+ if (!options.allowProtected && (task.state === 'blocked' || task.state === 'archive')) {
131
+ throw new Error(`SANDBOX_CLEANUP_STATE_UNSUPPORTED: task ${requestedRef} is ${task.state}`);
132
+ }
133
+ return {
134
+ requestedRef,
135
+ branch: task.branch,
136
+ workspace: { mode: 'task-bound', taskId: task.taskId },
137
+ taskState: task.state
138
+ };
139
+ }
140
+
141
+ const target = resolveSandboxTarget(requestedRef, repoRoot);
142
+ return {
143
+ requestedRef,
144
+ branch: target.branch,
145
+ workspace: target.workspace.mode === 'task-bound'
146
+ ? { mode: 'task-bound', taskId: target.workspace.taskId }
147
+ : { mode: 'branch-only' },
148
+ taskState: target.workspace.mode === 'task-bound' ? 'active' : 'branch-only'
149
+ };
150
+ }
151
+
104
152
  export function parseSandboxWorkspaceIdentity(
105
153
  labels: Readonly<Record<string, string>>,
106
154
  keys: Readonly<{ mode: string; taskId: string }>
@@ -115,8 +163,8 @@ export function parseSandboxWorkspaceIdentity(
115
163
  }
116
164
 
117
165
  export function sameSandboxWorkspaceIdentity(
118
- left: SandboxWorkspaceIdentity | SandboxContainerWorkspaceIdentity,
119
- right: SandboxWorkspaceIdentity | SandboxContainerWorkspaceIdentity
166
+ left: SandboxWorkspaceIdentity | SandboxWorkspaceKey | SandboxContainerWorkspaceIdentity,
167
+ right: SandboxWorkspaceIdentity | SandboxWorkspaceKey | SandboxContainerWorkspaceIdentity
120
168
  ): boolean {
121
169
  return left.mode === right.mode
122
170
  && (left.mode !== 'task-bound' || (right.mode === 'task-bound' && left.taskId === right.taskId));
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import type { SandboxWorkspaceIdentity } from './workspace-identity.ts';
4
+ import type { SandboxWorkspaceIdentity, SandboxWorkspaceKey } from './workspace-identity.ts';
5
5
  import type { SandboxControlManifest } from './control/protocol.ts';
6
6
 
7
7
  export type SandboxWorkspaceView = Readonly<{
@@ -30,7 +30,7 @@ export function sandboxWorkspaceViewPaths(params: Readonly<{
30
30
  base: string;
31
31
  project: string;
32
32
  container: string;
33
- identity: SandboxWorkspaceIdentity;
33
+ identity: SandboxWorkspaceIdentity | SandboxWorkspaceKey;
34
34
  }>): SandboxWorkspaceView {
35
35
  const identityKey = params.identity.mode === 'task-bound'
36
36
  ? `task-bound:${params.identity.taskId}`
@@ -50,6 +50,7 @@ export type SandboxControlSetup = Readonly<{
50
50
  root: string;
51
51
  channelDir: string;
52
52
  statusDir: string;
53
+ runtimeDir: string;
53
54
  manifestPath: string;
54
55
  token: string;
55
56
  generation: string;
@@ -59,8 +60,8 @@ export function sandboxControlPaths(params: Readonly<{
59
60
  base: string;
60
61
  project: string;
61
62
  container: string;
62
- identity: SandboxWorkspaceIdentity;
63
- }>): Readonly<{ root: string; channelDir: string; statusDir: string; processingDir: string; manifestPath: string }> {
63
+ identity: SandboxWorkspaceIdentity | SandboxWorkspaceKey;
64
+ }>): Readonly<{ root: string; channelDir: string; statusDir: string; processingDir: string; runtimeDir: string; manifestPath: string }> {
64
65
  const identityKey = params.identity.mode === 'task-bound'
65
66
  ? `task-bound:${params.identity.taskId}`
66
67
  : 'branch-only';
@@ -71,6 +72,7 @@ export function sandboxControlPaths(params: Readonly<{
71
72
  channelDir: path.join(root, 'channel'),
72
73
  statusDir: path.join(root, 'public'),
73
74
  processingDir: path.join(root, 'processing'),
75
+ runtimeDir: path.join(root, 'runtime'),
74
76
  manifestPath: path.join(root, 'manifest.json')
75
77
  };
76
78
  }
@@ -85,6 +87,32 @@ function assertSafeDirectory(directory: string, expectedBase: string): void {
85
87
  }
86
88
  }
87
89
 
90
+ function assertSafeMountTargetPath(
91
+ target: string,
92
+ worktreeRoot: string,
93
+ canonicalWorktreeRoot: string
94
+ ): void {
95
+ const relative = path.relative(worktreeRoot, target);
96
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
97
+ throw new Error(`Sandbox workspace mount target escapes its worktree: ${target}`);
98
+ }
99
+ let current = worktreeRoot;
100
+ for (const segment of ['', ...relative.split(path.sep).filter(Boolean)]) {
101
+ current = segment ? path.join(current, segment) : current;
102
+ try {
103
+ if (fs.lstatSync(current).isSymbolicLink()) {
104
+ throw new Error(`Sandbox workspace mount target must not contain a symbolic link: ${current}`);
105
+ }
106
+ const canonicalRelative = path.relative(canonicalWorktreeRoot, fs.realpathSync.native(current));
107
+ if (canonicalRelative.startsWith('..') || path.isAbsolute(canonicalRelative)) {
108
+ throw new Error(`Sandbox workspace mount target escapes its real worktree: ${current}`);
109
+ }
110
+ } catch (error) {
111
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
112
+ }
113
+ }
114
+ }
115
+
88
116
  export function assertSandboxTaskSource(repoRoot: string, taskId: string): string {
89
117
  if (!/^TASK-\d{8}-\d{6}$/.test(taskId)) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
90
118
  const activeRoot = fs.realpathSync.native(path.join(repoRoot, '.agents', 'workspace', 'active'));
@@ -100,41 +128,22 @@ export function assertSandboxTaskSource(repoRoot: string, taskId: string): strin
100
128
  return canonical;
101
129
  }
102
130
 
103
- export function prepareSandboxWorkspaceMountTarget(worktreeRoot: string): string {
104
- const worktree = path.resolve(worktreeRoot);
105
- const canonicalWorktree = fs.realpathSync.native(worktree);
106
- const agentsRoot = path.join(worktree, '.agents');
107
- if (fs.existsSync(agentsRoot)) {
108
- const agentsStat = fs.lstatSync(agentsRoot);
109
- if (!agentsStat.isDirectory() || agentsStat.isSymbolicLink()) {
110
- throw new Error(`Sandbox workspace mount ancestor must be a real directory: ${agentsRoot}`);
111
- }
112
- } else {
113
- fs.mkdirSync(agentsRoot, { mode: 0o700 });
131
+ export function prepareSandboxWorkspaceMountTargets(worktreeRoot: string): void {
132
+ const resolvedWorktreeRoot = path.resolve(worktreeRoot);
133
+ const canonicalWorktreeRoot = fs.realpathSync.native(resolvedWorktreeRoot);
134
+ const workspaceRoot = path.resolve(resolvedWorktreeRoot, '.agents', 'workspace');
135
+ const statePaths = sandboxWorkspaceViewStatePaths(workspaceRoot);
136
+ const registryTarget = path.join(workspaceRoot, 'active', '.short-ids.json');
137
+ for (const target of [workspaceRoot, ...statePaths.map(({ hostPath }) => hostPath), registryTarget]) {
138
+ assertSafeMountTargetPath(target, resolvedWorktreeRoot, canonicalWorktreeRoot);
114
139
  }
115
- const canonicalAgents = fs.realpathSync.native(agentsRoot);
116
- if (path.dirname(canonicalAgents) !== canonicalWorktree) {
117
- throw new Error(`Sandbox workspace mount ancestor escapes the worktree: ${agentsRoot}`);
118
- }
119
-
120
- const target = path.join(agentsRoot, 'workspace');
121
- if (fs.existsSync(target)) {
122
- const stat = fs.lstatSync(target);
123
- if (stat.isSymbolicLink()) {
124
- throw new Error(`Sandbox workspace mount target must not be a symbolic link: ${target}`);
125
- }
126
- if (!stat.isDirectory()) {
127
- throw new Error(`Sandbox workspace mount target must be a directory: ${target}`);
128
- }
129
- if (path.dirname(fs.realpathSync.native(target)) !== canonicalAgents) {
130
- throw new Error(`Sandbox workspace mount target escapes the worktree: ${target}`);
131
- }
132
- } else {
133
- fs.mkdirSync(target, { mode: 0o700 });
140
+ fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 });
141
+ for (const { hostPath } of statePaths) {
142
+ fs.mkdirSync(hostPath, { recursive: true, mode: 0o700 });
143
+ fs.chmodSync(hostPath, 0o700);
134
144
  }
135
- fs.chmodSync(target, 0o700);
136
- fs.accessSync(target, fs.constants.W_OK);
137
- return target;
145
+ fs.chmodSync(workspaceRoot, 0o700);
146
+ fs.closeSync(fs.openSync(registryTarget, 'a', 0o600));
138
147
  }
139
148
 
140
149
  export function materializeSandboxWorkspaceView(params: Readonly<{
@@ -181,16 +190,36 @@ export function materializeSandboxControl(params: Readonly<{
181
190
  branch: string;
182
191
  identity: SandboxWorkspaceIdentity;
183
192
  engine?: string;
193
+ replacementLease?: Readonly<{
194
+ root: string;
195
+ assertOwned(): void;
196
+ }>;
184
197
  }>): SandboxControlSetup {
185
- const { root, channelDir, statusDir, processingDir, manifestPath } = sandboxControlPaths(params);
198
+ const { root, channelDir, statusDir, processingDir, runtimeDir, manifestPath } = sandboxControlPaths(params);
186
199
  assertSafeDirectory(root, path.resolve(params.base));
200
+ if (fs.existsSync(root)) {
201
+ if (!params.replacementLease || path.resolve(params.replacementLease.root) !== root) {
202
+ throw new Error('SANDBOX_CONTROL_REPLACEMENT_REQUIRED');
203
+ }
204
+ params.replacementLease.assertOwned();
205
+ }
187
206
  const consumedDir = path.join(root, 'consumed');
188
207
  assertSafeDirectory(consumedDir, root);
189
208
  fs.rmSync(consumedDir, { recursive: true, force: true });
190
209
  fs.mkdirSync(consumedDir, { recursive: true, mode: 0o700 });
191
- for (const directory of [statusDir, processingDir]) {
210
+ for (const directory of [statusDir, processingDir, runtimeDir]) {
192
211
  assertSafeDirectory(directory, root);
212
+ if (directory === runtimeDir) fs.rmSync(directory, { recursive: true, force: true });
213
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
214
+ }
215
+ for (const directory of [
216
+ path.join(runtimeDir, 'clients'),
217
+ path.join(runtimeDir, 'clients', 'codex', 'capabilities'),
218
+ path.join(runtimeDir, 'clients', 'codex', 'lifecycle')
219
+ ]) {
220
+ assertSafeDirectory(directory, runtimeDir);
193
221
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
222
+ fs.chmodSync(directory, 0o700);
194
223
  }
195
224
  for (const queue of ['requests', 'responses']) {
196
225
  const directory = path.join(channelDir, queue);
@@ -202,7 +231,7 @@ export function materializeSandboxControl(params: Readonly<{
202
231
  const generation = randomBytes(16).toString('hex');
203
232
  const repoRoot = fs.realpathSync.native(params.repoRoot);
204
233
  const manifest: SandboxControlManifest = {
205
- version: 4,
234
+ version: 5,
206
235
  engine: params.engine ?? 'docker',
207
236
  repoRoot,
208
237
  worktreeRoot: fs.realpathSync.native(params.worktreeRoot),
@@ -216,10 +245,11 @@ export function materializeSandboxControl(params: Readonly<{
216
245
  generation,
217
246
  channelDir,
218
247
  publicStatusDir: statusDir,
219
- processingDir
248
+ processingDir,
249
+ runtimeDir
220
250
  };
221
251
  fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { mode: 0o600 });
222
- return { root, channelDir, statusDir, manifestPath, token, generation };
252
+ return { root, channelDir, statusDir, runtimeDir, manifestPath, token, generation };
223
253
  }
224
254
 
225
255
  export function finalizeSandboxControlManifest(
@@ -228,7 +258,7 @@ export function finalizeSandboxControlManifest(
228
258
  ): void {
229
259
  if (!identity.engine || !identity.id) throw new Error('SANDBOX_CONTROL_CONTAINER_ID_INVALID');
230
260
  const manifest = JSON.parse(fs.readFileSync(setup.manifestPath, 'utf8')) as Record<string, unknown>;
231
- manifest.version = 4;
261
+ manifest.version = 5;
232
262
  manifest.engine = identity.engine;
233
263
  manifest.containerIdentity = { id: identity.id, labels: { ...identity.labels } };
234
264
  const temporary = `${setup.manifestPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
@@ -1,7 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { runProbe } from './shell.ts';
6
+ import { resolveSandboxTarget } from './workspace-identity.ts';
5
7
 
6
8
  type WorktreeChange = {
7
9
  indexStatus: string;
@@ -16,6 +18,8 @@ type WorktreeSnapshot = {
16
18
  head: string;
17
19
  changes: readonly WorktreeChange[];
18
20
  identity: string;
21
+ source?: 'registered' | 'recovered';
22
+ recovery?: WorktreeRecoveryContext;
19
23
  };
20
24
 
21
25
  type WorktreeInspection =
@@ -28,6 +32,14 @@ type WorktreeRemovalPermit = {
28
32
  snapshot: WorktreeSnapshot;
29
33
  };
30
34
 
35
+ type WorktreeRecoveryContext = Readonly<{
36
+ repoRoot: string;
37
+ worktreeBase: string;
38
+ branch: string;
39
+ identitySource: 'branch-only' | 'task-bound';
40
+ taskId: string | null;
41
+ }>;
42
+
31
43
  type Probe = typeof runProbe;
32
44
 
33
45
  function probeText(probe: Probe, cwd: string, args: string[]): string {
@@ -164,6 +176,203 @@ function captureOnce(worktree: string, probe: Probe): WorktreeSnapshot {
164
176
  return { worktree: resolvedWorktree, branch, head, changes, identity: hash.digest('hex') };
165
177
  }
166
178
 
179
+ function probeTextWithEnv(
180
+ probe: Probe,
181
+ repoRoot: string,
182
+ args: string[],
183
+ env: NodeJS.ProcessEnv
184
+ ): string {
185
+ const result = probe('git', args, { cwd: repoRoot, encoding: 'utf8', env });
186
+ if (result.error) throw result.error;
187
+ if (result.status !== 0) {
188
+ const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : result.stderr.toString('utf8').trim();
189
+ throw new Error(stderr || `git ${args[0] ?? 'command'} failed with exit code ${result.status ?? 'unknown'}`);
190
+ }
191
+ return typeof result.stdout === 'string' ? result.stdout : result.stdout.toString('utf8');
192
+ }
193
+
194
+ function probeMetadataCommand(
195
+ probe: Probe,
196
+ repoRoot: string,
197
+ args: string[],
198
+ env: NodeJS.ProcessEnv
199
+ ): boolean {
200
+ const result = probe('git', args, { cwd: repoRoot, encoding: 'utf8', env });
201
+ if (result.error) throw result.error;
202
+ return result.status === 0;
203
+ }
204
+
205
+ function hasValidHeadContent(content: string): boolean {
206
+ return /^(?:ref:\s+refs\/\S+|[0-9a-f]{40}|[0-9a-f]{64})$/i.test(content.trim());
207
+ }
208
+
209
+ function assertRecoverableMetadata(worktree: string, repoRoot: string, probe: Probe): void {
210
+ const dotGit = path.join(worktree, '.git');
211
+ let dotGitStat: fs.Stats;
212
+ try {
213
+ dotGitStat = fs.lstatSync(dotGit);
214
+ } catch (error) {
215
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
216
+ throw error;
217
+ }
218
+ if (dotGitStat.isSymbolicLink() || dotGitStat.isDirectory()) {
219
+ throw new Error('WORKTREE_RECOVERY_METADATA_NOT_ELIGIBLE');
220
+ }
221
+ if (!dotGitStat.isFile()) throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
222
+
223
+ const content = fs.readFileSync(dotGit, 'utf8').trim();
224
+ const match = /^gitdir:\s*(.+)$/i.exec(content);
225
+ if (!match) throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
226
+
227
+ const commonDir = fs.realpathSync.native(path.resolve(repoRoot, probeText(probe, repoRoot, [
228
+ 'rev-parse', '--git-common-dir'
229
+ ]).trim()));
230
+ const adminRoot = path.join(commonDir, 'worktrees');
231
+ const adminPath = path.resolve(path.dirname(dotGit), match[1]!);
232
+ const relative = path.relative(adminRoot, adminPath);
233
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || relative.includes(path.sep)) {
234
+ throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
235
+ }
236
+
237
+ if (!fs.existsSync(adminPath)) return;
238
+ const adminStat = fs.lstatSync(adminPath);
239
+ if (adminStat.isSymbolicLink()) throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
240
+ if (!adminStat.isDirectory()) return;
241
+
242
+ const requiredFiles = ['HEAD', 'gitdir', 'commondir', 'index'];
243
+ if (requiredFiles.some((name) => !fs.existsSync(path.join(adminPath, name)))) return;
244
+ for (const name of requiredFiles) {
245
+ const filePath = path.join(adminPath, name);
246
+ const stat = fs.lstatSync(filePath);
247
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
248
+ }
249
+
250
+ const adminGitdir = fs.readFileSync(path.join(adminPath, 'gitdir'), 'utf8').trim();
251
+ const adminCommonDir = fs.readFileSync(path.join(adminPath, 'commondir'), 'utf8').trim();
252
+ const adminGitdirPath = adminGitdir
253
+ ? fs.realpathSync.native(path.resolve(adminPath, adminGitdir))
254
+ : '';
255
+ const canonicalDotGit = fs.realpathSync.native(dotGit);
256
+ const adminCommonDirPath = adminCommonDir
257
+ ? fs.realpathSync.native(path.resolve(adminPath, adminCommonDir))
258
+ : '';
259
+ if (!adminGitdir || adminGitdirPath !== canonicalDotGit
260
+ || !adminCommonDir || adminCommonDirPath !== commonDir) {
261
+ throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
262
+ }
263
+
264
+ const headContent = fs.readFileSync(path.join(adminPath, 'HEAD'), 'utf8');
265
+ const indexContent = fs.readFileSync(path.join(adminPath, 'index'));
266
+ if (!hasValidHeadContent(headContent) || indexContent.subarray(0, 4).toString('ascii') !== 'DIRC') return;
267
+
268
+ const validationEnv: NodeJS.ProcessEnv = { ...process.env };
269
+ for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_WORK_TREE', 'GIT_PREFIX']) {
270
+ delete validationEnv[key];
271
+ }
272
+ validationEnv.GIT_DIR = adminPath;
273
+ validationEnv.GIT_WORK_TREE = path.resolve(worktree);
274
+ if (!probeMetadataCommand(probe, repoRoot, ['rev-parse', '--verify', 'HEAD^{commit}'], validationEnv)) return;
275
+ if (!probeMetadataCommand(probe, repoRoot, ['ls-files', '--stage', '-z'], validationEnv)) return;
276
+ throw new Error('WORKTREE_RECOVERY_METADATA_REGISTERED');
277
+ }
278
+
279
+ function parseRecoveredStatus(raw: string): WorktreeChange[] {
280
+ const records = raw.split('\0');
281
+ const changes: WorktreeChange[] = [];
282
+ for (let index = 0; index < records.length;) {
283
+ const status = records[index++] ?? '';
284
+ if (!status) continue;
285
+ const code = status[0] ?? '';
286
+ if (code === 'R' || code === 'C') {
287
+ const originalPath = records[index++];
288
+ const nextPath = records[index++];
289
+ if (!originalPath || !nextPath) throw new Error('Unable to parse recovered Git rename record');
290
+ changes.push({ indexStatus: code, worktreeStatus: ' ', path: nextPath, originalPath });
291
+ continue;
292
+ }
293
+ const filePath = records[index++];
294
+ if (!filePath) throw new Error('Unable to parse recovered Git status record');
295
+ changes.push({
296
+ indexStatus: code === 'D' ? ' ' : code,
297
+ worktreeStatus: code === 'D' ? 'D' : ' ',
298
+ path: filePath
299
+ });
300
+ }
301
+ return changes;
302
+ }
303
+
304
+ function captureRecoveredOnce(
305
+ worktree: string,
306
+ recovery: WorktreeRecoveryContext,
307
+ probe: Probe
308
+ ): WorktreeSnapshot {
309
+ const resolvedWorktree = path.resolve(worktree);
310
+ const resolvedBase = path.resolve(recovery.worktreeBase);
311
+ const relative = path.relative(resolvedBase, resolvedWorktree);
312
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
313
+ throw new Error(`WORKTREE_RECOVERY_PATH_INVALID: ${resolvedWorktree}`);
314
+ }
315
+ const stat = fs.lstatSync(resolvedWorktree);
316
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('WORKTREE_RECOVERY_PATH_INVALID');
317
+ const canonicalBase = fs.realpathSync.native(resolvedBase);
318
+ const canonicalWorktree = fs.realpathSync.native(resolvedWorktree);
319
+ const canonicalRelative = path.relative(canonicalBase, canonicalWorktree);
320
+ if (!canonicalRelative || canonicalRelative.startsWith('..') || path.isAbsolute(canonicalRelative)) {
321
+ throw new Error('WORKTREE_RECOVERY_PATH_INVALID');
322
+ }
323
+ const resolvedWorkspace = resolveSandboxTarget(recovery.branch, recovery.repoRoot).workspace;
324
+ if (recovery.identitySource === 'branch-only' && resolvedWorkspace.mode !== 'branch-only') {
325
+ throw new Error('WORKTREE_RECOVERY_IDENTITY_CHANGED');
326
+ }
327
+ if (recovery.identitySource === 'task-bound'
328
+ && (resolvedWorkspace.mode !== 'task-bound' || resolvedWorkspace.taskId !== recovery.taskId)) {
329
+ throw new Error('WORKTREE_RECOVERY_IDENTITY_CHANGED');
330
+ }
331
+ assertRecoverableMetadata(resolvedWorktree, recovery.repoRoot, probe);
332
+
333
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-infra-recovered-index-'));
334
+ const env: NodeJS.ProcessEnv = { ...process.env };
335
+ for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_PREFIX']) delete env[key];
336
+ env.GIT_INDEX_FILE = path.join(tempDir, 'index');
337
+ env.GIT_WORK_TREE = resolvedWorktree;
338
+ try {
339
+ const baseline = probeTextWithEnv(probe, recovery.repoRoot, [
340
+ 'rev-parse', '--verify', `refs/heads/${recovery.branch}^{commit}`
341
+ ], env).trim();
342
+ if (!baseline) throw new Error(`WORKTREE_RECOVERY_BRANCH_INVALID: ${recovery.branch}`);
343
+ probeTextWithEnv(probe, recovery.repoRoot, ['read-tree', baseline], env);
344
+ probeTextWithEnv(probe, recovery.repoRoot, ['add', '-A', '--', '.'], env);
345
+ const status = probeTextWithEnv(probe, recovery.repoRoot, [
346
+ 'diff', '--cached', '--name-status', '--find-renames', '-z', '--', '.'
347
+ ], env);
348
+ const changes = parseRecoveredStatus(status);
349
+ const hash = createHash('sha256');
350
+ updatePart(hash, 'source', 'recovered');
351
+ updatePart(hash, 'worktree', resolvedWorktree);
352
+ updatePart(hash, 'repo-root', path.resolve(recovery.repoRoot));
353
+ updatePart(hash, 'branch', recovery.branch);
354
+ updatePart(hash, 'baseline', baseline);
355
+ updatePart(hash, 'identity-source', recovery.identitySource);
356
+ updatePart(hash, 'task-id', recovery.taskId ?? '');
357
+ updatePart(hash, 'status', status);
358
+ for (const change of changes) {
359
+ hashPath(hash, resolvedWorktree, change.path, probe);
360
+ if (change.originalPath) hashPath(hash, resolvedWorktree, change.originalPath, probe);
361
+ }
362
+ return {
363
+ worktree: resolvedWorktree,
364
+ branch: recovery.branch,
365
+ head: baseline,
366
+ changes,
367
+ identity: hash.digest('hex'),
368
+ source: 'recovered',
369
+ recovery: { ...recovery }
370
+ };
371
+ } finally {
372
+ fs.rmSync(tempDir, { recursive: true, force: true });
373
+ }
374
+ }
375
+
167
376
  function inspectWorktree(worktree: string, { probe = runProbe }: { probe?: Probe } = {}): WorktreeInspection {
168
377
  const resolvedWorktree = path.resolve(worktree);
169
378
  try {
@@ -182,6 +391,28 @@ function inspectWorktree(worktree: string, { probe = runProbe }: { probe?: Probe
182
391
  }
183
392
  }
184
393
 
394
+ function inspectRecoveredWorktree(
395
+ worktree: string,
396
+ recovery: WorktreeRecoveryContext,
397
+ { probe = runProbe }: { probe?: Probe } = {}
398
+ ): WorktreeInspection {
399
+ const resolvedWorktree = path.resolve(worktree);
400
+ try {
401
+ const first = captureRecoveredOnce(resolvedWorktree, recovery, probe);
402
+ const second = captureRecoveredOnce(resolvedWorktree, recovery, probe);
403
+ if (first.identity !== second.identity) {
404
+ return { status: 'failed', worktree: resolvedWorktree, message: 'Snapshot changed while recovering worktree' };
405
+ }
406
+ return { status: second.changes.length === 0 ? 'clean' : 'dirty', snapshot: second };
407
+ } catch (error) {
408
+ return {
409
+ status: 'failed',
410
+ worktree: resolvedWorktree,
411
+ message: error instanceof Error ? error.message : String(error)
412
+ };
413
+ }
414
+ }
415
+
185
416
  function inspectWorktrees(worktrees: readonly string[]): WorktreeInspection[] {
186
417
  return [...new Set(worktrees.map((worktree) => path.resolve(worktree)))].sort()
187
418
  .map((worktree) => inspectWorktree(worktree));
@@ -201,7 +432,9 @@ function verifyWorktreePermit(
201
432
  permit: WorktreeRemovalPermit,
202
433
  options: { probe?: Probe } = {}
203
434
  ): WorktreeSnapshot {
204
- const current = inspectWorktree(permit.snapshot.worktree, options);
435
+ const current = permit.snapshot.source === 'recovered' && permit.snapshot.recovery
436
+ ? inspectRecoveredWorktree(permit.snapshot.worktree, permit.snapshot.recovery, options)
437
+ : inspectWorktree(permit.snapshot.worktree, options);
205
438
  if (current.status === 'failed') throw new Error(`Unable to verify worktree permit: ${current.message}`);
206
439
  if (current.snapshot.identity !== permit.snapshot.identity || (permit.mode === 'clean' && current.status !== 'clean')) {
207
440
  throw new Error(`Worktree changed after authorization: ${permit.snapshot.worktree}`);
@@ -227,8 +460,9 @@ export {
227
460
  createCleanPermit,
228
461
  createDiscardPermit,
229
462
  formatWorktreeSnapshot,
463
+ inspectRecoveredWorktree,
230
464
  inspectWorktree,
231
465
  inspectWorktrees,
232
466
  verifyWorktreePermit
233
467
  };
234
- export type { WorktreeChange, WorktreeInspection, WorktreeRemovalPermit, WorktreeSnapshot };
468
+ export type { WorktreeChange, WorktreeInspection, WorktreeRecoveryContext, WorktreeRemovalPermit, WorktreeSnapshot };