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
@@ -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
  }
@@ -188,6 +281,7 @@ export async function loadConfig(projectRoot) {
188
281
  return base;
189
282
  }
190
283
  const parsed = parse(await readTextIfExists(file));
284
+ assertObjectSections(parsed);
191
285
  const merged = {
192
286
  ...base,
193
287
  ...parsed,
@@ -197,9 +291,17 @@ export async function loadConfig(projectRoot) {
197
291
  ...(parsed.router ?? {}),
198
292
  codex: {
199
293
  ...base.router.codex,
200
- ...(parsed.router?.codex ?? {})
294
+ ...(parsed.router?.codex ?? {}),
295
+ env: {
296
+ ...base.router.codex.env,
297
+ ...(parsed.router?.codex?.env ?? {})
298
+ }
201
299
  }
202
300
  },
301
+ orchestration: {
302
+ ...base.orchestration,
303
+ ...(parsed.orchestration ?? {})
304
+ },
203
305
  workers: {
204
306
  codex: {
205
307
  ...base.workers.codex,
@@ -212,6 +314,10 @@ export async function loadConfig(projectRoot) {
212
314
  ...(parsed.workers?.codex?.model?.env ?? {})
213
315
  }
214
316
  },
317
+ capabilities: {
318
+ ...base.workers.codex.capabilities,
319
+ ...(parsed.workers?.codex?.capabilities ?? {})
320
+ },
215
321
  nativeSession: {
216
322
  ...base.workers.codex.nativeSession,
217
323
  ...(parsed.workers?.codex?.nativeSession ?? {})
@@ -232,6 +338,10 @@ export async function loadConfig(projectRoot) {
232
338
  ...(parsed.workers?.claude?.model?.env ?? {})
233
339
  }
234
340
  },
341
+ capabilities: {
342
+ ...base.workers.claude.capabilities,
343
+ ...(parsed.workers?.claude?.capabilities ?? {})
344
+ },
235
345
  nativeSession: {
236
346
  ...base.workers.claude.nativeSession,
237
347
  ...(parsed.workers?.claude?.nativeSession ?? {})
@@ -252,6 +362,10 @@ export async function loadConfig(projectRoot) {
252
362
  ...(parsed.workers?.mock?.model?.env ?? {})
253
363
  }
254
364
  },
365
+ capabilities: {
366
+ ...base.workers.mock.capabilities,
367
+ ...(parsed.workers?.mock?.capabilities ?? {})
368
+ },
255
369
  nativeSession: {
256
370
  ...base.workers.mock.nativeSession,
257
371
  ...(parsed.workers?.mock?.nativeSession ?? {})
@@ -286,19 +400,73 @@ export async function loadConfig(projectRoot) {
286
400
  },
287
401
  ui: {
288
402
  ...base.ui,
289
- ...(parsed.ui ?? {})
403
+ ...(parsed.ui ?? {}),
404
+ colors: {
405
+ ...base.ui.colors,
406
+ ...(parsed.ui?.colors ?? {})
407
+ }
290
408
  }
291
409
  };
292
410
  return AppConfigSchema.parse(merged);
293
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
+ }
424
+ function assertObjectSections(parsed) {
425
+ const sections = [
426
+ ["router", parsed.router],
427
+ ["router.codex", parsed.router?.codex],
428
+ ["router.codex.env", parsed.router?.codex?.env],
429
+ ["orchestration", parsed.orchestration],
430
+ ["workers", parsed.workers],
431
+ ["workers.codex", parsed.workers?.codex],
432
+ ["workers.codex.model", parsed.workers?.codex?.model],
433
+ ["workers.codex.model.env", parsed.workers?.codex?.model?.env],
434
+ ["workers.codex.capabilities", parsed.workers?.codex?.capabilities],
435
+ ["workers.codex.nativeSession", parsed.workers?.codex?.nativeSession],
436
+ ["workers.codex.interactive", parsed.workers?.codex?.interactive],
437
+ ["workers.claude", parsed.workers?.claude],
438
+ ["workers.claude.model", parsed.workers?.claude?.model],
439
+ ["workers.claude.model.env", parsed.workers?.claude?.model?.env],
440
+ ["workers.claude.capabilities", parsed.workers?.claude?.capabilities],
441
+ ["workers.claude.nativeSession", parsed.workers?.claude?.nativeSession],
442
+ ["workers.claude.interactive", parsed.workers?.claude?.interactive],
443
+ ["workers.mock", parsed.workers?.mock],
444
+ ["workers.mock.model", parsed.workers?.mock?.model],
445
+ ["workers.mock.model.env", parsed.workers?.mock?.model?.env],
446
+ ["workers.mock.capabilities", parsed.workers?.mock?.capabilities],
447
+ ["workers.mock.nativeSession", parsed.workers?.mock?.nativeSession],
448
+ ["workers.mock.interactive", parsed.workers?.mock?.interactive],
449
+ ["pairing", parsed.pairing],
450
+ ["roles", parsed.roles],
451
+ ["roles.main", parsed.roles?.main],
452
+ ["roles.judge", parsed.roles?.judge],
453
+ ["roles.actor", parsed.roles?.actor],
454
+ ["roles.critic", parsed.roles?.critic],
455
+ ["ui", parsed.ui],
456
+ ["ui.colors", parsed.ui?.colors]
457
+ ];
458
+ for (const [path, value] of sections) {
459
+ if (value !== undefined && !isPlainObject(value)) {
460
+ throw new Error(`Invalid config section [${path}]: expected a table`);
461
+ }
462
+ }
463
+ }
464
+ function isPlainObject(value) {
465
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
466
+ }
294
467
  export async function writeDefaultConfig(projectRoot) {
295
- const config = defaultConfig(projectRoot);
296
- const tomlText = stringify({
297
- router: config.router,
298
- workers: config.workers,
299
- pairing: config.pairing,
300
- roles: config.roles,
301
- ui: config.ui
302
- });
303
- 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");
304
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
  }
@@ -15,6 +18,17 @@ export async function pathExists(path) {
15
18
  throw error;
16
19
  }
17
20
  }
