parallel-codex-tui 0.1.0 → 0.1.4

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 (74) hide show
  1. package/.parallel-codex/config.example.toml +90 -3
  2. package/README.md +269 -12
  3. package/dist/bootstrap.js +50 -18
  4. package/dist/cli-args.js +96 -14
  5. package/dist/cli-help.js +20 -0
  6. package/dist/cli-startup-recovery.js +70 -0
  7. package/dist/cli-workspace-picker.js +330 -0
  8. package/dist/cli-workspace-transition.js +33 -0
  9. package/dist/cli-workspace.js +40 -0
  10. package/dist/cli.js +291 -35
  11. package/dist/core/app-root.js +8 -0
  12. package/dist/core/collaboration-timeline.js +261 -0
  13. package/dist/core/config-errors.js +14 -0
  14. package/dist/core/config.js +191 -23
  15. package/dist/core/file-store.js +130 -6
  16. package/dist/core/lease-finalization.js +22 -0
  17. package/dist/core/paths.js +10 -0
  18. package/dist/core/process-identity.js +48 -0
  19. package/dist/core/process-mutation-turn.js +128 -0
  20. package/dist/core/process-ownership.js +276 -0
  21. package/dist/core/process-tree.js +90 -0
  22. package/dist/core/router-audit.js +155 -0
  23. package/dist/core/router-redaction.js +31 -0
  24. package/dist/core/router.js +473 -42
  25. package/dist/core/session-index.js +225 -30
  26. package/dist/core/session-manager.js +1182 -44
  27. package/dist/core/task-state-machine.js +17 -0
  28. package/dist/core/workspace-commit-recovery.js +118 -0
  29. package/dist/core/workspace.js +126 -0
  30. package/dist/doctor.js +384 -30
  31. package/dist/domain/schemas.js +127 -6
  32. package/dist/orchestrator/collaboration-channel.js +255 -4
  33. package/dist/orchestrator/feature-plan.js +70 -0
  34. package/dist/orchestrator/judge-artifacts.js +236 -0
  35. package/dist/orchestrator/orchestrator.js +1777 -212
  36. package/dist/orchestrator/prompts.js +126 -2
  37. package/dist/orchestrator/supervisor-summary.js +56 -2
  38. package/dist/orchestrator/workspace-sandbox.js +911 -0
  39. package/dist/tui/App.js +2838 -159
  40. package/dist/tui/AppShell.js +188 -23
  41. package/dist/tui/CollaborationTimelineView.js +327 -0
  42. package/dist/tui/FeatureBoardView.js +227 -0
  43. package/dist/tui/InputBar.js +514 -57
  44. package/dist/tui/RouterDiagnosticsView.js +469 -0
  45. package/dist/tui/StatusBar.js +610 -57
  46. package/dist/tui/TaskSessionsView.js +207 -0
  47. package/dist/tui/TerminalOutput.js +53 -9
  48. package/dist/tui/WorkerOutputView.js +1403 -161
  49. package/dist/tui/WorkerOverviewView.js +250 -0
  50. package/dist/tui/chat-history.js +25 -0
  51. package/dist/tui/chat-input.js +67 -19
  52. package/dist/tui/chat-paste.js +76 -0
  53. package/dist/tui/display-width.js +41 -3
  54. package/dist/tui/incremental-text-file.js +101 -0
  55. package/dist/tui/keyboard.js +46 -0
  56. package/dist/tui/markdown-text.js +14 -0
  57. package/dist/tui/raw-input-decoder.js +3 -0
  58. package/dist/tui/scrolling.js +2 -1
  59. package/dist/tui/status-line.js +318 -11
  60. package/dist/tui/task-memory.js +15 -0
  61. package/dist/tui/task-result.js +105 -0
  62. package/dist/tui/terminal-screen.js +13 -1
  63. package/dist/tui/theme-contrast.js +144 -0
  64. package/dist/tui/theme-preview.js +109 -0
  65. package/dist/tui/theme.js +158 -0
  66. package/dist/version.js +1 -1
  67. package/dist/workers/capabilities.js +212 -0
  68. package/dist/workers/live-probe.js +176 -0
  69. package/dist/workers/mock-adapter.js +39 -6
  70. package/dist/workers/native-attach.js +147 -8
  71. package/dist/workers/native-session-detection.js +17 -0
  72. package/dist/workers/process-adapter.js +580 -81
  73. package/dist/workers/registry.js +4 -2
  74. package/package.json +17 -2
