parallel-codex-tui 0.1.3 → 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 (72) hide show
  1. package/.parallel-codex/config.example.toml +90 -3
  2. package/README.md +240 -21
  3. package/dist/bootstrap.js +37 -17
  4. package/dist/cli-args.js +34 -3
  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 +7 -71
  10. package/dist/cli.js +221 -23
  11. package/dist/core/collaboration-timeline.js +261 -0
  12. package/dist/core/config-errors.js +14 -0
  13. package/dist/core/config.js +154 -24
  14. package/dist/core/file-store.js +119 -6
  15. package/dist/core/lease-finalization.js +22 -0
  16. package/dist/core/paths.js +7 -0
  17. package/dist/core/process-identity.js +48 -0
  18. package/dist/core/process-mutation-turn.js +128 -0
  19. package/dist/core/process-ownership.js +276 -0
  20. package/dist/core/process-tree.js +90 -0
  21. package/dist/core/router-audit.js +155 -0
  22. package/dist/core/router-redaction.js +31 -0
  23. package/dist/core/router.js +462 -35
  24. package/dist/core/session-index.js +188 -37
  25. package/dist/core/session-manager.js +1086 -40
  26. package/dist/core/task-state-machine.js +17 -0
  27. package/dist/core/workspace-commit-recovery.js +118 -0
  28. package/dist/core/workspace.js +19 -11
  29. package/dist/doctor.js +343 -23
  30. package/dist/domain/schemas.js +127 -6
  31. package/dist/orchestrator/collaboration-channel.js +255 -4
  32. package/dist/orchestrator/feature-plan.js +70 -0
  33. package/dist/orchestrator/judge-artifacts.js +236 -0
  34. package/dist/orchestrator/orchestrator.js +1749 -202
  35. package/dist/orchestrator/prompts.js +126 -2
  36. package/dist/orchestrator/supervisor-summary.js +56 -2
  37. package/dist/orchestrator/workspace-sandbox.js +911 -0
  38. package/dist/tui/App.js +2830 -153
  39. package/dist/tui/AppShell.js +188 -23
  40. package/dist/tui/CollaborationTimelineView.js +327 -0
  41. package/dist/tui/FeatureBoardView.js +227 -0
  42. package/dist/tui/InputBar.js +514 -57
  43. package/dist/tui/RouterDiagnosticsView.js +469 -0
  44. package/dist/tui/StatusBar.js +610 -57
  45. package/dist/tui/TaskSessionsView.js +207 -0
  46. package/dist/tui/TerminalOutput.js +53 -9
  47. package/dist/tui/WorkerOutputView.js +1403 -161
  48. package/dist/tui/WorkerOverviewView.js +250 -0
  49. package/dist/tui/chat-history.js +25 -0
  50. package/dist/tui/chat-input.js +67 -19
  51. package/dist/tui/chat-paste.js +76 -0
  52. package/dist/tui/display-width.js +41 -3
  53. package/dist/tui/incremental-text-file.js +101 -0
  54. package/dist/tui/keyboard.js +46 -0
  55. package/dist/tui/markdown-text.js +14 -0
  56. package/dist/tui/raw-input-decoder.js +3 -0
  57. package/dist/tui/scrolling.js +2 -1
  58. package/dist/tui/status-line.js +318 -11
  59. package/dist/tui/task-memory.js +13 -1
  60. package/dist/tui/task-result.js +105 -0
  61. package/dist/tui/terminal-screen.js +13 -1
  62. package/dist/tui/theme-contrast.js +144 -0
  63. package/dist/tui/theme-preview.js +109 -0
  64. package/dist/tui/theme.js +158 -0
  65. package/dist/version.js +1 -1
  66. package/dist/workers/capabilities.js +212 -0
  67. package/dist/workers/live-probe.js +176 -0
  68. package/dist/workers/mock-adapter.js +39 -6
  69. package/dist/workers/native-attach.js +78 -3
  70. package/dist/workers/process-adapter.js +570 -77
  71. package/dist/workers/registry.js +4 -2
  72. package/package.json +5 -2