21
+ export async function pathIsDirectory(path) {
22
+ try {
23
+ return (await stat(path)).isDirectory();
24
+ }
25
+ catch (error) {
26
+ if (error.code === "ENOENT") {
27
+ return false;
28
+ }
29
+ throw error;
30
+ }
31
+ }
18
32
  export async function writeJson(path, value) {
19
33
  const dir = dirname(path);
20
34
  const tempPath = join(dir, `.${basename(path)}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
@@ -27,16 +41,120 @@ export async function readJson(path, schema) {
27
41
  return schema.parse(JSON.parse(text));
28
42
  }
29
43
  export async function appendJsonLine(path, value) {
30
- await ensureDir(dirname(path));
31
- await writeFile(path, `${JSON.stringify(value)}\n`, { encoding: "utf8", flag: "a" });
44
+ await appendFile(path, `${JSON.stringify(value)}\n`);
32
45
  }
33
46
  export async function writeText(path, value) {
34
47
  await ensureDir(dirname(path));
35
48
  await writeFile(path, value, "utf8");
36
49
  }
37
50
  export async function appendText(path, value) {
38
- await ensureDir(dirname(path));
39
- 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
+ }
40
158
  }
41
159
  export async function readTextIfExists(path) {
42
160
  if (!(await pathExists(path))) {
@@ -54,3 +172,9 @@ export async function removeIfExists(path) {
54
172
  }
55
173
  }
56
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
  }
@@ -12,6 +13,15 @@ export function formatTaskTimestamp(date) {
12
13
  export function sessionsRoot(projectRoot, dataDir) {
13
14
  return join(projectRoot, dataDir, "sessions");
14
15
  }
16
+ export function routerRuntimeDir(appRoot, dataDir) {
17
+ return join(appRoot, dataDir, "router");
18
+ }
15
19
  export function taskDir(projectRoot, dataDir, taskId) {
20
+ if (!taskSessionIdIsValid(taskId)) {
21
+ throw new Error(`Invalid task session id: ${JSON.stringify(taskId)}`);
22
+ }
16
23
  return join(sessionsRoot(projectRoot, dataDir), taskId);
17
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
+ }