@@ -0,0 +1,911 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { chmod, constants, copyFile, link, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
+ import { ensureDir, pathExists, pathIsDirectory, writeJson } from "../core/file-store.js";
6
+ const MAX_TEXT_MERGE_BYTES = 10 * 1024 * 1024;
7
+ const FEATURE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
8
+ const COMMIT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/;
9
+ const LIVE_COMMIT_PROTOCOL = "atomic-claim-v1";
10
+ export class WorkspaceMergeConflictError extends Error {
11
+ paths;
12
+ conflictDir;
13
+ constructor(paths, conflictDir) {
14
+ const count = paths.length;
15
+ super(`Workspace integration conflict in ${count} ${count === 1 ? "path" : "paths"}: ${paths.join(", ")}. `
16
+ + `The live workspace was not changed. Conflict evidence: ${conflictDir}`);
17
+ this.name = "WorkspaceMergeConflictError";
18
+ this.paths = paths;
19
+ this.conflictDir = conflictDir;
20
+ }
21
+ }
22
+ export class WorkspaceLiveMutationError extends Error {
23
+ paths;
24
+ constructor(paths, context = "before-commit") {
25
+ const count = paths.length;
26
+ super(`Live workspace changed outside orchestration in ${count} ${count === 1 ? "path" : "paths"}: ${paths.join(", ")}. `
27
+ + (context === "pending-commit"
28
+ ? "Pending integration recovery was blocked and its commit intent was preserved."
29
+ : context === "during-commit"
30
+ ? "The integration intent was preserved and the concurrent content was not overwritten."
31
+ : "The isolated wave was not integrated."));
32
+ this.name = "WorkspaceLiveMutationError";
33
+ this.paths = paths;
34
+ }
35
+ }
36
+ export class ParallelWorkspaceManager {
37
+ workspaceRoot;
38
+ taskDir;
39
+ dataRelativePath;
40
+ writeIntegrationCheckpoint;
41
+ removeIntegrationIntent;
42
+ liveCommitHook;
43
+ constructor(options, dependencies = {}) {
44
+ this.workspaceRoot = resolve(options.workspaceRoot);
45
+ this.taskDir = resolve(options.taskDir);
46
+ const candidateDataRoot = resolve(this.workspaceRoot, options.dataDir);
47
+ const dataRoot = isWithin(candidateDataRoot, this.workspaceRoot) ? candidateDataRoot : null;
48
+ this.dataRelativePath = dataRoot ? relative(this.workspaceRoot, dataRoot) : null;
49
+ this.writeIntegrationCheckpoint = dependencies.writeIntegrationCheckpoint ?? writeJson;
50
+ this.removeIntegrationIntent = dependencies.removeIntegrationIntent
51
+ ?? ((path) => rm(path, { force: true }));
52
+ this.liveCommitHook = dependencies.liveCommitHook ?? (async () => undefined);
53
+ }
54
+ async prepareWave(input) {
55
+ const wave = this.describeWave(input);
56
+ await rm(wave.rootDir, { recursive: true, force: true });
57
+ await cloneTree(this.workspaceRoot, wave.baselineDir, (path) => this.excludeFromSource(path));
58
+ await cloneTree(wave.baselineDir, wave.stagingDir);
59
+ for (const featureDir of wave.featureDirs.values()) {
60
+ await cloneTree(wave.baselineDir, featureDir);
61
+ }
62
+ await writeJson(join(wave.rootDir, "workspace.json"), {
63
+ version: 1,
64
+ workspace_root: this.workspaceRoot,
65
+ turn_id: input.turnId,
66
+ wave: input.wave,
67
+ baseline: wave.baselineDir,
68
+ staging: wave.stagingDir,
69
+ integration: wave.integrationDir,
70
+ verification: wave.verificationDir,
71
+ features: Object.fromEntries(wave.featureDirs),
72
+ reviews: Object.fromEntries(wave.reviewDirs)
73
+ });
74
+ return wave;
75
+ }
76
+ async restoreWave(input) {
77
+ const wave = this.describeWave(input);
78
+ const manifest = await readJsonRecord(join(wave.rootDir, "workspace.json"));
79
+ if (manifest?.version !== 1
80
+ || manifest.workspace_root !== this.workspaceRoot
81
+ || manifest.turn_id !== input.turnId
82
+ || manifest.wave !== input.wave
83
+ || !recordKeysEqual(manifest.features, input.featureIds)
84
+ || !(await pathIsDirectory(wave.baselineDir))
85
+ || !(await pathIsDirectory(wave.stagingDir))) {
86
+ return null;
87
+ }
88
+ for (const featureDir of wave.featureDirs.values()) {
89
+ if (!(await pathIsDirectory(featureDir))) {
90
+ return null;
91
+ }
92
+ }
93
+ const pendingCommit = await this.readPendingCommit(wave);
94
+ if (pendingCommit) {
95
+ await this.recoverOwnedCommitArtifacts(wave, pendingCommit);
96
+ await this.assertLiveWorkspaceCommitResumable(wave);
97
+ return wave;
98
+ }
99
+ try {
100
+ await this.assertLiveWorkspaceUnchanged(wave);
101
+ return wave;
102
+ }
103
+ catch (error) {
104
+ if (error instanceof WorkspaceLiveMutationError) {
105
+ return null;
106
+ }
107
+ throw error;
108
+ }
109
+ }
110
+ describeWave(input) {
111
+ if (!Number.isInteger(input.wave) || input.wave < 1) {
112
+ throw new Error(`Workspace wave must be a positive integer: ${input.wave}`);
113
+ }
114
+ if (!/^\d{4,}$/.test(input.turnId)) {
115
+ throw new Error(`Unsafe workspace turn id: ${input.turnId}`);
116
+ }
117
+ if (input.featureIds.length === 0 || new Set(input.featureIds).size !== input.featureIds.length) {
118
+ throw new Error("Workspace wave requires unique feature ids.");
119
+ }
120
+ for (const featureId of input.featureIds) {
121
+ if (!FEATURE_ID_PATTERN.test(featureId)) {
122
+ throw new Error(`Unsafe workspace feature id: ${featureId}`);
123
+ }
124
+ }
125
+ const rootDir = join(this.taskDir, "workspaces", `turn-${input.turnId}`, `wave-${String(input.wave).padStart(4, "0")}`);
126
+ const baselineDir = join(rootDir, "baseline");
127
+ const stagingDir = join(rootDir, "staging");
128
+ const integrationDir = join(rootDir, "integration");
129
+ const verificationDir = join(rootDir, "verification");
130
+ const conflictDir = join(rootDir, "conflicts");
131
+ const featureDirs = new Map();
132
+ const reviewDirs = new Map();
133
+ for (const featureId of input.featureIds) {
134
+ const featureDir = join(rootDir, "features", featureId);
135
+ featureDirs.set(featureId, featureDir);
136
+ reviewDirs.set(featureId, join(rootDir, "reviews", featureId));
137
+ }
138
+ const wave = {
139
+ turnId: input.turnId,
140
+ wave: input.wave,
141
+ rootDir,
142
+ baselineDir,
143
+ stagingDir,
144
+ integrationDir,
145
+ verificationDir,
146
+ conflictDir,
147
+ featureIds: [...input.featureIds],
148
+ featureDirs,
149
+ reviewDirs
150
+ };
151
+ return wave;
152
+ }
153
+ async stageWave(wave) {
154
+ const pendingCommit = await this.readPendingCommit(wave);
155
+ if (pendingCommit) {
156
+ await this.recoverOwnedCommitArtifacts(wave, pendingCommit);
157
+ await this.assertLiveWorkspaceCommitResumable(wave);
158
+ return { changedPaths: pendingCommit.changed_paths };
159
+ }
160
+ await this.assertLiveWorkspaceUnchanged(wave);
161
+ await rm(wave.conflictDir, { recursive: true, force: true });
162
+ await rm(wave.stagingDir, { recursive: true, force: true });
163
+ await rm(wave.integrationDir, { recursive: true, force: true });
164
+ await rm(wave.verificationDir, { recursive: true, force: true });
165
+ await cloneTree(wave.baselineDir, wave.stagingDir);
166
+ for (const featureId of wave.featureIds) {
167
+ const featureDir = wave.featureDirs.get(featureId);
168
+ if (!featureDir) {
169
+ throw new Error(`Feature workspace missing for ${featureId}`);
170
+ }
171
+ const featureConflictDir = join(wave.conflictDir, featureId);
172
+ const plan = await planWorkspaceMerge(wave.baselineDir, featureDir, wave.stagingDir, featureConflictDir, (path) => this.excludeRelativePath(path));
173
+ if (plan.conflicts.length > 0) {
174
+ throw new WorkspaceMergeConflictError(plan.conflicts, featureConflictDir);
175
+ }
176
+ await applyMergePlan(wave.stagingDir, plan);
177
+ }
178
+ await cloneTree(wave.stagingDir, wave.integrationDir);
179
+ const changedPaths = await workspaceChangedPaths(wave.baselineDir, wave.integrationDir, (path) => this.excludeRelativePath(path));
180
+ await this.writeIntegrationCheckpoint(join(wave.rootDir, "integration.json"), {
181
+ version: 1,
182
+ state: "staged",
183
+ turn_id: wave.turnId,
184
+ wave: wave.wave,
185
+ feature_ids: wave.featureIds,
186
+ changed_paths: changedPaths
187
+ });
188
+ return { changedPaths };
189
+ }
190
+ async prepareVerificationWorkspace(wave) {
191
+ await rm(wave.verificationDir, { recursive: true, force: true });
192
+ await cloneTree(wave.integrationDir, wave.verificationDir);
193
+ return wave.verificationDir;
194
+ }
195
+ async prepareFeatureReviewWorkspace(wave, featureId) {
196
+ const featureDir = wave.featureDirs.get(featureId);
197
+ const reviewDir = wave.reviewDirs.get(featureId);
198
+ if (!featureDir || !reviewDir) {
199
+ throw new Error(`Feature review workspace missing for ${featureId}`);
200
+ }
201
+ await rm(reviewDir, { recursive: true, force: true });
202
+ await cloneTree(featureDir, reviewDir);
203
+ return reviewDir;
204
+ }
205
+ async commitWave(wave) {
206
+ const pendingCommit = await this.readPendingCommit(wave);
207
+ if (pendingCommit) {
208
+ await this.recoverOwnedCommitArtifacts(wave, pendingCommit);
209
+ await this.assertLiveWorkspaceCommitResumable(wave);
210
+ }
211
+ else {
212
+ await this.assertLiveWorkspaceUnchanged(wave);
213
+ }
214
+ const liveConflictDir = join(wave.conflictDir, "live-workspace");
215
+ const livePlan = await planWorkspaceMerge(wave.baselineDir, wave.integrationDir, this.workspaceRoot, liveConflictDir, (path) => this.excludeRelativePath(path));
216
+ if (livePlan.conflicts.length > 0) {
217
+ throw new WorkspaceMergeConflictError(livePlan.conflicts, liveConflictDir);
218
+ }
219
+ const changedPaths = pendingCommit?.changed_paths ?? await workspaceChangedPaths(wave.baselineDir, wave.integrationDir, (path) => this.excludeRelativePath(path));
220
+ const pendingPath = this.pendingCommitPath(wave);
221
+ const commitId = pendingCommit?.commit_id ?? randomUUID();
222
+ if (!pendingCommit || pendingCommit.commit_protocol !== LIVE_COMMIT_PROTOCOL) {
223
+ await this.writeIntegrationCheckpoint(pendingPath, {
224
+ version: 1,
225
+ state: "committing",
226
+ turn_id: wave.turnId,
227
+ wave: wave.wave,
228
+ feature_ids: wave.featureIds,
229
+ commit_id: commitId,
230
+ commit_protocol: LIVE_COMMIT_PROTOCOL,
231
+ changed_paths: changedPaths
232
+ });
233
+ }
234
+ await applyMergePlan(this.workspaceRoot, livePlan, commitId, this.liveCommitHook);
235
+ const incompletePaths = await workspaceChangedPaths(wave.integrationDir, this.workspaceRoot, (path) => this.excludeRelativePath(path));
236
+ if (incompletePaths.length > 0) {
237
+ throw new Error(`Live workspace commit verification failed: ${incompletePaths.join(", ")}`);
238
+ }
239
+ await this.writeIntegrationCheckpoint(join(wave.rootDir, "integration.json"), {
240
+ version: 1,
241
+ state: "integrated",
242
+ turn_id: wave.turnId,
243
+ wave: wave.wave,
244
+ feature_ids: wave.featureIds,
245
+ commit_id: commitId,
246
+ commit_protocol: LIVE_COMMIT_PROTOCOL,
247
+ changed_paths: changedPaths
248
+ });
249
+ try {
250
+ await this.removeIntegrationIntent(pendingPath);
251
+ }
252
+ catch {
253
+ // The integrated checkpoint is authoritative; a later retry can remove the redundant intent.
254
+ }
255
+ return { changedPaths };
256
+ }
257
+ async integrateWave(wave) {
258
+ await this.stageWave(wave);
259
+ return this.commitWave(wave);
260
+ }
261
+ async assertLiveWorkspaceUnchanged(wave) {
262
+ const paths = await workspaceChangedPaths(wave.baselineDir, this.workspaceRoot, (path) => this.excludeRelativePath(path));
263
+ if (paths.length > 0) {
264
+ throw new WorkspaceLiveMutationError(paths);
265
+ }
266
+ }
267
+ pendingCommitPath(wave) {
268
+ return join(wave.rootDir, "integration.pending.json");
269
+ }
270
+ async readPendingCommit(wave) {
271
+ const path = this.pendingCommitPath(wave);
272
+ if (!(await pathExists(path))) {
273
+ return null;
274
+ }
275
+ const record = await readJsonRecord(path);
276
+ const featureIds = readStringArray(record?.feature_ids);
277
+ const changedPaths = readStringArray(record?.changed_paths);
278
+ const commitId = typeof record?.commit_id === "string" && COMMIT_ID_PATTERN.test(record.commit_id)
279
+ ? record.commit_id
280
+ : undefined;
281
+ const commitProtocol = record?.commit_protocol === LIVE_COMMIT_PROTOCOL
282
+ ? LIVE_COMMIT_PROTOCOL
283
+ : undefined;
284
+ const expectedChangedPaths = await workspaceChangedPaths(wave.baselineDir, wave.integrationDir, (item) => this.excludeRelativePath(item));
285
+ if (record?.version !== 1
286
+ || record.state !== "committing"
287
+ || record.turn_id !== wave.turnId
288
+ || record.wave !== wave.wave
289
+ || !featureIds
290
+ || !changedPaths
291
+ || (record.commit_id !== undefined && !commitId)
292
+ || (record.commit_protocol !== undefined && !commitProtocol)
293
+ || !sameStrings(featureIds, wave.featureIds)
294
+ || !sameStrings(changedPaths, expectedChangedPaths)) {
295
+ throw new Error(`Invalid integration commit intent: ${path}`);
296
+ }
297
+ return {
298
+ version: 1,
299
+ state: "committing",
300
+ turn_id: wave.turnId,
301
+ wave: wave.wave,
302
+ feature_ids: featureIds,
303
+ ...(commitId ? { commit_id: commitId } : {}),
304
+ ...(commitProtocol ? { commit_protocol: commitProtocol } : {}),
305
+ changed_paths: changedPaths
306
+ };
307
+ }
308
+ async recoverOwnedCommitArtifacts(wave, intent) {
309
+ if (!intent.commit_id) {
310
+ return;
311
+ }
312
+ for (const path of intent.changed_paths) {
313
+ const tempPath = mergeOperationTempPath(this.workspaceRoot, path, intent.commit_id);
314
+ const backupPath = mergeOperationBackupPath(this.workspaceRoot, path, intent.commit_id);
315
+ const [baseline, integration] = await Promise.all([
316
+ inspectEntry(join(wave.baselineDir, path)),
317
+ inspectEntry(join(wave.integrationDir, path))
318
+ ]);
319
+ await recoverOwnedCommitPath({
320
+ path,
321
+ targetPath: join(this.workspaceRoot, path),
322
+ tempPath,
323
+ backupPath,
324
+ baseline,
325
+ integration,
326
+ commitProtocol: intent.commit_protocol
327
+ });
328
+ }
329
+ }
330
+ async assertLiveWorkspaceCommitResumable(wave) {
331
+ const exclude = (path) => this.excludeRelativePath(path);
332
+ const [baseline, integration, live] = await Promise.all([
333
+ workspaceManifest(wave.baselineDir, exclude),
334
+ workspaceManifest(wave.integrationDir, exclude),
335
+ workspaceManifest(this.workspaceRoot, exclude)
336
+ ]);
337
+ const paths = [...new Set([...baseline.keys(), ...integration.keys(), ...live.keys()])].sort();
338
+ const unsafe = paths.filter((path) => {
339
+ const current = live.get(path) ?? missingEntry();
340
+ return !sameEntry(current, baseline.get(path) ?? missingEntry())
341
+ && !sameEntry(current, integration.get(path) ?? missingEntry());
342
+ });
343
+ if (unsafe.length > 0) {
344
+ throw new WorkspaceLiveMutationError(unsafe, "pending-commit");
345
+ }
346
+ }
347
+ excludeFromSource(path) {
348
+ const absolutePath = resolve(path);
349
+ return this.excludeRelativePath(relative(this.workspaceRoot, absolutePath));
350
+ }
351
+ excludeRelativePath(path) {
352
+ const segments = path.split(sep);
353
+ if (segments.includes(".git")) {
354
+ return true;
355
+ }
356
+ return Boolean(this.dataRelativePath
357
+ && (path === this.dataRelativePath || path.startsWith(`${this.dataRelativePath}${sep}`)));
358
+ }
359
+ }
360
+ async function readJsonRecord(path) {
361
+ try {
362
+ const value = JSON.parse(await readFile(path, "utf8"));
363
+ return value !== null && typeof value === "object" && !Array.isArray(value)
364
+ ? value
365
+ : null;
366
+ }
367
+ catch {
368
+ return null;
369
+ }
370
+ }
371
+ function recordKeysEqual(value, expected) {
372
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
373
+ return false;
374
+ }
375
+ const actual = Object.keys(value).sort();
376
+ const wanted = [...expected].sort();
377
+ return actual.length === wanted.length
378
+ && actual.every((key, index) => key === wanted[index]);
379
+ }
380
+ function readStringArray(value) {
381
+ return Array.isArray(value) && value.every((item) => typeof item === "string")
382
+ ? [...value]
383
+ : null;
384
+ }
385
+ function sameStrings(left, right) {
386
+ const actual = [...left].sort();
387
+ const expected = [...right].sort();
388
+ return actual.length === expected.length
389
+ && actual.every((value, index) => value === expected[index]);
390
+ }
391
+ async function workspaceChangedPaths(baselineRoot, incomingRoot, exclude) {
392
+ const [baseline, incoming] = await Promise.all([
393
+ workspaceManifest(baselineRoot, exclude),
394
+ workspaceManifest(incomingRoot, exclude)
395
+ ]);
396
+ return [...new Set([...baseline.keys(), ...incoming.keys()])]
397
+ .filter((path) => !exclude(path))
398
+ .filter((path) => !sameEntry(baseline.get(path) ?? missingEntry(), incoming.get(path) ?? missingEntry()))
399
+ .sort();
400
+ }
401
+ async function cloneTree(sourceRoot, targetRoot, exclude = () => false) {
402
+ const source = resolve(sourceRoot);
403
+ const target = resolve(targetRoot);
404
+ if (exclude(source)) {
405
+ return;
406
+ }
407
+ await mkdir(target, { recursive: true });
408
+ await cloneDirectory(source, target, exclude);
409
+ }
410
+ async function cloneDirectory(sourceDir, targetDir, exclude) {
411
+ const sourceStat = await lstat(sourceDir);
412
+ await chmod(targetDir, sourceStat.mode & 0o777).catch(() => undefined);
413
+ const entries = await readdir(sourceDir, { withFileTypes: true });
414
+ for (const entry of entries) {
415
+ const sourcePath = join(sourceDir, entry.name);
416
+ if (exclude(sourcePath)) {
417
+ continue;
418
+ }
419
+ const targetPath = join(targetDir, entry.name);
420
+ if (entry.isDirectory()) {
421
+ await mkdir(targetPath, { recursive: true });
422
+ await cloneDirectory(sourcePath, targetPath, exclude);
423
+ continue;
424
+ }
425
+ if (entry.isSymbolicLink()) {
426
+ await symlink(await readlink(sourcePath), targetPath);
427
+ continue;
428
+ }
429
+ if (entry.isFile()) {
430
+ const stat = await lstat(sourcePath);
431
+ await copyFile(sourcePath, targetPath, constants.COPYFILE_FICLONE);
432
+ await chmod(targetPath, stat.mode & 0o777);
433
+ }
434
+ }
435
+ }
436
+ async function planWorkspaceMerge(baselineRoot, incomingRoot, targetRoot, conflictDir, exclude = () => false) {
437
+ const [baseline, incoming] = await Promise.all([
438
+ workspaceManifest(baselineRoot, exclude),
439
+ workspaceManifest(incomingRoot, exclude)
440
+ ]);
441
+ const paths = [...new Set([...baseline.keys(), ...incoming.keys()])].sort();
442
+ const operations = [];
443
+ const changedPaths = [];
444
+ const conflicts = [];
445
+ for (const path of paths) {
446
+ if (exclude(path)) {
447
+ continue;
448
+ }
449
+ const baselineEntry = baseline.get(path) ?? missingEntry();
450
+ const incomingEntry = incoming.get(path) ?? missingEntry();
451
+ if (sameEntry(baselineEntry, incomingEntry)) {
452
+ continue;
453
+ }
454
+ const targetPath = join(targetRoot, path);
455
+ const targetEntry = await inspectEntry(targetPath);
456
+ if (sameEntry(targetEntry, incomingEntry)) {
457
+ continue;
458
+ }
459
+ if (sameEntry(targetEntry, baselineEntry)) {
460
+ operations.push({
461
+ path,
462
+ expected: targetEntry,
463
+ incoming: incomingEntry,
464
+ ...(incomingEntry.type !== "missing" ? { sourcePath: join(incomingRoot, path) } : {})
465
+ });
466
+ changedPaths.push(path);
467
+ continue;
468
+ }
469
+ const merged = await tryTextMerge({
470
+ path,
471
+ baselineRoot,
472
+ incomingRoot,
473
+ targetRoot,
474
+ baselineEntry,
475
+ incomingEntry,
476
+ targetEntry,
477
+ conflictDir
478
+ });
479
+ if (merged?.clean) {
480
+ operations.push({
481
+ path,
482
+ expected: targetEntry,
483
+ incoming: {
484
+ type: "file",
485
+ hash: hashBuffer(merged.content),
486
+ mode: targetEntry.type === "file" ? targetEntry.mode : incomingEntry.type === "file" ? incomingEntry.mode : 0o644,
487
+ size: merged.content.length
488
+ },
489
+ content: merged.content
490
+ });
491
+ changedPaths.push(path);
492
+ continue;
493
+ }
494
+ conflicts.push(path);
495
+ if (!merged) {
496
+ await writeJson(join(conflictDir, `${path}.json`), {
497
+ path,
498
+ reason: "binary-or-structural-conflict",
499
+ baseline: describeEntry(baselineEntry),
500
+ target: describeEntry(targetEntry),
501
+ incoming: describeEntry(incomingEntry)
502
+ });
503
+ }
504
+ }
505
+ return {
506
+ operations,
507
+ changedPaths: [...new Set(changedPaths)].sort(),
508
+ conflicts: [...new Set(conflicts)].sort()
509
+ };
510
+ }
511
+ async function applyMergePlan(targetRoot, plan, commitId, liveCommitHook = async () => undefined) {
512
+ const operations = [
513
+ ...plan.operations.filter((item) => item.incoming.type === "missing"),
514
+ ...plan.operations.filter((item) => item.incoming.type !== "missing")
515
+ ];
516
+ for (const operation of operations) {
517
+ if (commitId) {
518
+ await applyCommittedOperation(targetRoot, operation, commitId, liveCommitHook);
519
+ }
520
+ else if (operation.incoming.type === "missing") {
521
+ await rm(join(targetRoot, operation.path), { recursive: true, force: true });
522
+ }
523
+ else {
524
+ await applyOperation(targetRoot, operation);
525
+ }
526
+ }
527
+ }
528
+ async function applyOperation(targetRoot, operation) {
529
+ const targetPath = join(targetRoot, operation.path);
530
+ const tempPath = join(dirname(targetPath), `.${basename(targetPath)}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
531
+ await prepareOperationTemp(targetPath, tempPath, operation);
532
+ await rm(targetPath, { recursive: true, force: true });
533
+ await rename(tempPath, targetPath);
534
+ }
535
+ async function applyCommittedOperation(targetRoot, operation, commitId, liveCommitHook) {
536
+ const targetPath = join(targetRoot, operation.path);
537
+ const tempPath = mergeOperationTempPath(targetRoot, operation.path, commitId);
538
+ const backupPath = mergeOperationBackupPath(targetRoot, operation.path, commitId);
539
+ if (operation.incoming.type !== "missing") {
540
+ await prepareOperationTemp(targetPath, tempPath, operation);
541
+ }
542
+ await liveCommitHook({ path: operation.path, phase: "before-claim" });
543
+ const current = await inspectEntry(targetPath);
544
+ if (!sameEntry(current, operation.expected)) {
545
+ await rm(tempPath, { recursive: true, force: true });
546
+ throw new WorkspaceLiveMutationError([operation.path], "during-commit");
547
+ }
548
+ let claimed = false;
549
+ if (operation.expected.type !== "missing") {
550
+ if ((await inspectEntry(backupPath)).type !== "missing") {
551
+ throw new WorkspaceLiveMutationError([operation.path], "during-commit");
552
+ }
553
+ try {
554
+ await rename(targetPath, backupPath);
555
+ claimed = true;
556
+ }
557
+ catch (error) {
558
+ if (error.code === "ENOENT") {
559
+ throw new WorkspaceLiveMutationError([operation.path], "during-commit");
560
+ }
561
+ throw error;
562
+ }
563
+ const backup = await inspectEntry(backupPath);
564
+ if (!sameEntry(backup, operation.expected)) {
565
+ const restored = await restoreClaimedEntry(targetPath, backupPath, backup);
566
+ if (restored) {
567
+ await rm(tempPath, { recursive: true, force: true });
568
+ }
569
+ throw new WorkspaceLiveMutationError([operation.path], "during-commit");
570
+ }
571
+ }
572
+ await liveCommitHook({ path: operation.path, phase: "before-publish" });
573
+ if ((await inspectEntry(targetPath)).type !== "missing") {
574
+ throw new WorkspaceLiveMutationError([operation.path], "during-commit");
575
+ }
576
+ if (operation.incoming.type !== "missing") {
577
+ try {
578
+ await publishPreparedEntry(tempPath, targetPath, operation.incoming);
579
+ }
580
+ catch (error) {
581
+ if (error.code === "EEXIST") {
582
+ throw new WorkspaceLiveMutationError([operation.path], "during-commit");
583
+ }
584
+ throw error;
585
+ }
586
+ await rm(tempPath, { recursive: true, force: true });
587
+ }
588
+ if (claimed) {
589
+ if (!sameEntry(await inspectEntry(backupPath), operation.expected)) {
590
+ throw new WorkspaceLiveMutationError([operation.path], "during-commit");
591
+ }
592
+ await rm(backupPath, { recursive: true, force: true });
593
+ }
594
+ }
595
+ async function prepareOperationTemp(targetPath, tempPath, operation) {
596
+ await ensureDir(dirname(targetPath));
597
+ await rm(tempPath, { recursive: true, force: true });
598
+ if (operation.incoming.type === "file") {
599
+ if (operation.content) {
600
+ await writeFile(tempPath, operation.content);
601
+ }
602
+ else if (operation.sourcePath) {
603
+ await copyFile(operation.sourcePath, tempPath, constants.COPYFILE_FICLONE);
604
+ }
605
+ else {
606
+ throw new Error(`Missing source for workspace file: ${operation.path}`);
607
+ }
608
+ await chmod(tempPath, operation.incoming.mode);
609
+ }
610
+ else if (operation.incoming.type === "symlink") {
611
+ await symlink(operation.incoming.target, tempPath);
612
+ }
613
+ }
614
+ async function publishPreparedEntry(sourcePath, targetPath, entry) {
615
+ if (entry.type === "file") {
616
+ await link(sourcePath, targetPath);
617
+ }
618
+ else {
619
+ await symlink(await readlink(sourcePath), targetPath);
620
+ }
621
+ }
622
+ async function restoreClaimedEntry(targetPath, backupPath, entry) {
623
+ if (entry.type === "missing" || (await inspectEntry(targetPath)).type !== "missing") {
624
+ return false;
625
+ }
626
+ try {
627
+ await publishPreparedEntry(backupPath, targetPath, entry);
628
+ await rm(backupPath, { recursive: true, force: true });
629
+ return true;
630
+ }
631
+ catch {
632
+ return false;
633
+ }
634
+ }
635
+ function mergeOperationTempPath(targetRoot, path, commitId) {
636
+ const targetPath = join(targetRoot, path);
637
+ return join(dirname(targetPath), `.${basename(targetPath)}.parallel-codex-${commitId}.tmp`);
638
+ }
639
+ function mergeOperationBackupPath(targetRoot, path, commitId) {
640
+ const targetPath = join(targetRoot, path);
641
+ return join(dirname(targetPath), `.${basename(targetPath)}.parallel-codex-${commitId}.backup`);
642
+ }
643
+ async function recoverOwnedCommitPath(input) {
644
+ const [initialTemp, backup, initialLive] = await Promise.all([
645
+ inspectEntry(input.tempPath),
646
+ inspectEntry(input.backupPath),
647
+ inspectEntry(input.targetPath)
648
+ ]);
649
+ let temp = initialTemp;
650
+ let live = initialLive;
651
+ if (temp.type !== "missing" && !sameEntry(temp, input.integration)) {
652
+ throw new Error(`Pending integration temp does not match the integration snapshot: ${input.path}`);
653
+ }
654
+ if (backup.type !== "missing" && !sameEntry(backup, input.baseline)) {
655
+ throw new Error(`Pending integration backup does not match the baseline snapshot: ${input.path}`);
656
+ }
657
+ if (backup.type !== "missing") {
658
+ if (sameEntry(live, input.integration)) {
659
+ await rm(input.tempPath, { recursive: true, force: true });
660
+ await rm(input.backupPath, { recursive: true, force: true });
661
+ return;
662
+ }
663
+ if (sameEntry(live, input.baseline)) {
664
+ await rm(input.backupPath, { recursive: true, force: true });
665
+ }
666
+ else if (live.type === "missing") {
667
+ if (input.integration.type === "missing") {
668
+ await rm(input.backupPath, { recursive: true, force: true });
669
+ return;
670
+ }
671
+ if (temp.type === "missing") {
672
+ throw new Error(`Pending integration replacement is missing: ${input.path}`);
673
+ }
674
+ await publishPendingEntry(input, temp);
675
+ await rm(input.tempPath, { recursive: true, force: true });
676
+ if (!sameEntry(await inspectEntry(input.backupPath), input.baseline)) {
677
+ throw new WorkspaceLiveMutationError([input.path], "pending-commit");
678
+ }
679
+ await rm(input.backupPath, { recursive: true, force: true });
680
+ return;
681
+ }
682
+ else {
683
+ throw new WorkspaceLiveMutationError([input.path], "pending-commit");
684
+ }
685
+ }
686
+ temp = await inspectEntry(input.tempPath);
687
+ if (temp.type === "missing") {
688
+ return;
689
+ }
690
+ live = await inspectEntry(input.targetPath);
691
+ if (sameEntry(live, input.integration)) {
692
+ await rm(input.tempPath, { recursive: true, force: true });
693
+ return;
694
+ }
695
+ if (!sameEntry(live, input.baseline) && live.type !== "missing") {
696
+ throw new WorkspaceLiveMutationError([input.path], "pending-commit");
697
+ }
698
+ if (input.commitProtocol === LIVE_COMMIT_PROTOCOL
699
+ && live.type === "missing"
700
+ && input.baseline.type !== "missing") {
701
+ throw new WorkspaceLiveMutationError([input.path], "pending-commit");
702
+ }
703
+ let claimed = false;
704
+ if (live.type !== "missing") {
705
+ await rename(input.targetPath, input.backupPath);
706
+ claimed = true;
707
+ if (!sameEntry(await inspectEntry(input.backupPath), input.baseline)) {
708
+ throw new WorkspaceLiveMutationError([input.path], "pending-commit");
709
+ }
710
+ }
711
+ await publishPendingEntry(input, temp);
712
+ await rm(input.tempPath, { recursive: true, force: true });
713
+ if (claimed) {
714
+ if (!sameEntry(await inspectEntry(input.backupPath), input.baseline)) {
715
+ throw new WorkspaceLiveMutationError([input.path], "pending-commit");
716
+ }
717
+ await rm(input.backupPath, { recursive: true, force: true });
718
+ }
719
+ }
720
+ async function publishPendingEntry(input, entry) {
721
+ try {
722
+ await publishPreparedEntry(input.tempPath, input.targetPath, entry);
723
+ }
724
+ catch (error) {
725
+ if (error.code === "EEXIST") {
726
+ throw new WorkspaceLiveMutationError([input.path], "pending-commit");
727
+ }
728
+ throw error;
729
+ }
730
+ }
731
+ async function workspaceManifest(root, exclude) {
732
+ const manifest = new Map();
733
+ await visitManifest(resolve(root), resolve(root), manifest, exclude);
734
+ return manifest;
735
+ }
736
+ async function visitManifest(root, current, manifest, exclude) {
737
+ const entries = await readdir(current, { withFileTypes: true });
738
+ for (const entry of entries) {
739
+ const path = join(current, entry.name);
740
+ const relativePath = relative(root, path);
741
+ if (exclude(relativePath)) {
742
+ continue;
743
+ }
744
+ if (entry.isDirectory()) {
745
+ await visitManifest(root, path, manifest, exclude);
746
+ continue;
747
+ }
748
+ const inspected = await inspectEntry(path);
749
+ if (inspected.type !== "missing") {
750
+ manifest.set(relativePath, inspected);
751
+ }
752
+ }
753
+ }
754
+ async function inspectEntry(path) {
755
+ try {
756
+ const stat = await lstat(path);
757
+ if (stat.isSymbolicLink()) {
758
+ return { type: "symlink", target: await readlink(path) };
759
+ }
760
+ if (stat.isFile()) {
761
+ const content = await readFile(path);
762
+ return {
763
+ type: "file",
764
+ hash: hashBuffer(content),
765
+ mode: stat.mode & 0o777,
766
+ size: stat.size
767
+ };
768
+ }
769
+ return missingEntry();
770
+ }
771
+ catch (error) {
772
+ if (["ENOENT", "ENOTDIR"].includes(error.code ?? "")) {
773
+ return missingEntry();
774
+ }
775
+ throw error;
776
+ }
777
+ }
778
+ async function tryTextMerge(input) {
779
+ if (input.baselineEntry.type !== "file"
780
+ || input.incomingEntry.type !== "file"
781
+ || input.targetEntry.type !== "file"
782
+ || input.baselineEntry.size > MAX_TEXT_MERGE_BYTES
783
+ || input.incomingEntry.size > MAX_TEXT_MERGE_BYTES
784
+ || input.targetEntry.size > MAX_TEXT_MERGE_BYTES) {
785
+ return null;
786
+ }
787
+ const paths = {
788
+ baseline: join(input.baselineRoot, input.path),
789
+ incoming: join(input.incomingRoot, input.path),
790
+ target: join(input.targetRoot, input.path)
791
+ };
792
+ const [baseline, incoming, target] = await Promise.all([
793
+ readFile(paths.baseline),
794
+ readFile(paths.incoming),
795
+ readFile(paths.target)
796
+ ]);
797
+ if (containsNull(baseline) || containsNull(incoming) || containsNull(target)) {
798
+ return null;
799
+ }
800
+ const alignedMerge = mergeAlignedText(baseline, target, incoming);
801
+ if (alignedMerge) {
802
+ return { clean: true, content: alignedMerge };
803
+ }
804
+ const result = await runGitMergeFile(paths.target, paths.baseline, paths.incoming);
805
+ if (result.code === 0) {
806
+ return { clean: true, content: result.stdout };
807
+ }
808
+ if (result.code === 1) {
809
+ await writeConflictFile(join(input.conflictDir, input.path), result.stdout);
810
+ return { clean: false, content: result.stdout };
811
+ }
812
+ throw new Error(`git merge-file failed for ${input.path}: ${result.stderr.trim() || `exit ${result.code}`}`);
813
+ }
814
+ async function runGitMergeFile(target, baseline, incoming) {
815
+ return new Promise((resolvePromise, reject) => {
816
+ const child = spawn("git", [
817
+ "merge-file",
818
+ "-p",
819
+ "-L",
820
+ "current",
821
+ "-L",
822
+ "baseline",
823
+ "-L",
824
+ "incoming",
825
+ target,
826
+ baseline,
827
+ incoming
828
+ ], { stdio: ["ignore", "pipe", "pipe"] });
829
+ const stdout = [];
830
+ const stderr = [];
831
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
832
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
833
+ child.on("error", reject);
834
+ child.on("close", (code) => resolvePromise({
835
+ code: code ?? 2,
836
+ stdout: Buffer.concat(stdout),
837
+ stderr: Buffer.concat(stderr).toString("utf8")
838
+ }));
839
+ });
840
+ }
841
+ async function writeConflictFile(path, content) {
842
+ await ensureDir(dirname(path));
843
+ await writeFile(path, content);
844
+ }
845
+ function sameEntry(left, right) {
846
+ if (left.type !== right.type) {
847
+ return false;
848
+ }
849
+ if (left.type === "missing") {
850
+ return true;
851
+ }
852
+ if (left.type === "symlink" && right.type === "symlink") {
853
+ return left.target === right.target;
854
+ }
855
+ return left.type === "file"
856
+ && right.type === "file"
857
+ && left.hash === right.hash
858
+ && left.mode === right.mode;
859
+ }
860
+ function describeEntry(entry) {
861
+ if (entry.type === "missing") {
862
+ return { type: "missing" };
863
+ }
864
+ if (entry.type === "symlink") {
865
+ return { type: "symlink", target: entry.target };
866
+ }
867
+ return { type: "file", hash: entry.hash, mode: entry.mode, size: entry.size };
868
+ }
869
+ function missingEntry() {
870
+ return { type: "missing" };
871
+ }
872
+ function hashBuffer(content) {
873
+ return createHash("sha256").update(content).digest("hex");
874
+ }
875
+ function containsNull(content) {
876
+ return content.includes(0);
877
+ }
878
+ function mergeAlignedText(baseline, target, incoming) {
879
+ const baselineLines = splitLines(baseline.toString("utf8"));
880
+ const targetLines = splitLines(target.toString("utf8"));
881
+ const incomingLines = splitLines(incoming.toString("utf8"));
882
+ if (baselineLines.length !== targetLines.length || baselineLines.length !== incomingLines.length) {
883
+ return null;
884
+ }
885
+ const merged = [];
886
+ for (let index = 0; index < baselineLines.length; index += 1) {
887
+ const baselineLine = baselineLines[index];
888
+ const targetLine = targetLines[index];
889
+ const incomingLine = incomingLines[index];
890
+ if (targetLine === incomingLine) {
891
+ merged.push(targetLine);
892
+ }
893
+ else if (targetLine === baselineLine) {
894
+ merged.push(incomingLine);
895
+ }
896
+ else if (incomingLine === baselineLine) {
897
+ merged.push(targetLine);
898
+ }
899
+ else {
900
+ return null;
901
+ }
902
+ }
903
+ return Buffer.from(merged.join(""), "utf8");
904
+ }
905
+ function splitLines(value) {
906
+ return value.match(/[^\n]*\n|[^\n]+$/g) ?? [];
907
+ }
908
+ function isWithin(path, root) {
909
+ const pathFromRoot = relative(root, path);
910
+ return pathFromRoot === "" || (!pathFromRoot.startsWith(`..${sep}`) && pathFromRoot !== "..");
911
+ }