@@ -1,7 +1,9 @@
1
- import { parse, stringify } from "@iarna/toml";
1
+ import { parse } from "@iarna/toml";
2
+ import { readFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import { z } from "zod";
4
5
  import { pathExists, readTextIfExists, writeText } from "./file-store.js";
6
+ import { normalizeTuiThemeColorValue, normalizeTuiThemeName, TUI_THEME_FIELDS, TUI_THEME_NAMES } from "../tui/theme.js";
5
7
  const NativeSessionConfigSchema = z.object({
6
8
  enabled: z.boolean().default(true),
7
9
  resumeArgs: z.array(z.string()).default([]),
@@ -14,6 +16,26 @@ const WorkerModelConfigSchema = z.object({
14
16
  args: z.array(z.string()).default([]),
15
17
  env: z.record(z.string()).default({})
16
18
  });
19
+ const WorkerCapabilitiesConfigSchema = z.object({
20
+ profile: z.enum(["codex", "claude", "generic"]),
21
+ writableDirArgs: z.array(z.string()).default([]),
22
+ freshSessionArgs: z.array(z.string()).default([])
23
+ }).strict().superRefine((value, context) => {
24
+ if (value.writableDirArgs.length > 0 && !value.writableDirArgs.some((arg) => arg.includes("{dir}"))) {
25
+ context.addIssue({
26
+ code: z.ZodIssueCode.custom,
27
+ path: ["writableDirArgs"],
28
+ message: "writableDirArgs must include a {dir} template"
29
+ });
30
+ }
31
+ if (value.freshSessionArgs.length > 0 && !value.freshSessionArgs.some((arg) => arg.includes("{sessionId}"))) {
32
+ context.addIssue({
33
+ code: z.ZodIssueCode.custom,
34
+ path: ["freshSessionArgs"],
35
+ message: "freshSessionArgs must include a {sessionId} template"
36
+ });
37
+ }
38
+ });
17
39
  const InteractiveCommandSchema = z.object({
18
40
  command: z.string().min(1),
19
41
  args: z.array(z.string()).default([])
@@ -22,12 +44,45 @@ const RolePromptConfigSchema = z.object({
22
44
  title: z.string().min(1),
23
45
  instructions: z.array(z.string()).default([])
24
46
  });
47
+ const TuiColorValueSchema = z.string().min(1).refine((value) => normalizeTuiThemeColorValue(value) !== null, {
48
+ message: "Invalid TUI color value. Use a Chalk color name, #rgb/#rrggbb, rgb(r,g,b), or ansi256(0..255)."
49
+ }).transform((value) => normalizeTuiThemeColorValue(value) ?? value.trim());
50
+ const TuiThemeNameSchema = z.preprocess((value) => typeof value === "string" ? normalizeTuiThemeName(value) ?? value.trim() : value, z.enum(TUI_THEME_NAMES)).default("codex");
51
+ const UiColorOverridesSchema = z.object(Object.fromEntries(TUI_THEME_FIELDS.map((field) => [field, TuiColorValueSchema.optional()]))).strict().default({});
25
52
  const CodexRouterConfigSchema = z.object({
26
53
  command: z.string().min(1).default("codex"),
27
- args: z.array(z.string()).default(["exec", "--skip-git-repo-check", "--sandbox", "workspace-write", "--color", "never", "-"]),
28
- timeoutMs: z.number().int().positive().default(120000),
29
- fallback: z.enum(["simple", "complex"]).default("complex")
54
+ args: z.array(z.string()).default([
55
+ "exec",
56
+ "--ephemeral",
57
+ "--ignore-rules",
58
+ "-c",
59
+ "model_reasoning_effort=low",
60
+ "--skip-git-repo-check",
61
+ "--sandbox",
62
+ "read-only",
63
+ "--color",
64
+ "never",
65
+ "-"
66
+ ]),
67
+ timeoutMs: z.number().int().positive().default(30000),
68
+ firstOutputTimeoutMs: z.number().int().positive().default(15000),
69
+ idleTimeoutMs: z.number().int().positive().default(15000),
70
+ maxOutputBytes: z.number().int().min(1024).max(16 * 1024 * 1024).default(1024 * 1024),
71
+ maxAttempts: z.number().int().min(1).max(3).default(2),
72
+ retryDelayMs: z.number().int().min(0).max(10000).default(500),
73
+ followUpTimeoutMs: z.number().int().positive().max(120000).default(20000),
74
+ fallback: z.enum(["simple", "complex"]).default("simple"),
75
+ env: z.record(z.string()).default({})
30
76
  });
77
+ const OrchestrationConfigSchema = z.object({
78
+ maxParallelFeatures: z.number().int().min(1).max(8)
79
+ }).strict();
80
+ const UiConfigSchema = z.object({
81
+ showStatusBar: z.boolean(),
82
+ autoOpenFailedWorker: z.boolean(),
83
+ theme: TuiThemeNameSchema,
84
+ colors: UiColorOverridesSchema
85
+ }).strict();
31
86
  const WorkerCommandSchema = z.object({
32
87
  command: z.string().min(1),
33
88
  args: z.array(z.string()).default([]),
@@ -35,6 +90,7 @@ const WorkerCommandSchema = z.object({
35
90
  idleTimeoutMs: z.number().int().positive().optional(),
36
91
  firstOutputTimeoutMs: z.number().int().positive().optional(),
37
92
  model: WorkerModelConfigSchema.default({}),
93
+ capabilities: WorkerCapabilitiesConfigSchema,
38
94
  nativeSession: NativeSessionConfigSchema,
39
95
  interactive: InteractiveCommandSchema
40
96
  });
@@ -45,6 +101,7 @@ const AppConfigSchema = z.object({
45
101
  defaultMode: z.enum(["auto", "simple", "complex"]),
46
102
  codex: CodexRouterConfigSchema.default({})
47
103
  }),
104
+ orchestration: OrchestrationConfigSchema,
48
105
  workers: z.object({
49
106
  codex: WorkerCommandSchema,
50
107
  claude: WorkerCommandSchema,
@@ -62,10 +119,7 @@ const AppConfigSchema = z.object({
62
119
  actor: RolePromptConfigSchema,
63
120
  critic: RolePromptConfigSchema
64
121
  }),
65
- ui: z.object({
66
- showStatusBar: z.boolean(),
67
- autoOpenFailedWorker: z.boolean()
68
- })
122
+ ui: UiConfigSchema
69
123
  });
70
124
  export function defaultConfig(projectRoot) {
71
125
  return {
@@ -75,11 +129,33 @@ export function defaultConfig(projectRoot) {
75
129
  defaultMode: "auto",
76
130
  codex: {
77
131
  command: "codex",
78
- args: ["exec", "--skip-git-repo-check", "--sandbox", "workspace-write", "--color", "never", "-"],
79
- timeoutMs: 120000,
80
- fallback: "complex"
132
+ args: [
133
+ "exec",
134
+ "--ephemeral",
135
+ "--ignore-rules",
136
+ "-c",
137
+ "model_reasoning_effort=low",
138
+ "--skip-git-repo-check",
139
+ "--sandbox",
140
+ "read-only",
141
+ "--color",
142
+ "never",
143
+ "-"
144
+ ],
145
+ timeoutMs: 30000,
146
+ firstOutputTimeoutMs: 15000,
147
+ idleTimeoutMs: 15000,
148
+ maxOutputBytes: 1024 * 1024,
149
+ maxAttempts: 2,
150
+ retryDelayMs: 500,
151
+ followUpTimeoutMs: 20000,
152
+ fallback: "simple",
153
+ env: {}
81
154
  }
82
155
  },
156
+ orchestration: {
157
+ maxParallelFeatures: 3
158
+ },
83
159
  workers: {
84
160
  codex: {
85
161
  command: "codex",
@@ -93,6 +169,11 @@ export function defaultConfig(projectRoot) {
93
169
  args: [],
94
170
  env: {}
95
171
  },
172
+ capabilities: {
173
+ profile: "codex",
174
+ writableDirArgs: ["--add-dir", "{dir}"],
175
+ freshSessionArgs: []
176
+ },
96
177
  nativeSession: {
97
178
  enabled: true,
98
179
  resumeArgs: ["exec", "resume", "{sessionId}", "--skip-git-repo-check", "-"],
@@ -116,6 +197,11 @@ export function defaultConfig(projectRoot) {
116
197
  args: [],
117
198
  env: {}
118
199
  },
200
+ capabilities: {
201
+ profile: "claude",
202
+ writableDirArgs: ["--add-dir", "{dir}"],
203
+ freshSessionArgs: ["--session-id", "{sessionId}"]
204
+ },
119
205
  nativeSession: {
120
206
  enabled: true,
121
207
  resumeArgs: ["--print", "--resume", "{sessionId}", "--permission-mode", "acceptEdits", "--output-format", "text"],
@@ -136,6 +222,11 @@ export function defaultConfig(projectRoot) {
136
222
  args: [],
137
223
  env: {}
138
224
  },
225
+ capabilities: {
226
+ profile: "generic",
227
+ writableDirArgs: [],
228
+ freshSessionArgs: []
229
+ },
139
230
  nativeSession: {
140
231
  enabled: true,
141
232
  resumeArgs: ["resume", "{sessionId}", "-"],
@@ -174,7 +265,9 @@ export function defaultConfig(projectRoot) {
174
265
  },
175
266
  ui: {
176
267
  showStatusBar: true,
177
- autoOpenFailedWorker: true
268
+ autoOpenFailedWorker: true,
269
+ theme: "codex",
270
+ colors: {}
178
271
  }
179
272
  };
180
273
  }
@@ -198,9 +291,17 @@ export async function loadConfig(projectRoot) {
198
291
  ...(parsed.router ?? {}),
199
292
  codex: {
200
293
  ...base.router.codex,
201
- ...(parsed.router?.codex ?? {})
294
+ ...(parsed.router?.codex ?? {}),
295
+ env: {
296
+ ...base.router.codex.env,
297
+ ...(parsed.router?.codex?.env ?? {})
298
+ }
202
299
  }
203
300
  },
301
+ orchestration: {
302
+ ...base.orchestration,
303
+ ...(parsed.orchestration ?? {})
304
+ },
204
305
  workers: {
205
306
  codex: {
206
307
  ...base.workers.codex,
@@ -213,6 +314,10 @@ export async function loadConfig(projectRoot) {
213
314
  ...(parsed.workers?.codex?.model?.env ?? {})
214
315
  }
215
316
  },
317
+ capabilities: {
318
+ ...base.workers.codex.capabilities,
319
+ ...(parsed.workers?.codex?.capabilities ?? {})
320
+ },
216
321
  nativeSession: {
217
322
  ...base.workers.codex.nativeSession,
218
323
  ...(parsed.workers?.codex?.nativeSession ?? {})
@@ -233,6 +338,10 @@ export async function loadConfig(projectRoot) {
233
338
  ...(parsed.workers?.claude?.model?.env ?? {})
234
339
  }
235
340
  },
341
+ capabilities: {
342
+ ...base.workers.claude.capabilities,
343
+ ...(parsed.workers?.claude?.capabilities ?? {})
344
+ },
236
345
  nativeSession: {
237
346
  ...base.workers.claude.nativeSession,
238
347
  ...(parsed.workers?.claude?.nativeSession ?? {})
@@ -253,6 +362,10 @@ export async function loadConfig(projectRoot) {
253
362
  ...(parsed.workers?.mock?.model?.env ?? {})
254
363
  }
255
364
  },
365
+ capabilities: {
366
+ ...base.workers.mock.capabilities,
367
+ ...(parsed.workers?.mock?.capabilities ?? {})
368
+ },
256
369
  nativeSession: {
257
370
  ...base.workers.mock.nativeSession,
258
371
  ...(parsed.workers?.mock?.nativeSession ?? {})
@@ -287,29 +400,50 @@ export async function loadConfig(projectRoot) {
287
400
  },
288
401
  ui: {
289
402
  ...base.ui,
290
- ...(parsed.ui ?? {})
403
+ ...(parsed.ui ?? {}),
404
+ colors: {
405
+ ...base.ui.colors,
406
+ ...(parsed.ui?.colors ?? {})
407
+ }
291
408
  }
292
409
  };
293
410
  return AppConfigSchema.parse(merged);
294
411
  }
412
+ export function withUiThemeOverride(config, theme) {
413
+ if (!theme) {
414
+ return config;
415
+ }
416
+ return {
417
+ ...config,
418
+ ui: {
419
+ ...config.ui,
420
+ theme
421
+ }
422
+ };
423
+ }
295
424
  function assertObjectSections(parsed) {
296
425
  const sections = [
297
426
  ["router", parsed.router],
298
427
  ["router.codex", parsed.router?.codex],
428
+ ["router.codex.env", parsed.router?.codex?.env],
429
+ ["orchestration", parsed.orchestration],
299
430
  ["workers", parsed.workers],
300
431
  ["workers.codex", parsed.workers?.codex],
301
432
  ["workers.codex.model", parsed.workers?.codex?.model],
302
433
  ["workers.codex.model.env", parsed.workers?.codex?.model?.env],
434
+ ["workers.codex.capabilities", parsed.workers?.codex?.capabilities],
303
435
  ["workers.codex.nativeSession", parsed.workers?.codex?.nativeSession],
304
436
  ["workers.codex.interactive", parsed.workers?.codex?.interactive],
305
437
  ["workers.claude", parsed.workers?.claude],
306
438
  ["workers.claude.model", parsed.workers?.claude?.model],
307
439
  ["workers.claude.model.env", parsed.workers?.claude?.model?.env],
440
+ ["workers.claude.capabilities", parsed.workers?.claude?.capabilities],
308
441
  ["workers.claude.nativeSession", parsed.workers?.claude?.nativeSession],
309
442
  ["workers.claude.interactive", parsed.workers?.claude?.interactive],
310
443
  ["workers.mock", parsed.workers?.mock],
311
444
  ["workers.mock.model", parsed.workers?.mock?.model],
312
445
  ["workers.mock.model.env", parsed.workers?.mock?.model?.env],
446
+ ["workers.mock.capabilities", parsed.workers?.mock?.capabilities],
313
447
  ["workers.mock.nativeSession", parsed.workers?.mock?.nativeSession],
314
448
  ["workers.mock.interactive", parsed.workers?.mock?.interactive],
315
449
  ["pairing", parsed.pairing],
@@ -318,7 +452,8 @@ function assertObjectSections(parsed) {
318
452
  ["roles.judge", parsed.roles?.judge],
319
453
  ["roles.actor", parsed.roles?.actor],
320
454
  ["roles.critic", parsed.roles?.critic],
321
- ["ui", parsed.ui]
455
+ ["ui", parsed.ui],
456
+ ["ui.colors", parsed.ui?.colors]
322
457
  ];
323
458
  for (const [path, value] of sections) {
324
459
  if (value !== undefined && !isPlainObject(value)) {
@@ -330,13 +465,8 @@ function isPlainObject(value) {
330
465
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
331
466
  }
332
467
  export async function writeDefaultConfig(projectRoot) {
333
- const config = defaultConfig(projectRoot);
334
- const tomlText = stringify({
335
- router: config.router,
336
- workers: config.workers,
337
- pairing: config.pairing,
338
- roles: config.roles,
339
- ui: config.ui
340
- });
341
- await writeText(configPath(projectRoot), tomlText);
468
+ await writeText(configPath(projectRoot), await readExampleConfig());
469
+ }
470
+ async function readExampleConfig() {
471
+ return readFile(new URL("../../.parallel-codex/config.example.toml", import.meta.url), "utf8");
342
472
  }
@@ -1,5 +1,8 @@
1
- import { mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
2
- import { basename, dirname, join } from "node:path";
1
+ import { mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ const appendQueues = new Map();
4
+ const defaultRecentJsonLineChunkBytes = 64 * 1024;
5
+ const defaultRecentJsonLineMaxBytes = 2 * 1024 * 1024;
3
6
  export async function ensureDir(path) {
4
7
  await mkdir(path, { recursive: true });
5
8
  }
@@ -38,16 +41,120 @@ export async function readJson(path, schema) {
38
41
  return schema.parse(JSON.parse(text));
39
42
  }
40
43
  export async function appendJsonLine(path, value) {
41
- await ensureDir(dirname(path));
42
- await writeFile(path, `${JSON.stringify(value)}\n`, { encoding: "utf8", flag: "a" });
44
+ await appendFile(path, `${JSON.stringify(value)}\n`);
43
45
  }
44
46
  export async function writeText(path, value) {
45
47
  await ensureDir(dirname(path));
46
48
  await writeFile(path, value, "utf8");
47
49
  }
48
50
  export async function appendText(path, value) {
49
- await ensureDir(dirname(path));
50
- await writeFile(path, value, { encoding: "utf8", flag: "a" });
51
+ await appendFile(path, value);
52
+ }
53
+ export async function readRecentJsonLines(path, schema, limit, options = {}) {
54
+ const targetLimit = Number.isFinite(limit)
55
+ ? Math.min(10000, Math.max(0, Math.trunc(limit)))
56
+ : 10000;
57
+ if (targetLimit === 0) {
58
+ return [];
59
+ }
60
+ const chunkBytes = boundedPositiveInteger(options.chunkBytes, defaultRecentJsonLineChunkBytes, 4 * 1024 * 1024);
61
+ const maxLineBytes = boundedPositiveInteger(options.maxLineBytes, defaultRecentJsonLineMaxBytes, 16 * 1024 * 1024);
62
+ let handle;
63
+ try {
64
+ handle = await open(path, "r");
65
+ }
66
+ catch (error) {
67
+ if (error.code === "ENOENT") {
68
+ return [];
69
+ }
70
+ throw error;
71
+ }
72
+ try {
73
+ let position = (await handle.stat()).size;
74
+ let lineSegments = [];
75
+ let lineBytes = 0;
76
+ let lineTooLong = false;
77
+ const newestFirst = [];
78
+ const addLineSegment = (segment) => {
79
+ if (segment.length === 0 || lineTooLong) {
80
+ return;
81
+ }
82
+ lineBytes += segment.length;
83
+ if (lineBytes > maxLineBytes) {
84
+ lineSegments = [];
85
+ lineTooLong = true;
86
+ return;
87
+ }
88
+ lineSegments.push(Buffer.from(segment));
89
+ };
90
+ const finishLine = () => {
91
+ if (!lineTooLong && lineBytes > 0) {
92
+ const line = Buffer.concat([...lineSegments].reverse(), lineBytes).toString("utf8").trim();
93
+ if (line) {
94
+ try {
95
+ const parsed = schema.safeParse(JSON.parse(line));
96
+ if (parsed.success) {
97
+ newestFirst.push(parsed.data);
98
+ }
99
+ }
100
+ catch {
101
+ // Invalid or partial rows do not hide earlier valid records.
102
+ }
103
+ }
104
+ }
105
+ lineSegments = [];
106
+ lineBytes = 0;
107
+ lineTooLong = false;
108
+ };
109
+ while (position > 0 && newestFirst.length < targetLimit) {
110
+ const requested = Math.min(chunkBytes, position);
111
+ position -= requested;
112
+ const buffer = Buffer.allocUnsafe(requested);
113
+ const { bytesRead } = await handle.read(buffer, 0, requested, position);
114
+ if (bytesRead === 0) {
115
+ continue;
116
+ }
117
+ let segmentEnd = bytesRead;
118
+ for (let index = bytesRead - 1; index >= 0; index -= 1) {
119
+ if (buffer[index] !== 0x0a) {
120
+ continue;
121
+ }
122
+ addLineSegment(buffer.subarray(index + 1, segmentEnd));
123
+ finishLine();
124
+ segmentEnd = index;
125
+ if (newestFirst.length >= targetLimit) {
126
+ break;
127
+ }
128
+ }
129
+ if (newestFirst.length < targetLimit) {
130
+ addLineSegment(buffer.subarray(0, segmentEnd));
131
+ }
132
+ }
133
+ if (position === 0 && newestFirst.length < targetLimit && (lineBytes > 0 || lineTooLong)) {
134
+ finishLine();
135
+ }
136
+ return newestFirst.reverse();
137
+ }
138
+ finally {
139
+ await handle.close();
140
+ }
141
+ }
142
+ async function appendFile(path, value) {
143
+ const key = resolve(path);
144
+ const previous = appendQueues.get(key) ?? Promise.resolve();
145
+ const operation = previous.catch(() => undefined).then(async () => {
146
+ await ensureDir(dirname(path));
147
+ await writeFile(path, value, { encoding: "utf8", flag: "a" });
148
+ });
149
+ appendQueues.set(key, operation);
150
+ try {
151
+ await operation;
152
+ }
153
+ finally {
154
+ if (appendQueues.get(key) === operation) {
155
+ appendQueues.delete(key);
156
+ }
157
+ }
51
158
  }
52
159
  export async function readTextIfExists(path) {
53
160
  if (!(await pathExists(path))) {
@@ -65,3 +172,9 @@ export async function removeIfExists(path) {
65
172
  }
66
173
  }
67
174
  }
175
+ function boundedPositiveInteger(value, fallback, maximum) {
176
+ if (typeof value !== "number" || !Number.isFinite(value)) {
177
+ return fallback;
178
+ }
179
+ return Math.min(maximum, Math.max(1, Math.trunc(value)));
180
+ }
@@ -0,0 +1,22 @@
1
+ export async function runWithLeaseFinalization(subject, lease, run) {
2
+ const outcome = await run().then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
3
+ try {
4
+ await lease.release();
5
+ }
6
+ catch (releaseError) {
7
+ const releaseSummary = `${subject} lease release failed: ${errorMessage(releaseError)}`;
8
+ if (!outcome.ok) {
9
+ throw new Error(`${errorMessage(outcome.error)}; ${releaseSummary}`, {
10
+ cause: new AggregateError([outcome.error, releaseError])
11
+ });
12
+ }
13
+ throw new Error(releaseSummary, { cause: releaseError });
14
+ }
15
+ if (!outcome.ok) {
16
+ throw outcome.error;
17
+ }
18
+ return outcome.value;
19
+ }
20
+ function errorMessage(error) {
21
+ return error instanceof Error ? error.message : String(error);
22
+ }
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import { TaskSessionIdSchema } from "../domain/schemas.js";
2
3
  function pad(value) {
3
4
  return String(value).padStart(2, "0");
4
5
  }
@@ -16,5 +17,11 @@ export function routerRuntimeDir(appRoot, dataDir) {
16
17
  return join(appRoot, dataDir, "router");
17
18
  }
18
19
  export function taskDir(projectRoot, dataDir, taskId) {
20
+ if (!taskSessionIdIsValid(taskId)) {
21
+ throw new Error(`Invalid task session id: ${JSON.stringify(taskId)}`);
22
+ }
19
23
  return join(sessionsRoot(projectRoot, dataDir), taskId);
20
24
  }
25
+ export function taskSessionIdIsValid(taskId) {
26
+ return TaskSessionIdSchema.safeParse(taskId).success;
27
+ }
@@ -0,0 +1,48 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { promisify } from "node:util";
4
+ const execFileAsync = promisify(execFile);
5
+ export function processIsAlive(pid) {
6
+ if (!Number.isInteger(pid) || pid <= 0) {
7
+ return false;
8
+ }
9
+ try {
10
+ process.kill(pid, 0);
11
+ return true;
12
+ }
13
+ catch (error) {
14
+ return error.code === "EPERM";
15
+ }
16
+ }
17
+ export async function readProcessStartToken(pid) {
18
+ if (!processIsAlive(pid)) {
19
+ return null;
20
+ }
21
+ if (process.platform === "linux") {
22
+ try {
23
+ const stat = await readFile(`/proc/${pid}/stat`, "utf8");
24
+ const fields = stat.slice(stat.lastIndexOf(") ") + 2).trim().split(/\s+/);
25
+ const startTick = fields[19];
26
+ if (startTick) {
27
+ return `linux:${startTick}`;
28
+ }
29
+ }
30
+ catch {
31
+ // Fall through to ps when procfs is unavailable.
32
+ }
33
+ }
34
+ if (process.platform !== "win32") {
35
+ try {
36
+ const result = await execFileAsync("ps", ["-o", "lstart=", "-p", String(pid)], {
37
+ encoding: "utf8",
38
+ timeout: 1000
39
+ });
40
+ const value = String(result.stdout).trim().replace(/\s+/g, " ");
41
+ return value ? `ps:${value}` : null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ return null;
48
+ }
@@ -0,0 +1,128 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { ensureDir, readJson, removeIfExists, writeJson } from "./file-store.js";
6
+ import { processIsAlive, readProcessStartToken } from "./process-identity.js";
7
+ const ProcessMutationIntentSchema = z.object({
8
+ version: z.literal(1),
9
+ intent_id: z.string().min(1),
10
+ pid: z.number().int().positive(),
11
+ created_at: z.string().datetime(),
12
+ choosing: z.boolean(),
13
+ ticket: z.number().int().nonnegative(),
14
+ process_start_token: z.string().min(1).optional()
15
+ });
16
+ let currentProcessStartToken = null;
17
+ export async function acquireProcessMutationTurn(directory, options) {
18
+ validateIntentPrefix(options.intentPrefix);
19
+ await ensureDir(directory);
20
+ const identity = await currentMutationIdentity();
21
+ const intentId = randomUUID();
22
+ const path = join(directory, `${options.intentPrefix}${intentId}.json`);
23
+ let intent = ProcessMutationIntentSchema.parse({
24
+ version: 1,
25
+ intent_id: intentId,
26
+ pid: identity.pid,
27
+ created_at: new Date().toISOString(),
28
+ choosing: true,
29
+ ticket: 0,
30
+ ...(identity.process_start_token ? { process_start_token: identity.process_start_token } : {})
31
+ });
32
+ await writeJson(path, intent);
33
+ try {
34
+ const existing = await readActiveIntents(directory, options.intentPrefix);
35
+ intent = {
36
+ ...intent,
37
+ choosing: false,
38
+ ticket: Math.max(0, ...existing.map((candidate) => candidate.ticket)) + 1
39
+ };
40
+ await writeJson(path, intent);
41
+ const deadline = Date.now() + (options.timeoutMs ?? 5000);
42
+ while (true) {
43
+ const candidates = await readActiveIntents(directory, options.intentPrefix);
44
+ const blocked = candidates.some((candidate) => (candidate.intent_id !== intent.intent_id
45
+ && (candidate.choosing || intentPrecedes(candidate, intent))));
46
+ if (!blocked) {
47
+ let released = false;
48
+ return {
49
+ release: async () => {
50
+ if (released) {
51
+ return;
52
+ }
53
+ released = true;
54
+ await removeIfExists(path);
55
+ }
56
+ };
57
+ }
58
+ if (Date.now() >= deadline) {
59
+ throw new Error(options.timeoutMessage);
60
+ }
61
+ await delay(options.pollMs ?? 5);
62
+ }
63
+ }
64
+ catch (error) {
65
+ await removeIfExists(path);
66
+ throw error;
67
+ }
68
+ }
69
+ async function currentMutationIdentity() {
70
+ currentProcessStartToken ??= readProcessStartToken(process.pid);
71
+ const processStartToken = await currentProcessStartToken;
72
+ return {
73
+ pid: process.pid,
74
+ ...(processStartToken ? { process_start_token: processStartToken } : {})
75
+ };
76
+ }
77
+ async function readActiveIntents(directory, prefix) {
78
+ const names = await readdir(directory);
79
+ const tokenReads = new Map();
80
+ const active = [];
81
+ for (const name of names) {
82
+ if (!name.startsWith(prefix) || !name.endsWith(".json")) {
83
+ continue;
84
+ }
85
+ const path = join(directory, name);
86
+ const intent = await readValidIntent(path);
87
+ if (!intent || !processIsAlive(intent.pid)) {
88
+ await removeIfExists(path);
89
+ continue;
90
+ }
91
+ if (intent.process_start_token) {
92
+ let tokenRead = tokenReads.get(intent.pid);
93
+ if (!tokenRead) {
94
+ tokenRead = readProcessStartToken(intent.pid);
95
+ tokenReads.set(intent.pid, tokenRead);
96
+ }
97
+ const currentToken = await tokenRead;
98
+ if (!currentToken || currentToken !== intent.process_start_token) {
99
+ await removeIfExists(path);
100
+ continue;
101
+ }
102
+ }
103
+ active.push(intent);
104
+ }
105
+ return active;
106
+ }
107
+ async function readValidIntent(path) {
108
+ try {
109
+ return await readJson(path, ProcessMutationIntentSchema);
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ }
115
+ function intentPrecedes(candidate, current) {
116
+ if (candidate.ticket !== current.ticket) {
117
+ return candidate.ticket < current.ticket;
118
+ }
119
+ return candidate.intent_id < current.intent_id;
120
+ }
121
+ function validateIntentPrefix(prefix) {
122
+ if (!prefix.startsWith(".") || prefix.includes("/") || prefix.includes("\\")) {
123
+ throw new Error(`Invalid process mutation intent prefix: ${prefix}`);
124
+ }
125
+ }
126
+ async function delay(milliseconds) {
127
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
128
+ }