parallel-codex-tui 0.1.3 → 0.1.5

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 (78) hide show
  1. package/.parallel-codex/config.example.toml +136 -3
  2. package/README.md +299 -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-preflight.js +18 -0
  7. package/dist/cli-startup-recovery.js +82 -0
  8. package/dist/cli-workspace-picker.js +330 -0
  9. package/dist/cli-workspace-transition.js +33 -0
  10. package/dist/cli-workspace.js +7 -71
  11. package/dist/cli.js +234 -24
  12. package/dist/core/collaboration-timeline.js +268 -0
  13. package/dist/core/config-errors.js +14 -0
  14. package/dist/core/config.js +297 -109
  15. package/dist/core/file-store.js +119 -6
  16. package/dist/core/lease-finalization.js +22 -0
  17. package/dist/core/paths.js +7 -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 +462 -35
  25. package/dist/core/session-index.js +412 -88
  26. package/dist/core/session-manager.js +1110 -40
  27. package/dist/core/task-session-details.js +175 -0
  28. package/dist/core/task-state-machine.js +18 -0
  29. package/dist/core/workspace-commit-recovery.js +118 -0
  30. package/dist/core/workspace.js +19 -11
  31. package/dist/doctor.js +373 -34
  32. package/dist/domain/schemas.js +142 -7
  33. package/dist/orchestrator/collaboration-channel.js +289 -6
  34. package/dist/orchestrator/feature-plan.js +70 -0
  35. package/dist/orchestrator/final-acceptance.js +86 -0
  36. package/dist/orchestrator/judge-artifacts.js +236 -0
  37. package/dist/orchestrator/orchestrator.js +2086 -203
  38. package/dist/orchestrator/prompts.js +168 -5
  39. package/dist/orchestrator/supervisor-summary.js +56 -2
  40. package/dist/orchestrator/workspace-sandbox.js +927 -0
  41. package/dist/tui/App.js +3187 -161
  42. package/dist/tui/AppShell.js +196 -25
  43. package/dist/tui/CollaborationTimelineView.js +327 -0
  44. package/dist/tui/FeatureBoardView.js +232 -0
  45. package/dist/tui/InputBar.js +581 -57
  46. package/dist/tui/RouterDiagnosticsView.js +469 -0
  47. package/dist/tui/StatusBar.js +610 -57
  48. package/dist/tui/StatusDetailView.js +164 -0
  49. package/dist/tui/TaskSessionDetailView.js +222 -0
  50. package/dist/tui/TaskSessionsView.js +210 -0
  51. package/dist/tui/TerminalOutput.js +53 -9
  52. package/dist/tui/WorkerOutputView.js +1404 -161
  53. package/dist/tui/WorkerOverviewView.js +269 -0
  54. package/dist/tui/chat-history.js +25 -0
  55. package/dist/tui/chat-input.js +67 -19
  56. package/dist/tui/chat-paste.js +76 -0
  57. package/dist/tui/display-width.js +41 -3
  58. package/dist/tui/incremental-text-file.js +101 -0
  59. package/dist/tui/keyboard.js +49 -0
  60. package/dist/tui/markdown-text.js +14 -0
  61. package/dist/tui/raw-input-decoder.js +3 -0
  62. package/dist/tui/scrolling.js +2 -1
  63. package/dist/tui/status-line.js +360 -11
  64. package/dist/tui/task-memory.js +13 -1
  65. package/dist/tui/task-result.js +105 -0
  66. package/dist/tui/terminal-screen.js +13 -1
  67. package/dist/tui/theme-contrast.js +144 -0
  68. package/dist/tui/theme-preview.js +109 -0
  69. package/dist/tui/theme.js +158 -0
  70. package/dist/version.js +1 -1
  71. package/dist/workers/capabilities.js +213 -0
  72. package/dist/workers/live-probe.js +177 -0
  73. package/dist/workers/mock-adapter.js +73 -8
  74. package/dist/workers/native-attach.js +106 -16
  75. package/dist/workers/process-adapter.js +572 -77
  76. package/dist/workers/provider.js +26 -0
  77. package/dist/workers/registry.js +12 -20
  78. package/package.json +11 -2
@@ -1,6 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { StringDecoder } from "node:string_decoder";
2
4
  import { appendText, writeJson } from "../core/file-store.js";
5
+ import { clearWorkerProcessRecord, writeWorkerProcessRecord } from "../core/process-ownership.js";
6
+ import { terminateProcessTree } from "../core/process-tree.js";
3
7
  import { detectNativeSessionId } from "./native-session-detection.js";
