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,5 +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";
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;
3
11
  export class ProcessWorkerAdapter {
4
12
  name;
5
13
  command;
@@ -13,7 +21,8 @@ export class ProcessWorkerAdapter {
13
21
  }
14
22
  async run(spec) {
15
23
  const model = spec.modelConfig ?? this.defaults.model;
16
- 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);
17
26
  const runSpec = {
18
27
  ...spec,
19
28
  timeoutMs: spec.timeoutMs ?? this.defaults.timeoutMs,
@@ -22,7 +31,9 @@ export class ProcessWorkerAdapter {
22
31
  nativeSession: launch.nativeSession,
23
32
  modelConfig: model
24
33
  };
25
- const first = await this.runAttempt(runSpec, launch);
34
+ const first = await this.runAttempt(runSpec, launch, {
35
+ initialNativeSessionId: launch.initialNativeSessionId
36
+ });
26
37
  if (!shouldFallbackToNewNativeSession(first, runSpec.nativeSessionConfig)) {
27
38
  return first.result;
28
39
  }
@@ -31,20 +42,32 @@ export class ProcessWorkerAdapter {
31
42
  await runSpec.onNativeSessionRetired?.(retiredSessionId, first.output);
32
43
  }
33
44
  await appendText(runSpec.outputLogPath, `\nNative resume for ${retiredSessionId ?? "unknown session"} is unrecoverable; starting a fresh native session.\n`);
34
- const freshLaunch = buildFreshLaunch(this.args, model);
45
+ const freshLaunch = buildFreshLaunch(this.args, model, capabilities, runSpec.writableDirs, runSpec.enforceWorkspaceIsolation, runSpec.nativeSessionConfig);
35
46
  return (await this.runAttempt({
36
47
  ...runSpec,
37
48
  nativeSession: null
38
49
  }, freshLaunch, {
39
- initialNativeSessionId: undefined,
50
+ initialNativeSessionId: freshLaunch.initialNativeSessionId,
40
51
  startPhase: "native-resume-fallback",
41
52
  startSummary: `${this.command} starting fresh session after unrecoverable native resume`
42
53
  })).result;
43
54
  }
44
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
+ }
45
67
  await setStatus(runSpec, "starting", options.startPhase ?? "process-starting", options.startSummary ?? `Starting ${this.command}`);