8
+ const WORKER_DIAGNOSTIC_TAIL_CHARS = 64 * 1024;
9
+ const RESUME_DETECTION_OVERLAP_CHARS = 512;
10
+ const RESUME_REASON_CHARS = 2048;
4
11
  export class ProcessWorkerAdapter {
5
12
  name;
6
13
  command;
@@ -14,7 +21,8 @@ export class ProcessWorkerAdapter {
14
21
  }
15
22
  async run(spec) {
16
23
  const model = spec.modelConfig ?? this.defaults.model;
17
- const launch = buildLaunch(this.args, spec.nativeSession, spec.nativeSessionConfig, model);
24
+ const capabilities = resolveWorkerCapabilities(this.name, this.defaults.capabilities);
25
+ const launch = buildLaunch(this.args, spec.nativeSession, spec.nativeSessionConfig, model, capabilities, spec.writableDirs, spec.enforceWorkspaceIsolation);
18
26
  const runSpec = {
19
27
  ...spec,
20
28
  timeoutMs: spec.timeoutMs ?? this.defaults.timeoutMs,
@@ -23,7 +31,9 @@ export class ProcessWorkerAdapter {
23
31
  nativeSession: launch.nativeSession,
24
32
  modelConfig: model
25
33
  };
26
- const first = await this.runAttempt(runSpec, launch);
34
+ const first = await this.runAttempt(runSpec, launch, {
35
+ initialNativeSessionId: launch.initialNativeSessionId
36
+ });
27
37
  if (!shouldFallbackToNewNativeSession(first, runSpec.nativeSessionConfig)) {
28
38
  return first.result;
29
39
  }
@@ -32,20 +42,32 @@ export class ProcessWorkerAdapter {
32
42
  await runSpec.onNativeSessionRetired?.(retiredSessionId, first.output);
33
43
  }
34
44
  await appendText(runSpec.outputLogPath, `\nNative resume for ${retiredSessionId ?? "unknown session"} is unrecoverable; starting a fresh native session.\n`);
35
- const freshLaunch = buildFreshLaunch(this.args, model);
45
+ const freshLaunch = buildFreshLaunch(this.args, model, capabilities, runSpec.writableDirs, runSpec.enforceWorkspaceIsolation, runSpec.nativeSessionConfig);
36
46
  return (await this.runAttempt({
37
47
  ...runSpec,
38
48
  nativeSession: null
39
49
  }, freshLaunch, {
40
- initialNativeSessionId: undefined,
50
+ initialNativeSessionId: freshLaunch.initialNativeSessionId,
41
51
  startPhase: "native-resume-fallback",
42
52
  startSummary: `${this.command} starting fresh session after unrecoverable native resume`
43
53
  })).result;
44
54
  }
45
55
  async runAttempt(runSpec, launch, options = {}) {
56
+ if (runSpec.signal?.aborted) {
57
+ const result = {
58
+ workerId: runSpec.workerId,
59
+ exitCode: 130,
60
+ signal: "SIGTERM",
61
+ cancelled: true
62
+ };
63
+ await setStatus(runSpec, "cancelled", "process-cancelled", `${this.command} cancelled before start`);
64
+ await appendText(runSpec.outputLogPath, "Process cancelled by user before start\n");
65
+ return { result, output: "", launch };
66
+ }
46
67
  await setStatus(runSpec, "starting", options.startPhase ?? "process-starting", options.startSummary ?? `Starting ${this.command}`);
47
68
  await appendText(runSpec.outputLogPath, `$ ${formatShellCommand(this.command, launch.args)}\n`);
48
69
  return new Promise((resolve, reject) => {
70
+ const detached = process.platform !== "win32";
49
71
  const child = spawn(this.command, launch.args, {
50
72
  cwd: runSpec.cwd,
51
73
  env: {
@@ -55,102 +77,354 @@ export class ProcessWorkerAdapter {
55
77
  PARALLEL_CODEX_ROLE: runSpec.role,
56
78
  PARALLEL_CODEX_FILES_DIR: runSpec.filesDir
57
79
  },
58
- stdio: ["pipe", "pipe", "pipe"]
80
+ stdio: ["pipe", "pipe", "pipe"],
81
+ detached
59
82
  });
83
+ let processRecordError;
84
+ const processRecordReady = typeof child.pid === "number"
85
+ ? writeWorkerProcessRecord(runSpec.filesDir, {
86
+ workerId: runSpec.workerId,
87
+ pid: child.pid,
88
+ command: this.command,
89
+ ...(detached ? { processGroupId: child.pid } : {})
90
+ }).then(() => undefined, (error) => {
91
+ processRecordError = error;
92
+ })
93
+ : Promise.resolve();
60
94
  let settled = false;
95
+ let finishing = false;
61
96
  let timeout;
62
97
  let idleTimeout;
63
98
  let firstOutputTimeout;
99
+ let processTreeCleanup;
100
+ let abortListener;
64
101
  let terminalPhase;
65
102
  let terminalSummary;
103
+ let terminalState;
66
104
  let outputWrites = Promise.resolve();
105
+ let persistenceError;
106
+ let hasPersistenceError = false;
67
107
  let detectedNativeSessionId = options.initialNativeSessionId ?? runSpec.nativeSession?.session_id;
108
+ let initialNativeSessionPersisted = !options.initialNativeSessionId;
109
+ const persistedNativeSessionId = () => (initialNativeSessionPersisted ? detectedNativeSessionId : undefined);
110
+ let stdoutSessionDetectionTail = "";
111
+ let stderrSessionDetectionTail = "";
68
112
  let sawOutput = false;
69
- const outputChunks = [];
70
- const finish = async (result) => {
71
- if (settled) {
72
- return;
73
- }
74
- settled = true;
113
+ let outputTail = "";
114
+ let stdoutResumeDetectionTail = "";
115
+ let stderrResumeDetectionTail = "";
116
+ let unrecoverableResumeReason;
117
+ const stdoutDecoder = new StringDecoder("utf8");
118
+ const stderrDecoder = new StringDecoder("utf8");
119
+ let outputDecodersEnded = false;
120
+ const clearRunTimers = () => {
75
121
  if (timeout) {
76
122
  clearTimeout(timeout);
123
+ timeout = undefined;
77
124
  }
78
125
  if (idleTimeout) {
79
126
  clearTimeout(idleTimeout);
127
+ idleTimeout = undefined;
80
128
  }
81
129
  if (firstOutputTimeout) {
82
130
  clearTimeout(firstOutputTimeout);
131
+ firstOutputTimeout = undefined;
83
132
  }
84
- await outputWrites;
85
- const phase = terminalPhase ?? (launch.isResume && result.exitCode !== 0 ? "native-resume-failed" : "process-exited");
86
- const summary = terminalSummary ??
87
- (launch.isResume && result.exitCode !== 0
88
- ? `${this.command} native resume exited with code ${result.exitCode}`
89
- : `${this.command} exited with code ${result.exitCode}`);
90
- await setStatus(runSpec, result.exitCode === 0 ? "done" : "failed", phase, summary, detectedNativeSessionId);
91
- resolve({
92
- result,
93
- output: outputChunks.join(""),
94
- launch
133
+ };
134
+ const ensureProcessTreeStopped = () => {
135
+ processTreeCleanup ??= terminateProcessTree(child, {
136
+ processGroup: detached,
137
+ label: `${this.command} worker process`,
138
+ termGraceMs: 1500,
139
+ killWaitMs: 500,
140
+ pollMs: 20
95
141
  });
142
+ return processTreeCleanup;
143
+ };
144
+ const terminalFallbackResult = () => ({
145
+ workerId: runSpec.workerId,
146
+ exitCode: terminalState === "cancelled" ? 130 : 1,
147
+ signal: null,
148
+ ...(terminalState === "cancelled" ? { cancelled: true } : {})
149
+ });
150
+ const finishAfterCleanupFailure = (error) => {
151
+ void finish(terminalFallbackResult(), error);
152
+ };
153
+ const failForPersistence = (error) => {
154
+ if (!hasPersistenceError) {
155
+ hasPersistenceError = true;
156
+ persistenceError = error;
157
+ }
158
+ if (settled || terminalState) {
159
+ return;
160
+ }
161
+ terminalState = "failed";
162
+ terminalPhase = "process-finalization-error";
163
+ terminalSummary = `${this.command} persistence failed: ${errorMessage(error)}`;
164
+ clearRunTimers();
165
+ void ensureProcessTreeStopped().catch(finishAfterCleanupFailure);
166
+ };
167
+ const queuePersistence = (operation) => {
168
+ outputWrites = outputWrites.then(async () => {
169
+ if (hasPersistenceError) {
170
+ return;
171
+ }
172
+ try {
173
+ await operation();
174
+ }
175
+ catch (error) {
176
+ failForPersistence(error);
177
+ }
178
+ });
179
+ };
180
+ const recordDecodedOutput = (text, stream) => {
181
+ if (!text || settled || finishing) {
182
+ return;
183
+ }
184
+ outputTail = appendBoundedTextTail(outputTail, text, WORKER_DIAGNOSTIC_TAIL_CHARS);
185
+ if (launch.isResume && !unrecoverableResumeReason) {
186
+ const detectionTail = stream === "stdout" ? stdoutResumeDetectionTail : stderrResumeDetectionTail;
187
+ const detectionText = `${detectionTail}${text}`;
188
+ unrecoverableResumeReason = findUnrecoverableNativeResumeReason(detectionText);
189
+ if (stream === "stdout") {
190
+ stdoutResumeDetectionTail = detectionText.slice(-RESUME_DETECTION_OVERLAP_CHARS);
191
+ }
192
+ else {
193
+ stderrResumeDetectionTail = detectionText.slice(-RESUME_DETECTION_OVERLAP_CHARS);
194
+ }
195
+ }
196
+ queuePersistence(async () => {
197
+ await appendText(runSpec.outputLogPath, text);
198
+ if (!initialNativeSessionPersisted && options.initialNativeSessionId) {
199
+ await runSpec.onNativeSession?.(options.initialNativeSessionId);
200
+ initialNativeSessionPersisted = true;
201
+ }
202
+ if (!detectedNativeSessionId && runSpec.nativeSessionConfig?.detectSessionId !== false) {
203
+ const detectionTail = stream === "stdout" ? stdoutSessionDetectionTail : stderrSessionDetectionTail;
204
+ const detectionText = `${detectionTail}${text}`;
205
+ const sessionId = detectNativeSessionId(detectionText);
206
+ if (sessionId) {
207
+ detectedNativeSessionId = sessionId;
208
+ stdoutSessionDetectionTail = "";
209
+ stderrSessionDetectionTail = "";
210
+ await runSpec.onNativeSession?.(sessionId);
211
+ }
212
+ else if (stream === "stdout") {
213
+ stdoutSessionDetectionTail = detectionText.slice(-512);
214
+ }
215
+ else {
216
+ stderrSessionDetectionTail = detectionText.slice(-512);
217
+ }
218
+ }
219
+ if (!settled && !terminalState) {
220
+ await setStatus(runSpec, "running", "process-output", summarizeOutput(text), persistedNativeSessionId());
221
+ }
222
+ });
223
+ };
224
+ const endOutputDecoders = () => {
225
+ if (outputDecodersEnded) {
226
+ return;
227
+ }
228
+ outputDecodersEnded = true;
229
+ recordDecodedOutput(stdoutDecoder.end(), "stdout");
230
+ recordDecodedOutput(stderrDecoder.end(), "stderr");
231
+ };
232
+ const failAndTerminate = (phase, summary, logLine) => {
233
+ if (settled || terminalState) {
234
+ return;
235
+ }
236
+ terminalState = "failed";
237
+ terminalPhase = phase;
238
+ terminalSummary = summary;
239
+ clearRunTimers();
240
+ queuePersistence(async () => {
241
+ await appendText(runSpec.outputLogPath, logLine);
242
+ await setStatus(runSpec, "running", "process-stopping", `${summary}; stopping process tree`, persistedNativeSessionId());
243
+ });
244
+ void ensureProcessTreeStopped().catch(finishAfterCleanupFailure);
245
+ };
246
+ const failForProcessOwnership = () => {
247
+ if (!processRecordError || settled || terminalState) {
248
+ return;
249
+ }
250
+ const detail = processRecordError instanceof Error
251
+ ? processRecordError.message
252
+ : String(processRecordError);
253
+ failAndTerminate("process-ownership-error", `${this.command} process ownership could not be recorded: ${detail}`, `\nProcess ownership record failed: ${detail}\n`);
254
+ };
255
+ const rejectForFinalization = async (error) => {
256
+ settled = true;
257
+ const detail = errorMessage(error);
258
+ const phase = "process-finalization-error";
259
+ const summary = `${this.command} worker finalization failed: ${detail}`;
260
+ try {
261
+ await appendText(runSpec.outputLogPath, `\nWorker finalization failed: ${detail}\n`);
262
+ }
263
+ catch {
264
+ // Preserve the original finalization error.
265
+ }
266
+ try {
267
+ await setStatus(runSpec, "failed", phase, summary, persistedNativeSessionId());
268
+ }
269
+ catch {
270
+ // The ownership record remains the recovery authority when status cannot be written.
271
+ }
272
+ reject(new Error(summary, { cause: error }));
273
+ };
274
+ const finish = async (result, knownCleanupError) => {
275
+ if (settled || finishing) {
276
+ return;
277
+ }
278
+ endOutputDecoders();
279
+ finishing = true;
280
+ try {
281
+ clearRunTimers();
282
+ if (abortListener) {
283
+ runSpec.signal?.removeEventListener("abort", abortListener);
284
+ abortListener = undefined;
285
+ }
286
+ await processRecordReady;
287
+ failForProcessOwnership();
288
+ let cleanupError = knownCleanupError;
289
+ if (!cleanupError) {
290
+ try {
291
+ await ensureProcessTreeStopped();
292
+ }
293
+ catch (error) {
294
+ cleanupError = error;
295
+ }
296
+ }
297
+ settled = true;
298
+ await outputWrites;
299
+ if (cleanupError) {
300
+ const detail = errorMessage(cleanupError);
301
+ const phase = "process-cleanup-error";
302
+ const summary = `${this.command} process tree cleanup failed: ${detail}`;
303
+ await appendText(runSpec.outputLogPath, `\nProcess tree cleanup failed: ${detail}\n`);
304
+ await setStatus(runSpec, "failed", phase, summary, persistedNativeSessionId());
305
+ resolve({
306
+ result: {
307
+ workerId: runSpec.workerId,
308
+ exitCode: result.exitCode || 1,
309
+ signal: result.signal,
310
+ failure: { phase, summary }
311
+ },
312
+ output: unrecoverableResumeReason ?? outputTail,
313
+ launch
314
+ });
315
+ return;
316
+ }
317
+ if (hasPersistenceError) {
318
+ throw persistenceError;
319
+ }
320
+ const phase = terminalPhase ?? (launch.isResume && result.exitCode !== 0 ? "native-resume-failed" : "process-exited");
321
+ const summary = terminalSummary ??
322
+ (launch.isResume && result.exitCode !== 0
323
+ ? `${this.command} native resume exited with code ${result.exitCode}`
324
+ : `${this.command} exited with code ${result.exitCode}`);
325
+ await setStatus(runSpec, terminalState ?? (result.exitCode === 0 ? "done" : "failed"), phase, summary, persistedNativeSessionId());
326
+ if (!processRecordError) {
327
+ await clearWorkerProcessRecord(runSpec.filesDir);
328
+ }
329
+ const finalResult = {
330
+ ...result,
331
+ ...(terminalState === "cancelled" ? { cancelled: true } : {}),
332
+ ...(terminalState === "failed"
333
+ ? { failure: { phase, summary } }
334
+ : {})
335
+ };
336
+ resolve({
337
+ result: finalResult,
338
+ output: unrecoverableResumeReason ?? outputTail,
339
+ launch
340
+ });
341
+ }
342
+ catch (error) {
343
+ await rejectForFinalization(error);
344
+ }
96
345
  };
97
346
  const resetIdleTimeout = () => {
98
- if (!runSpec.idleTimeoutMs || runSpec.idleTimeoutMs <= 0 || settled) {
347
+ if (!runSpec.idleTimeoutMs || runSpec.idleTimeoutMs <= 0 || settled || terminalState) {
348
+ return;
349
+ }
350
+ if (!sawOutput) {
351
+ return;
352
+ }
353
+ if (runSpec.timeoutMs
354
+ && runSpec.timeoutMs > 0
355
+ && runSpec.idleTimeoutMs >= runSpec.timeoutMs) {
99
356
  return;
100
357
  }
101
358
  if (idleTimeout) {
102
359
  clearTimeout(idleTimeout);
103
360
  }
104
361
  idleTimeout = setTimeout(() => {
105
- terminalPhase = "process-idle-timeout";
106
- terminalSummary = `${this.command} produced no output for ${runSpec.idleTimeoutMs}ms`;
107
- void appendText(runSpec.outputLogPath, `\nProcess idle timed out after ${runSpec.idleTimeoutMs}ms\n`);
108
- void setStatus(runSpec, "failed", terminalPhase, terminalSummary);
109
- child.kill("SIGTERM");
362
+ failAndTerminate("process-idle-timeout", `${this.command} produced no output for ${runSpec.idleTimeoutMs}ms`, `\nProcess idle timed out after ${runSpec.idleTimeoutMs}ms\n`);
110
363
  }, runSpec.idleTimeoutMs);
111
364
  };
112
- const recordOutput = (chunk) => {
365
+ const recordOutput = (chunk, decoder, stream) => {
366
+ if (settled || finishing) {
367
+ return;
368
+ }
113
369
  sawOutput = true;
114
370
  if (firstOutputTimeout) {
115
371
  clearTimeout(firstOutputTimeout);
116
372
  firstOutputTimeout = undefined;
117
373
  }
118
- const text = chunk.toString("utf8");
119
- outputChunks.push(text);
120
- outputWrites = outputWrites.then(async () => {
121
- await appendText(runSpec.outputLogPath, text);
122
- const sessionId = detectNativeSessionId(text);
123
- if (sessionId && sessionId !== detectedNativeSessionId && runSpec.nativeSessionConfig?.detectSessionId !== false) {
124
- detectedNativeSessionId = sessionId;
125
- await runSpec.onNativeSession?.(sessionId);
126
- }
127
- if (!settled) {
128
- await setStatus(runSpec, "running", "process-output", summarizeOutput(text), detectedNativeSessionId);
129
- }
130
- });
374
+ recordDecodedOutput(decoder.write(chunk), stream);
131
375
  resetIdleTimeout();
132
376
  };
133
377
  child.stdout.on("data", (chunk) => {
134
- recordOutput(chunk);
378
+ recordOutput(chunk, stdoutDecoder, "stdout");
135
379
  });
136
380
  child.stderr.on("data", (chunk) => {
137
- recordOutput(chunk);
381
+ recordOutput(chunk, stderrDecoder, "stderr");
138
382
  });
139
383
  child.on("error", (error) => {
140
384
  if (settled) {
141
385
  return;
142
386
  }
143
- settled = true;
144
- if (timeout) {
145
- clearTimeout(timeout);
387
+ if (terminalState) {
388
+ void finish({
389
+ workerId: runSpec.workerId,
390
+ exitCode: terminalState === "cancelled" ? 130 : 1,
391
+ signal: null,
392
+ ...(terminalState === "cancelled" ? { cancelled: true } : {})
393
+ });
394
+ return;
146
395
  }
147
- if (idleTimeout) {
148
- clearTimeout(idleTimeout);
396
+ if (runSpec.signal?.aborted) {
397
+ terminalState = "cancelled";
398
+ terminalPhase = "process-cancelled";
399
+ terminalSummary = `${this.command} cancelled by user`;
400
+ void finish({
401
+ workerId: runSpec.workerId,
402
+ exitCode: 130,
403
+ signal: "SIGTERM",
404
+ cancelled: true
405
+ });
406
+ return;
149
407
  }
150
- if (firstOutputTimeout) {
151
- clearTimeout(firstOutputTimeout);
408
+ endOutputDecoders();
409
+ settled = true;
410
+ clearRunTimers();
411
+ if (abortListener) {
412
+ runSpec.signal?.removeEventListener("abort", abortListener);
152
413
  }
153
- void setStatus(runSpec, "failed", "process-error", error.message, detectedNativeSessionId).finally(() => reject(error));
414
+ void (async () => {
415
+ try {
416
+ await processRecordReady;
417
+ await ensureProcessTreeStopped();
418
+ await setStatus(runSpec, "failed", "process-error", error.message, persistedNativeSessionId());
419
+ if (!processRecordError) {
420
+ await clearWorkerProcessRecord(runSpec.filesDir);
421
+ }
422
+ reject(error);
423
+ }
424
+ catch (finalizationError) {
425
+ await rejectForFinalization(finalizationError);
426
+ }
427
+ })();
154
428
  });
155
429
  child.on("close", (code, signal) => {
156
430
  void finish({
@@ -159,53 +433,225 @@ export class ProcessWorkerAdapter {
159
433
  signal
160
434
  });
161
435
  });
436
+ child.stdin.once("error", (error) => {
437
+ failAndTerminate("process-input-error", `${this.command} input failed: ${error.message}`, `\nProcess input failed: ${error.message}\n`);
438
+ });
439
+ abortListener = () => {
440
+ if (settled || terminalState) {
441
+ return;
442
+ }
443
+ terminalState = "cancelled";
444
+ terminalPhase = "process-cancelled";
445
+ terminalSummary = `${this.command} cancelled by user`;
446
+ clearRunTimers();
447
+ queuePersistence(async () => {
448
+ await appendText(runSpec.outputLogPath, "\nProcess cancelled by user\n");
449
+ await setStatus(runSpec, "running", "process-stopping", `${terminalSummary}; stopping process tree`, persistedNativeSessionId());
450
+ });
451
+ void ensureProcessTreeStopped().catch(finishAfterCleanupFailure);
452
+ };
453
+ runSpec.signal?.addEventListener("abort", abortListener, { once: true });
162
454
  if (runSpec.timeoutMs && runSpec.timeoutMs > 0) {
163
455
  timeout = setTimeout(() => {
164
- terminalPhase = "process-timeout";
165
- terminalSummary = `${this.command} exceeded ${runSpec.timeoutMs}ms`;
166
- void setStatus(runSpec, "failed", terminalPhase, terminalSummary, detectedNativeSessionId);
167
- child.kill("SIGTERM");
168
- void appendText(runSpec.outputLogPath, `\nProcess timed out after ${runSpec.timeoutMs}ms\n`);
456
+ failAndTerminate("process-timeout", `${this.command} exceeded ${runSpec.timeoutMs}ms`, `\nProcess timed out after ${runSpec.timeoutMs}ms\n`);
169
457
  }, runSpec.timeoutMs);
170
458
  }
171
- if (runSpec.firstOutputTimeoutMs && runSpec.firstOutputTimeoutMs > 0) {
459
+ if (runSpec.firstOutputTimeoutMs
460
+ && runSpec.firstOutputTimeoutMs > 0
461
+ && (!runSpec.timeoutMs || runSpec.timeoutMs <= 0 || runSpec.firstOutputTimeoutMs < runSpec.timeoutMs)) {
172
462
  firstOutputTimeout = setTimeout(() => {
173
463
  if (sawOutput || settled) {
174
464
  return;
175
465
  }
176
- terminalPhase = "process-first-output-timeout";
177
- terminalSummary = `${this.command} produced no first output for ${runSpec.firstOutputTimeoutMs}ms`;
178
- void setStatus(runSpec, "failed", terminalPhase, terminalSummary, detectedNativeSessionId);
179
- child.kill("SIGTERM");
180
- void appendText(runSpec.outputLogPath, `\nProcess produced no first output after ${runSpec.firstOutputTimeoutMs}ms\n`);
466
+ failAndTerminate("process-first-output-timeout", `${this.command} produced no first output for ${runSpec.firstOutputTimeoutMs}ms`, `\nProcess produced no first output after ${runSpec.firstOutputTimeoutMs}ms\n`);
181
467
  }, runSpec.firstOutputTimeoutMs);
182
468
  }
183
- void setStatus(runSpec, "running", "process-running", `${this.command} running`, detectedNativeSessionId);
184
- resetIdleTimeout();
185
- child.stdin.write(runSpec.prompt);
186
- child.stdin.end();
469
+ void processRecordReady.then(() => {
470
+ failForProcessOwnership();
471
+ if (processRecordError || settled || finishing || terminalState) {
472
+ return;
473
+ }
474
+ if (runSpec.signal?.aborted) {
475
+ abortListener?.();
476
+ }
477
+ else {
478
+ child.stdin.end(runSpec.prompt);
479
+ }
480
+ });
187
481
  });
188
482
  }
189
483
  }
190
- function buildLaunch(defaultArgs, nativeSession, nativeSessionConfig, modelConfig) {
484
+ function buildLaunch(defaultArgs, nativeSession, nativeSessionConfig, modelConfig, capabilities, writableDirs, enforceWorkspaceIsolation = false) {
191
485
  const modelArgs = buildModelArgs(modelConfig);
192
486
  if (!nativeSession || !nativeSessionConfig?.enabled || nativeSessionConfig.resumeArgs.length === 0) {
193
- return buildFreshLaunch(defaultArgs, modelConfig);
487
+ return buildFreshLaunch(defaultArgs, modelConfig, capabilities, writableDirs, enforceWorkspaceIsolation, nativeSessionConfig);
194
488
  }
195
489
  return {
196
- args: [
490
+ args: withWritableDirectoryArgs(enforceWorkerIsolationArgs([
197
491
  ...nativeSessionConfig.resumeArgs.map((arg) => renderTemplate(arg, nativeSession.session_id, modelConfig)),
198
492
  ...modelArgs
199
- ],
493
+ ], capabilities.profile, enforceWorkspaceIsolation), capabilities, true, writableDirs),
200
494
  isResume: true,
201
495
  nativeSession
202
496
  };
203
497
  }
204
- function buildFreshLaunch(defaultArgs, modelConfig) {
498
+ function buildFreshLaunch(defaultArgs, modelConfig, capabilities, writableDirs, enforceWorkspaceIsolation = false, nativeSessionConfig) {
499
+ const isolatedArgs = enforceWorkerIsolationArgs([...defaultArgs, ...buildModelArgs(modelConfig)], capabilities.profile, enforceWorkspaceIsolation);
500
+ const freshSession = withFreshSessionId(isolatedArgs, capabilities, nativeSessionConfig, modelConfig);
205
501
  return {
206
- args: [...defaultArgs, ...buildModelArgs(modelConfig)],
502
+ args: withWritableDirectoryArgs(freshSession.args, capabilities, false, writableDirs),
207
503
  isResume: false,
208
- nativeSession: null
504
+ nativeSession: null,
505
+ ...(freshSession.sessionId ? { initialNativeSessionId: freshSession.sessionId } : {})
506
+ };
507
+ }
508
+ function withFreshSessionId(args, capabilities, nativeSessionConfig, modelConfig) {
509
+ if (!nativeSessionConfig?.enabled
510
+ || nativeSessionConfig.detectSessionId === false
511
+ || nativeSessionConfig.resumeArgs.length === 0
512
+ || capabilities.freshSessionArgs.length === 0) {
513
+ return { args };
514
+ }
515
+ for (let index = 0; index < args.length; index += 1) {
516
+ const arg = args[index] ?? "";
517
+ if (arg === "--session-id") {
518
+ const sessionId = args[index + 1]?.trim();
519
+ return sessionId ? { args, sessionId } : { args };
520
+ }
521
+ const match = arg.match(/^--session-id=(.+)$/);
522
+ if (match?.[1]?.trim()) {
523
+ return { args, sessionId: match[1].trim() };
524
+ }
525
+ }
526
+ const sessionId = randomUUID();
527
+ return {
528
+ args: [
529
+ ...args,
530
+ ...capabilities.freshSessionArgs.map((arg) => renderTemplate(arg, sessionId, modelConfig))
531
+ ],
532
+ sessionId
533
+ };
534
+ }
535
+ function enforceWorkerIsolationArgs(args, profile, enforce) {
536
+ if (!enforce) {
537
+ return args;
538
+ }
539
+ if (profile === "codex") {
540
+ return enforceCodexWorkspaceSandbox(args);
541
+ }
542
+ if (profile === "claude") {
543
+ return enforceClaudeEditPermissions(args);
544
+ }
545
+ return args;
546
+ }
547
+ function enforceCodexWorkspaceSandbox(args) {
548
+ const result = [];
549
+ let hasSandbox = false;
550
+ for (let index = 0; index < args.length; index += 1) {
551
+ const arg = args[index] ?? "";
552
+ if (arg === "--dangerously-bypass-approvals-and-sandbox") {
553
+ continue;
554
+ }
555
+ if (arg === "--sandbox" || arg === "-s") {
556
+ result.push(arg, "workspace-write");
557
+ hasSandbox = true;
558
+ index += 1;
559
+ continue;
560
+ }
561
+ if (arg.startsWith("--sandbox=") || arg.startsWith("-s=")) {
562
+ result.push(arg.startsWith("-s=") ? "-s=workspace-write" : "--sandbox=workspace-write");
563
+ hasSandbox = true;
564
+ continue;
565
+ }
566
+ result.push(arg);
567
+ }
568
+ if (!hasSandbox) {
569
+ const execIndex = result.indexOf("exec");
570
+ const promptIndex = result.lastIndexOf("-");
571
+ const insertAt = execIndex >= 0 ? execIndex + 1 : promptIndex >= 0 ? promptIndex : result.length;
572
+ result.splice(insertAt, 0, "--sandbox", "workspace-write");
573
+ }
574
+ return result;
575
+ }
576
+ function enforceClaudeEditPermissions(args) {
577
+ const result = [];
578
+ let hasPermissionMode = false;
579
+ for (let index = 0; index < args.length; index += 1) {
580
+ const arg = args[index] ?? "";
581
+ if (arg === "--dangerously-skip-permissions") {
582
+ continue;
583
+ }
584
+ if (arg === "--permission-mode") {
585
+ result.push(arg, "acceptEdits");
586
+ hasPermissionMode = true;
587
+ index += 1;
588
+ continue;
589
+ }
590
+ if (arg.startsWith("--permission-mode=")) {
591
+ result.push("--permission-mode=acceptEdits");
592
+ hasPermissionMode = true;
593
+ continue;
594
+ }
595
+ result.push(arg);
596
+ }
597
+ if (!hasPermissionMode) {
598
+ result.push("--permission-mode", "acceptEdits");
599
+ }
600
+ return result;
601
+ }
602
+ function withWritableDirectoryArgs(args, capabilities, isResume, writableDirs) {
603
+ const directories = [...new Set((writableDirs ?? []).filter(Boolean))];
604
+ if (directories.length === 0 || capabilities.writableDirArgs.length === 0) {
605
+ return args;
606
+ }
607
+ const directoryArgs = directories.flatMap((directory) => (capabilities.writableDirArgs.map((arg) => arg.replaceAll("{dir}", directory))));
608
+ if (capabilities.profile !== "codex") {
609
+ return [...args, ...directoryArgs];
610
+ }
611
+ if (isResume) {
612
+ const resumeIndex = args.indexOf("resume");
613
+ const execIndex = args.lastIndexOf("exec", resumeIndex);
614
+ if (resumeIndex < 0 || execIndex < 0) {
615
+ return args;
616
+ }
617
+ return [
618
+ ...args.slice(0, resumeIndex),
619
+ ...directoryArgs,
620
+ ...args.slice(resumeIndex)
621
+ ];
622
+ }
623
+ const promptIndex = args.lastIndexOf("-");
624
+ if (promptIndex < 0) {
625
+ return [...args, ...directoryArgs];
626
+ }
627
+ return [
628
+ ...args.slice(0, promptIndex),
629
+ ...directoryArgs,
630
+ ...args.slice(promptIndex)
631
+ ];
632
+ }
633
+ function resolveWorkerCapabilities(engine, configured) {
634
+ if (configured) {
635
+ return configured;
636
+ }
637
+ if (engine === "codex") {
638
+ return {
639
+ profile: "codex",
640
+ writableDirArgs: ["--add-dir", "{dir}"],
641
+ freshSessionArgs: []
642
+ };
643
+ }
644
+ if (engine === "claude") {
645
+ return {
646
+ profile: "claude",
647
+ writableDirArgs: ["--add-dir", "{dir}"],
648
+ freshSessionArgs: ["--session-id", "{sessionId}"]
649
+ };
650
+ }
651
+ return {
652
+ profile: "generic",
653
+ writableDirArgs: [],
654
+ freshSessionArgs: []
209
655
  };
210
656
  }
211
657
  function buildModelArgs(modelConfig) {
@@ -221,8 +667,13 @@ function buildModelEnv(modelConfig) {
221
667
  return Object.fromEntries(Object.entries(modelConfig.env).map(([key, value]) => [key, renderTemplate(value, undefined, modelConfig)]));
222
668
  }
223
669
  function shouldFallbackToNewNativeSession(attempt, nativeSessionConfig) {
670
+ if (attempt.result.failure?.phase === "process-cleanup-error"
671
+ || attempt.result.failure?.phase === "process-ownership-error") {
672
+ return false;
673
+ }
224
674
  return (attempt.launch.isResume &&
225
- attempt.result.exitCode !== 0 &&
675
+ !attempt.result.cancelled &&
676
+ (Boolean(attempt.result.failure) || attempt.result.exitCode !== 0) &&
226
677
  nativeSessionConfig?.fallback === "new" &&
227
678
  isUnrecoverableNativeResumeOutput(attempt.output));
228
679
  }
@@ -233,6 +684,33 @@ function isUnrecoverableNativeResumeOutput(output) {
233
684
  normalized.includes("clear earlier history") ||
234
685
  normalized.includes("start a new thread"));
235
686
  }
687
+ function findUnrecoverableNativeResumeReason(output) {
688
+ const normalized = output.toLowerCase();
689
+ const matchIndex = [
690
+ "context window",
691
+ "ran out of room",
692
+ "clear earlier history",
693
+ "start a new thread"
694
+ ].reduce((earliest, phrase) => {
695
+ const index = normalized.indexOf(phrase);
696
+ return index < 0 || (earliest >= 0 && earliest <= index) ? earliest : index;
697
+ }, -1);
698
+ if (matchIndex < 0) {
699
+ return undefined;
700
+ }
701
+ const lineStart = output.lastIndexOf("\n", matchIndex - 1) + 1;
702
+ const nextLine = output.indexOf("\n", matchIndex);
703
+ const lineEnd = nextLine < 0 ? output.length : nextLine;
704
+ const contextStart = Math.max(lineStart, matchIndex - 256);
705
+ const contextEnd = Math.min(lineEnd, contextStart + RESUME_REASON_CHARS);
706
+ return output.slice(contextStart, contextEnd).trim() || "Native session context is exhausted";
707
+ }
708
+ function appendBoundedTextTail(current, chunk, limit) {
709
+ if (chunk.length >= limit) {
710
+ return chunk.slice(-limit);
711
+ }
712
+ return `${current.slice(-(limit - chunk.length))}${chunk}`;
713
+ }
236
714
  function renderTemplate(value, sessionId, modelConfig) {
237
715
  return value
238
716
  .replaceAll("{sessionId}", sessionId ?? "")
@@ -257,15 +735,32 @@ function summarizeOutput(text) {
257
735
  const summary = lines.at(-1) ?? "Worker produced output";
258
736
  return summary.length > 160 ? `${summary.slice(0, 157)}...` : summary;
259
737
  }
738
+ function errorMessage(error) {
739
+ return error instanceof Error ? error.message : String(error);
740
+ }
260
741
  async function setStatus(spec, state, phase, summary, nativeSessionId) {
261
- await writeJson(spec.statusPath, {
742
+ const status = {
262
743
  worker_id: spec.workerId,
744
+ ...(spec.featureId ? { feature_id: spec.featureId } : {}),
745
+ ...(spec.featureTitle ? { feature_title: spec.featureTitle } : {}),
263
746
  role: spec.role,
264
747
  engine: spec.engine,
748
+ ...(spec.modelConfig?.name.trim() ? { model_name: spec.modelConfig.name.trim() } : {}),
749
+ ...(spec.modelConfig?.provider.trim() ? { model_provider: spec.modelConfig.provider.trim() } : {}),
265
750
  state,
266
751
  phase,
267
752
  last_event_at: new Date().toISOString(),
268
753
  summary,
269
754
  ...(nativeSessionId ? { native_session_id: nativeSessionId } : {})
270
- });
755
+ };
756
+ await writeJson(spec.statusPath, status);
757
+ notifyStatus(spec, status);
758
+ }
759
+ function notifyStatus(spec, status) {
760
+ try {
761
+ void Promise.resolve(spec.onStatus?.(status)).catch(() => { });
762
+ }
763
+ catch {
764
+ // Status observers cannot change the worker outcome.
765
+ }
271
766
  }