46
- await appendText(runSpec.outputLogPath, `$ ${this.command} ${launch.args.join(" ")}\n`);
68
+ await appendText(runSpec.outputLogPath, `$ ${formatShellCommand(this.command, launch.args)}\n`);
47
69
  return new Promise((resolve, reject) => {
70
+ const detached = process.platform !== "win32";
48
71
  const child = spawn(this.command, launch.args, {
49
72
  cwd: runSpec.cwd,
50
73
  env: {
@@ -54,102 +77,354 @@ export class ProcessWorkerAdapter {
54
77
  PARALLEL_CODEX_ROLE: runSpec.role,
55
78
  PARALLEL_CODEX_FILES_DIR: runSpec.filesDir
56
79
  },
57
- stdio: ["pipe", "pipe", "pipe"]
80
+ stdio: ["pipe", "pipe", "pipe"],
81
+ detached
58
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();
59
94
  let settled = false;
95
+ let finishing = false;
60
96
  let timeout;
61
97
  let idleTimeout;
62
98
  let firstOutputTimeout;
99
+ let processTreeCleanup;
100
+ let abortListener;
63
101
  let terminalPhase;
64
102
  let terminalSummary;
103
+ let terminalState;
65
104
  let outputWrites = Promise.resolve();
105
+ let persistenceError;
106
+ let hasPersistenceError = false;
66
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 = "";
67
112
  let sawOutput = false;
68
- const outputChunks = [];
69
- const finish = async (result) => {
70
- if (settled) {
71
- return;
72
- }
73
- 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 = () => {
74
121
  if (timeout) {
75
122
  clearTimeout(timeout);
123
+ timeout = undefined;
76
124
  }
77
125
  if (idleTimeout) {
78
126
  clearTimeout(idleTimeout);
127
+ idleTimeout = undefined;
79
128
  }
80
129
  if (firstOutputTimeout) {
81
130
  clearTimeout(firstOutputTimeout);
131
+ firstOutputTimeout = undefined;
132
+ }
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
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;
82
227
  }
83
- await outputWrites;
84
- const phase = terminalPhase ?? (launch.isResume && result.exitCode !== 0 ? "native-resume-failed" : "process-exited");
85
- const summary = terminalSummary ??
86
- (launch.isResume && result.exitCode !== 0
87
- ? `${this.command} native resume exited with code ${result.exitCode}`
88
- : `${this.command} exited with code ${result.exitCode}`);
89
- await setStatus(runSpec, result.exitCode === 0 ? "done" : "failed", phase, summary, detectedNativeSessionId);
90
- resolve({
91
- result,
92
- output: outputChunks.join(""),
93
- launch
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());
94
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
+ }
95
345
  };
96
346
  const resetIdleTimeout = () => {
97
- 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) {
98
356
  return;
99
357
  }
100
358
  if (idleTimeout) {
101
359
  clearTimeout(idleTimeout);
102
360
  }
103
361
  idleTimeout = setTimeout(() => {
104
- terminalPhase = "process-idle-timeout";
105
- terminalSummary = `${this.command} produced no output for ${runSpec.idleTimeoutMs}ms`;
106
- void appendText(runSpec.outputLogPath, `\nProcess idle timed out after ${runSpec.idleTimeoutMs}ms\n`);
107
- void setStatus(runSpec, "failed", terminalPhase, terminalSummary);
108
- 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`);
109
363
  }, runSpec.idleTimeoutMs);
110
364
  };
111
- const recordOutput = (chunk) => {
365
+ const recordOutput = (chunk, decoder, stream) => {
366
+ if (settled || finishing) {
367
+ return;
368
+ }
112
369
  sawOutput = true;
113
370
  if (firstOutputTimeout) {
114
371
  clearTimeout(firstOutputTimeout);
115
372
  firstOutputTimeout = undefined;
116
373
  }
117
- const text = chunk.toString("utf8");
118
- outputChunks.push(text);
119
- outputWrites = outputWrites.then(async () => {
120
- await appendText(runSpec.outputLogPath, text);
121
- const sessionId = detectNativeSessionId(text);
122
- if (sessionId && sessionId !== detectedNativeSessionId && runSpec.nativeSessionConfig?.detectSessionId !== false) {
123
- detectedNativeSessionId = sessionId;
124
- await runSpec.onNativeSession?.(sessionId);
125
- }
126
- if (!settled) {
127
- await setStatus(runSpec, "running", "process-output", summarizeOutput(text), detectedNativeSessionId);
128
- }
129
- });
374
+ recordDecodedOutput(decoder.write(chunk), stream);
130
375
  resetIdleTimeout();
131
376
  };
132
377
  child.stdout.on("data", (chunk) => {
133
- recordOutput(chunk);
378
+ recordOutput(chunk, stdoutDecoder, "stdout");
134
379
  });
135
380
  child.stderr.on("data", (chunk) => {
136
- recordOutput(chunk);
381
+ recordOutput(chunk, stderrDecoder, "stderr");
137
382
  });
138
383
  child.on("error", (error) => {
139
384
  if (settled) {
140
385
  return;
141
386
  }
142
- settled = true;
143
- if (timeout) {
144
- 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;
145
395
  }
146
- if (idleTimeout) {
147
- 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;
148
407
  }
149
- if (firstOutputTimeout) {
150
- clearTimeout(firstOutputTimeout);
408
+ endOutputDecoders();
409
+ settled = true;
410
+ clearRunTimers();
411
+ if (abortListener) {
412
+ runSpec.signal?.removeEventListener("abort", abortListener);
151
413
  }
152
- 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
+ })();
153
428
  });
154
429
  child.on("close", (code, signal) => {
155
430
  void finish({
@@ -158,53 +433,225 @@ export class ProcessWorkerAdapter {
158
433
  signal
159
434
  });
160
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 });
161
454
  if (runSpec.timeoutMs && runSpec.timeoutMs > 0) {
162
455
  timeout = setTimeout(() => {
163
- terminalPhase = "process-timeout";
164
- terminalSummary = `${this.command} exceeded ${runSpec.timeoutMs}ms`;
165
- void setStatus(runSpec, "failed", terminalPhase, terminalSummary, detectedNativeSessionId);
166
- child.kill("SIGTERM");
167
- 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`);
168
457
  }, runSpec.timeoutMs);
169
458
  }
170
- if (runSpec.firstOutputTimeoutMs && runSpec.firstOutputTimeoutMs > 0) {
459
+ if (runSpec.firstOutputTimeoutMs
460
+ && runSpec.firstOutputTimeoutMs > 0
461
+ && (!runSpec.timeoutMs || runSpec.timeoutMs <= 0 || runSpec.firstOutputTimeoutMs < runSpec.timeoutMs)) {
171
462
  firstOutputTimeout = setTimeout(() => {
172
463
  if (sawOutput || settled) {
173
464
  return;
174
465
  }
175
- terminalPhase = "process-first-output-timeout";
176
- terminalSummary = `${this.command} produced no first output for ${runSpec.firstOutputTimeoutMs}ms`;
177
- void setStatus(runSpec, "failed", terminalPhase, terminalSummary, detectedNativeSessionId);
178
- child.kill("SIGTERM");
179
- 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`);
180
467
  }, runSpec.firstOutputTimeoutMs);
181
468
  }
182
- void setStatus(runSpec, "running", "process-running", `${this.command} running`, detectedNativeSessionId);
183
- resetIdleTimeout();
184
- child.stdin.write(runSpec.prompt);
185
- 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
+ });
186
481
  });
187
482
  }
188
483
  }
189
- function buildLaunch(defaultArgs, nativeSession, nativeSessionConfig, modelConfig) {
484
+ function buildLaunch(defaultArgs, nativeSession, nativeSessionConfig, modelConfig, capabilities, writableDirs, enforceWorkspaceIsolation = false) {
190
485
  const modelArgs = buildModelArgs(modelConfig);
191
486
  if (!nativeSession || !nativeSessionConfig?.enabled || nativeSessionConfig.resumeArgs.length === 0) {
192
- return buildFreshLaunch(defaultArgs, modelConfig);
487
+ return buildFreshLaunch(defaultArgs, modelConfig, capabilities, writableDirs, enforceWorkspaceIsolation, nativeSessionConfig);
193
488
  }
194
489
  return {
195
- args: [
490
+ args: withWritableDirectoryArgs(enforceWorkerIsolationArgs([
196
491
  ...nativeSessionConfig.resumeArgs.map((arg) => renderTemplate(arg, nativeSession.session_id, modelConfig)),
197
492
  ...modelArgs
198
- ],
493
+ ], capabilities.profile, enforceWorkspaceIsolation), capabilities, true, writableDirs),
199
494
  isResume: true,
200
495
  nativeSession
201
496
  };
202
497
  }
203
- 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);
204
501
  return {
205
- args: [...defaultArgs, ...buildModelArgs(modelConfig)],
502
+ args: withWritableDirectoryArgs(freshSession.args, capabilities, false, writableDirs),
206
503
  isResume: false,
207
- 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: []
208
655
  };
209
656
  }
210
657
  function buildModelArgs(modelConfig) {
@@ -220,8 +667,13 @@ function buildModelEnv(modelConfig) {
220
667
  return Object.fromEntries(Object.entries(modelConfig.env).map(([key, value]) => [key, renderTemplate(value, undefined, modelConfig)]));
221
668
  }
222
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
+ }
223
674
  return (attempt.launch.isResume &&
224
- attempt.result.exitCode !== 0 &&
675
+ !attempt.result.cancelled &&
676
+ (Boolean(attempt.result.failure) || attempt.result.exitCode !== 0) &&
225
677
  nativeSessionConfig?.fallback === "new" &&
226
678
  isUnrecoverableNativeResumeOutput(attempt.output));
227
679
  }
@@ -232,6 +684,33 @@ function isUnrecoverableNativeResumeOutput(output) {
232
684
  normalized.includes("clear earlier history") ||
233
685
  normalized.includes("start a new thread"));
234
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
+ }
235
714
  function renderTemplate(value, sessionId, modelConfig) {
236
715
  return value
237
716
  .replaceAll("{sessionId}", sessionId ?? "")
@@ -239,6 +718,15 @@ function renderTemplate(value, sessionId, modelConfig) {
239
718
  .replaceAll("{provider}", modelConfig?.provider ?? "")
240
719
  .replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => process.env[name] ?? "");
241
720
  }
721
+ function formatShellCommand(command, args) {
722
+ return [command, ...args].map(shellQuote).join(" ");
723
+ }
724
+ function shellQuote(value) {
725
+ if (value.length > 0 && /^[A-Za-z0-9_./:=@%+,-]+$/.test(value)) {
726
+ return value;
727
+ }
728
+ return `'${value.replaceAll("'", "'\\''")}'`;
729
+ }
242
730
  function summarizeOutput(text) {
243
731
  const lines = text
244
732
  .split(/\r?\n/)
@@ -247,9 +735,14 @@ function summarizeOutput(text) {
247
735
  const summary = lines.at(-1) ?? "Worker produced output";
248
736
  return summary.length > 160 ? `${summary.slice(0, 157)}...` : summary;
249
737
  }
738
+ function errorMessage(error) {
739
+ return error instanceof Error ? error.message : String(error);
740
+ }
250
741
  async function setStatus(spec, state, phase, summary, nativeSessionId) {
251
- await writeJson(spec.statusPath, {
742
+ const status = {
252
743
  worker_id: spec.workerId,
744
+ ...(spec.featureId ? { feature_id: spec.featureId } : {}),
745
+ ...(spec.featureTitle ? { feature_title: spec.featureTitle } : {}),
253
746
  role: spec.role,
254
747
  engine: spec.engine,
255
748
  state,
@@ -257,9 +750,15 @@ async function setStatus(spec, state, phase, summary, nativeSessionId) {
257
750
  last_event_at: new Date().toISOString(),
258
751
  summary,
259
752
  ...(nativeSessionId ? { native_session_id: nativeSessionId } : {})
260
- });
753
+ };
754
+ await writeJson(spec.statusPath, status);
755
+ notifyStatus(spec, status);
261
756
  }
262
- function detectNativeSessionId(text) {
263
- const match = text.match(/\b(?:session id|session_id|session)\s*[:=]\s*([A-Za-z0-9._:@-]{4,})/i);
264
- return match?.[1] ?? null;
757
+ function notifyStatus(spec, status) {
758
+ try {
759
+ void Promise.resolve(spec.onStatus?.(status)).catch(() => { });
760
+ }
761
+ catch {
762
+ // Status observers cannot change the worker outcome.
763
+ }
265
764
  }