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
@@ -0,0 +1,177 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { ensureDir, readTextIfExists, writeText } from "../core/file-store.js";
5
+ import { createWorkerRegistry, getAdapter } from "./registry.js";
6
+ import { workerProvider } from "./provider.js";
7
+ export async function runLiveAgentProbes(config, workspaceRoot, engines, options = {}) {
8
+ const activeEngines = [...new Set(engines)];
9
+ if (activeEngines.length === 0) {
10
+ return {
11
+ ok: true,
12
+ lines: ["agent live probe: skipped (no process Worker providers active)"]
13
+ };
14
+ }
15
+ const probesRoot = join(workspaceRoot, config.dataDir, "probes");
16
+ await ensureDir(probesRoot);
17
+ const runRoot = await mkdtemp(join(probesRoot, ".agent-"));
18
+ const registry = options.registry ?? createWorkerRegistry(config);
19
+ const nonce = options.nonce ?? (() => randomBytes(4).toString("hex"));
20
+ const results = [];
21
+ for (const engine of activeEngines) {
22
+ try {
23
+ results.push(await probeEngine(config, registry, engine, runRoot, nonce(), options.timeoutMs));
24
+ }
25
+ catch (error) {
26
+ results.push({
27
+ ok: false,
28
+ line: `${engine} live probe: failed (${safeProbeError(error)}; artifacts ${runRoot})`
29
+ });
30
+ }
31
+ }
32
+ const ok = results.every((result) => result.ok);
33
+ if (ok) {
34
+ await rm(runRoot, { force: true, recursive: true });
35
+ }
36
+ return {
37
+ ok,
38
+ lines: results.map((result) => result.line)
39
+ };
40
+ }
41
+ async function probeEngine(config, registry, engine, runRoot, nonce, timeoutOverride) {
42
+ const startedAt = Date.now();
43
+ const worker = workerProvider(config, engine).config;
44
+ const adapter = getAdapter(registry, engine);
45
+ const cwd = join(runRoot, `${engine}-workspace`);
46
+ await mkdir(cwd, { recursive: true });
47
+ const first = probeChallenge(engine, "fresh", nonce);
48
+ let nativeSessionId = null;
49
+ const firstSpec = await probeSpec({
50
+ config,
51
+ engine,
52
+ cwd,
53
+ dir: join(runRoot, `${engine}-fresh`),
54
+ workerId: `doctor-${engine}-fresh`,
55
+ challenge: first,
56
+ timeoutOverride,
57
+ onNativeSession: (sessionId) => {
58
+ nativeSessionId = sessionId;
59
+ }
60
+ });
61
+ const firstResult = await adapter.run(firstSpec);
62
+ await requireProbeAnswer(firstResult, firstSpec, first.expected);
63
+ if (!worker.nativeSession.enabled || !worker.nativeSession.detectSessionId) {
64
+ return {
65
+ ok: true,
66
+ line: `${engine} live probe: ok (fresh; native resume disabled; ${formatProbeDuration(Date.now() - startedAt)})`
67
+ };
68
+ }
69
+ if (!nativeSessionId) {
70
+ throw new Error("fresh request succeeded but no native session id was detected");
71
+ }
72
+ const second = probeChallenge(engine, "resume", nonce);
73
+ let resumedSessionId = null;
74
+ const now = new Date().toISOString();
75
+ const nativeSession = {
76
+ engine,
77
+ role: "main",
78
+ worker_id: firstSpec.workerId,
79
+ session_id: nativeSessionId,
80
+ scope: "main",
81
+ cwd,
82
+ created_at: now,
83
+ last_used_at: now,
84
+ source: "manual"
85
+ };
86
+ const resumeSpec = await probeSpec({
87
+ config,
88
+ engine,
89
+ cwd,
90
+ dir: join(runRoot, `${engine}-resume`),
91
+ workerId: `doctor-${engine}-resume`,
92
+ challenge: second,
93
+ timeoutOverride,
94
+ nativeSession,
95
+ onNativeSession: (sessionId) => {
96
+ resumedSessionId = sessionId;
97
+ }
98
+ });
99
+ const resumeResult = await adapter.run(resumeSpec);
100
+ await requireProbeAnswer(resumeResult, resumeSpec, second.expected);
101
+ if (resumedSessionId && resumedSessionId !== nativeSessionId) {
102
+ throw new Error("resume request returned a different native session id");
103
+ }
104
+ return {
105
+ ok: true,
106
+ line: `${engine} live probe: ok (fresh + resume; session ${compactProbeSessionId(nativeSessionId)}; ${formatProbeDuration(Date.now() - startedAt)})`
107
+ };
108
+ }
109
+ async function probeSpec(input) {
110
+ const worker = workerProvider(input.config, input.engine).config;
111
+ await mkdir(input.dir, { recursive: true });
112
+ const promptPath = join(input.dir, "prompt.md");
113
+ await writeText(promptPath, `${input.challenge.prompt}\n`);
114
+ const maximumTimeout = input.timeoutOverride ?? 120_000;
115
+ const timeoutMs = Math.min(worker.timeoutMs ?? maximumTimeout, maximumTimeout);
116
+ const firstOutputTimeoutMs = Math.min(worker.firstOutputTimeoutMs ?? timeoutMs, timeoutMs);
117
+ const idleTimeoutMs = Math.min(worker.idleTimeoutMs ?? timeoutMs, timeoutMs);
118
+ return {
119
+ workerId: input.workerId,
120
+ role: "main",
121
+ engine: input.engine,
122
+ cwd: input.cwd,
123
+ enforceWorkspaceIsolation: true,
124
+ filesDir: input.dir,
125
+ promptPath,
126
+ outputLogPath: join(input.dir, "output.log"),
127
+ statusPath: join(input.dir, "status.json"),
128
+ prompt: input.challenge.prompt,
129
+ timeoutMs,
130
+ firstOutputTimeoutMs,
131
+ idleTimeoutMs,
132
+ ...(input.nativeSession ? { nativeSession: input.nativeSession } : {}),
133
+ nativeSessionConfig: {
134
+ ...worker.nativeSession,
135
+ fallback: "fail"
136
+ },
137
+ modelConfig: worker.model,
138
+ onNativeSession: input.onNativeSession
139
+ };
140
+ }
141
+ async function requireProbeAnswer(result, spec, expected) {
142
+ const output = await readTextIfExists(spec.outputLogPath);
143
+ if (result.exitCode !== 0 || result.cancelled || result.failure) {
144
+ throw new Error(result.failure?.summary || `worker exited with code ${result.exitCode}`);
145
+ }
146
+ if (!output.includes(expected)) {
147
+ throw new Error(`response did not contain challenge result ${expected}`);
148
+ }
149
+ }
150
+ function probeChallenge(engine, phase, nonce) {
151
+ const segments = ["PCT", engine.toUpperCase(), phase.toUpperCase(), nonce.toUpperCase()];
152
+ return {
153
+ prompt: [
154
+ "This is a connectivity probe. Do not use tools or modify files.",
155
+ `Join these segments with underscores and reply with only the joined value: ${segments.join(" | ")}`
156
+ ].join("\n"),
157
+ expected: segments.join("_")
158
+ };
159
+ }
160
+ function compactProbeSessionId(sessionId) {
161
+ return sessionId.length > 12 ? `${sessionId.slice(0, 8)}...` : sessionId;
162
+ }
163
+ function formatProbeDuration(durationMs) {
164
+ if (durationMs < 1000) {
165
+ return `${durationMs}ms`;
166
+ }
167
+ return `${(durationMs / 1000).toFixed(1).replace(/\.0$/, "")}s`;
168
+ }
169
+ function safeProbeError(error) {
170
+ const message = error instanceof Error ? error.message : String(error);
171
+ return message
172
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
173
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
174
+ .replace(/\s+/g, " ")
175
+ .trim()
176
+ .slice(0, 300) || "unknown error";
177
+ }
@@ -3,16 +3,36 @@ import { appendText, readTextIfExists, writeJson, writeText } from "../core/file
3
3
  export class MockWorkerAdapter {
4
4
  name = "mock";
5
5
  async run(spec) {
6
+ if (spec.signal?.aborted) {
7
+ return cancelMockWorker(spec);
8
+ }
6
9
  const nativeSessionId = spec.nativeSession?.session_id ?? `mock-${spec.workerId}`;
7
10
  await spec.onNativeSession?.(nativeSessionId);
8
11
  await setStatus(spec, "running", "mock-running", `${spec.role} mock worker running`, nativeSessionId);
9
12
  await appendText(spec.outputLogPath, `[mock:${spec.role}] started\n`);
10
13
  if (spec.role === "judge") {
11
- await writeText(join(spec.filesDir, "requirements.md"), "# Requirements\n\n- Mock requirements derived from the user request.\n");
12
- await writeText(join(spec.filesDir, "plan.md"), "# Plan\n\n1. Run Actor.\n2. Run Critic.\n");
13
- await writeText(join(spec.filesDir, "acceptance.md"), "# Acceptance\n\n- Mock review approves the result.\n");
14
- await writeText(join(spec.filesDir, "actor-brief.md"), "# Actor Brief\n\nImplement the requested change and write a worklog.\n");
15
- await writeText(join(spec.filesDir, "critic-brief.md"), "# Critic Brief\n\nReview the Actor output against acceptance criteria.\n");
14
+ if (spec.prompt.includes("# Role:") && spec.prompt.includes("· Final acceptance")) {
15
+ const criterionIds = promptJsonArray(spec.prompt, "Required acceptance criterion ids");
16
+ const changedPaths = promptJsonArray(spec.prompt, "Authoritative changed paths");
17
+ await writeJson(join(spec.filesDir, "final-acceptance.json"), {
18
+ version: 1,
19
+ decision: "approved",
20
+ summary: "Mock Final Judge verified every acceptance criterion.",
21
+ acceptance: criterionIds.map((criterionId) => ({
22
+ criterion_id: criterionId,
23
+ status: "passed",
24
+ evidence: "Mock verification completed."
25
+ })),
26
+ changed_paths: changedPaths
27
+ });
28
+ }
29
+ else {
30
+ await writeText(join(spec.filesDir, "requirements.md"), "# Requirements\n\n- [R-001] Mock requirements derived from the user request.\n");
31
+ await writeText(join(spec.filesDir, "plan.md"), "# Plan\n\n1. [P-001] Implement the scoped change.\n2. [P-002] Run focused verification.\n");
32
+ await writeText(join(spec.filesDir, "acceptance.md"), "# Acceptance\n\n- [A-001] [R-001] Focused tests pass for the requested behavior.\n");
33
+ await writeText(join(spec.filesDir, "actor-brief.md"), "# Actor Brief\n\nImplement the requested change and write a worklog.\n");
34
+ await writeText(join(spec.filesDir, "critic-brief.md"), "# Critic Brief\n\nReview the Actor output against acceptance criteria.\n");
35
+ }
16
36
  }
17
37
  if (spec.role === "actor") {
18
38
  await writeText(join(spec.filesDir, "worklog.md"), "# Worklog\n\n- Mock actor completed the implementation.\n");
@@ -33,7 +53,10 @@ export class MockWorkerAdapter {
33
53
  }
34
54
  }
35
55
  if (spec.role === "main") {
36
- await appendText(spec.outputLogPath, `Mock simple response for: ${spec.prompt.trim()}\n`);
56
+ await appendText(spec.outputLogPath, `Mock simple response for: ${mainRequestFromPrompt(spec.prompt)}\n`);
57
+ }
58
+ if (spec.signal?.aborted) {
59
+ return cancelMockWorker(spec, nativeSessionId);
37
60
  }
38
61
  await appendText(spec.outputLogPath, `[mock:${spec.role}] done\n`);
39
62
  await setStatus(spec, "done", "mock-done", `${spec.role} mock worker done`, nativeSessionId);
@@ -44,19 +67,61 @@ export class MockWorkerAdapter {
44
67
  };
45
68
  }
46
69
  }
70
+ async function cancelMockWorker(spec, nativeSessionId) {
71
+ await appendText(spec.outputLogPath, "[mock] cancelled\n");
72
+ await setStatus(spec, "cancelled", "mock-cancelled", `${spec.role} mock worker cancelled`, nativeSessionId);
73
+ return {
74
+ workerId: spec.workerId,
75
+ exitCode: 130,
76
+ signal: "SIGTERM",
77
+ cancelled: true
78
+ };
79
+ }
80
+ function mainRequestFromPrompt(prompt) {
81
+ const marker = "\nUser request:\n";
82
+ const markerIndex = prompt.indexOf(marker);
83
+ return markerIndex >= 0 ? prompt.slice(markerIndex + marker.length).trim() : prompt.trim();
84
+ }
47
85
  function featureDirFromPrompt(prompt) {
48
86
  const line = prompt.split("\n").find((item) => item.startsWith("Feature directory: "));
49
87
  return line ? line.replace("Feature directory: ", "").trim() : null;
50
88
  }
89
+ function promptJsonArray(prompt, label) {
90
+ const line = prompt.split("\n").find((item) => item.startsWith(`${label}: `));
91
+ if (!line) {
92
+ return [];
93
+ }
94
+ try {
95
+ const value = JSON.parse(line.slice(label.length + 2));
96
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
97
+ }
98
+ catch {
99
+ return [];
100
+ }
101
+ }
51
102
  async function setStatus(spec, state, phase, summary, nativeSessionId) {
52
- await writeJson(spec.statusPath, {
103
+ const status = {
53
104
  worker_id: spec.workerId,
105
+ ...(spec.featureId ? { feature_id: spec.featureId } : {}),
106
+ ...(spec.featureTitle ? { feature_title: spec.featureTitle } : {}),
54
107
  role: spec.role,
55
108
  engine: spec.engine,
109
+ ...(spec.modelConfig?.name.trim() ? { model_name: spec.modelConfig.name.trim() } : {}),
110
+ ...(spec.modelConfig?.provider.trim() ? { model_provider: spec.modelConfig.provider.trim() } : {}),
56
111
  state,
57
112
  phase,
58
113
  last_event_at: new Date().toISOString(),
59
114
  summary,
60
115
  ...(nativeSessionId ? { native_session_id: nativeSessionId } : {})
61
- });
116
+ };
117
+ await writeJson(spec.statusPath, status);
118
+ notifyStatus(spec, status);
119
+ }
120
+ function notifyStatus(spec, status) {
121
+ try {
122
+ void Promise.resolve(spec.onStatus?.(status)).catch(() => { });
123
+ }
124
+ catch {
125
+ // Status observers cannot change the worker outcome.
126
+ }
62
127
  }
@@ -2,30 +2,105 @@ import { accessSync, chmodSync, constants, existsSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { readdir } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
- import { dirname, join } from "node:path";
5
+ import { dirname, join, relative, sep } from "node:path";
6
6
  import { spawn } from "node-pty";
7
7
  import { pathExists, pathIsDirectory, readJson, readTextIfExists, removeIfExists, writeJson } from "../core/file-store.js";
8
8
  import { NativeSessionSchema, TaskMetaSchema } from "../domain/schemas.js";
9
9
  import { detectResumeSessionId } from "./native-session-detection.js";
10
+ import { workerProvider } from "./provider.js";
10
11
  const require = createRequire(import.meta.url);
11
12
  export async function buildNativeAttachLaunch(input) {
12
- const nativeSession = await readWorkerNativeSession(input.worker);
13
- const workerConfig = input.config.workers[input.worker.engine];
13
+ return buildNativeSessionLaunch(input, "resume");
14
+ }
15
+ export async function buildNativeForkLaunch(input) {
16
+ return buildNativeSessionLaunch(input, "fork");
17
+ }
18
+ async function buildNativeSessionLaunch(input, mode) {
19
+ const workerConfig = workerProvider(input.config, input.worker.engine).config;
20
+ const nativeSession = await readWorkerNativeSession(input.worker, workerConfig.capabilities.profile);
14
21
  const modelConfig = workerConfig.model;
22
+ const env = modelEnvironment(modelConfig);
23
+ const interactiveArgs = mode === "fork"
24
+ ? workerConfig.interactive.forkArgs
25
+ : workerConfig.interactive.args;
26
+ if (mode === "fork" && interactiveArgs.length === 0) {
27
+ throw new Error(`Native session fork is not configured for ${input.worker.engine}. `
28
+ + `Set workers.${input.worker.engine}.interactive.forkArgs with a {sessionId} template.`);
29
+ }
30
+ const workerDir = dirname(input.worker.statusPath);
31
+ const taskDir = dirname(workerDir);
15
32
  if (!(await pathExists(nativeSession.cwd))) {
16
33
  throw new Error(`Native session workspace not found for ${input.worker.label}: ${nativeSession.cwd}`);
17
34
  }
18
35
  if (!(await pathIsDirectory(nativeSession.cwd))) {
19
36
  throw new Error(`Native session workspace is not a directory for ${input.worker.label}: ${nativeSession.cwd}`);
20
37
  }
38
+ const recordedDirs = nativeSession.writable_dirs?.length
39
+ ? nativeSession.writable_dirs
40
+ : isWithin(nativeSession.cwd, join(taskDir, "workspaces")) ? [taskDir] : [];
41
+ const additionalDirs = [];
42
+ for (const directory of recordedDirs) {
43
+ if (directory !== nativeSession.cwd && await pathIsDirectory(directory)) {
44
+ additionalDirs.push(directory);
45
+ }
46
+ }
21
47
  return {
22
48
  command: workerConfig.interactive.command,
23
- args: workerConfig.interactive.args.map((arg) => renderTemplate(arg, nativeSession.session_id, modelConfig)),
49
+ args: nativeAttachArgs({
50
+ args: [
51
+ ...interactiveArgs,
52
+ ...modelConfig.args
53
+ ].map((arg) => renderTemplate(arg, nativeSession.session_id, modelConfig)),
54
+ providerId: input.worker.engine,
55
+ capabilities: workerConfig.capabilities,
56
+ additionalDirs
57
+ }),
58
+ ...(Object.keys(env).length > 0 ? { env } : {}),
24
59
  cwd: nativeSession.cwd,
25
60
  sessionId: nativeSession.session_id,
26
- label: input.worker.label
61
+ label: mode === "fork" ? `${input.worker.label} · fork` : input.worker.label
27
62
  };
28
63
  }
64
+ function nativeAttachArgs(input) {
65
+ if (input.additionalDirs.length === 0 || input.capabilities.writableDirArgs.length === 0) {
66
+ return input.args;
67
+ }
68
+ const args = input.capabilities.profile === "codex"
69
+ ? withCodexWritableSandbox(input.args, input.providerId)
70
+ : input.args;
71
+ return [
72
+ ...args,
73
+ ...[...new Set(input.additionalDirs)].flatMap((directory) => (input.capabilities.writableDirArgs.map((arg) => arg.replaceAll("{dir}", directory))))
74
+ ];
75
+ }
76
+ function withCodexWritableSandbox(args, providerId) {
77
+ const sandbox = codexSandboxSelection(args);
78
+ if (sandbox === "read-only") {
79
+ throw new Error("Codex native attach cannot use recorded worker directories with a read-only sandbox. "
80
+ + `Set workers.${providerId}.interactive.args to workspace-write or danger-full-access.`);
81
+ }
82
+ return sandbox ? args : [...args, "--sandbox", "workspace-write"];
83
+ }
84
+ function codexSandboxSelection(args) {
85
+ for (let index = 0; index < args.length; index += 1) {
86
+ const arg = args[index] ?? "";
87
+ if (arg === "--dangerously-bypass-approvals-and-sandbox") {
88
+ return "danger-full-access";
89
+ }
90
+ if (arg === "--sandbox" || arg === "-s") {
91
+ return args[index + 1]?.trim().toLowerCase() || null;
92
+ }
93
+ const match = arg.match(/^(?:--sandbox|-s)=(.+)$/);
94
+ if (match) {
95
+ return match[1]?.trim().toLowerCase() || null;
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+ function isWithin(path, root) {
101
+ const pathFromRoot = relative(root, path);
102
+ return pathFromRoot === "" || (!pathFromRoot.startsWith(`..${sep}`) && pathFromRoot !== "..");
103
+ }
29
104
  export function startNativeAttachProcess(launch, handlers = {}) {
30
105
  ensureNodePtySpawnHelperExecutable();
31
106
  const child = spawn(launch.command, launch.args, {
@@ -35,24 +110,39 @@ export function startNativeAttachProcess(launch, handlers = {}) {
35
110
  cwd: launch.cwd,
36
111
  env: {
37
112
  ...process.env,
113
+ ...(launch.env ?? {}),
38
114
  TERM: process.env.TERM || "xterm-256color"
39
115
  }
40
116
  });
117
+ let closed = false;
41
118
  child.onData((chunk) => {
42
119
  handlers.onOutput?.(chunk);
43
120
  });
44
121
  child.onExit(({ exitCode }) => {
122
+ closed = true;
45
123
  handlers.onClose?.(exitCode);
46
124
  });
47
125
  return {
48
126
  write(input) {
49
127
  child.write(input);
50
128
  },
129
+ resize(cols, rows) {
130
+ if (closed) {
131
+ return;
132
+ }
133
+ child.resize(Math.max(1, Math.trunc(cols)), Math.max(1, Math.trunc(rows)));
134
+ },
51
135
  kill() {
136
+ if (closed) {
137
+ return;
138
+ }
52
139
  child.kill("SIGTERM");
53
140
  }
54
141
  };
55
142
  }
143
+ function modelEnvironment(modelConfig) {
144
+ return Object.fromEntries(Object.entries(modelConfig.env ?? {}).map(([name, value]) => [name, renderTemplate(value, "", modelConfig)]));
145
+ }
56
146
  function ensureNodePtySpawnHelperExecutable() {
57
147
  if (process.platform === "win32") {
58
148
  return;
@@ -69,22 +159,22 @@ function ensureNodePtySpawnHelperExecutable() {
69
159
  chmodSync(helperPath, 0o755);
70
160
  }
71
161
  }
72
- async function readWorkerNativeSession(worker) {
162
+ async function readWorkerNativeSession(worker, profile) {
73
163
  const workerDir = dirname(worker.statusPath);
74
164
  const nativePath = join(workerDir, "native-session.json");
75
165
  const record = await readAttachNativeSessionIfValid(nativePath);
76
166
  if (!record) {
77
- const recoveredCodex = await recoverCodexNativeSession(worker, workerDir);
167
+ const recoveredCodex = await recoverCodexNativeSession(worker, workerDir, profile);
78
168
  if (recoveredCodex) {
79
169
  await writeJson(nativePath, NativeSessionSchema.parse(recoveredCodex));
80
170
  return recoveredCodex;
81
171
  }
82
- const recovered = await recoverClaudeNativeSession(worker, workerDir);
172
+ const recovered = await recoverClaudeNativeSession(worker, workerDir, profile);
83
173
  if (recovered) {
84
174
  await writeJson(nativePath, NativeSessionSchema.parse(recovered));
85
175
  return recovered;
86
176
  }
87
- throw new Error(`No native session recorded for ${worker.label}. Run the worker once before attaching, or make sure ${worker.engine} persisted a resumable session.`);
177
+ throw new Error(`No native session for ${worker.label} · run once before attach`);
88
178
  }
89
179
  if (record.engine !== worker.engine) {
90
180
  throw new Error(`Native session engine mismatch for ${worker.label}: expected ${worker.engine}, got ${record.engine}`);
@@ -106,8 +196,8 @@ async function readAttachNativeSessionIfValid(nativePath) {
106
196
  return null;
107
197
  }
108
198
  }
109
- async function recoverCodexNativeSession(worker, workerDir) {
110
- if (worker.engine !== "codex") {
199
+ async function recoverCodexNativeSession(worker, workerDir, profile) {
200
+ if (profile !== "codex") {
111
201
  return null;
112
202
  }
113
203
  const taskDir = dirname(workerDir);
@@ -122,7 +212,7 @@ async function recoverCodexNativeSession(worker, workerDir) {
122
212
  }
123
213
  const now = new Date().toISOString();
124
214
  return {
125
- engine: "codex",
215
+ engine: worker.engine,
126
216
  role: worker.role,
127
217
  worker_id: worker.id,
128
218
  session_id: sessionId,
@@ -133,8 +223,8 @@ async function recoverCodexNativeSession(worker, workerDir) {
133
223
  source: "output-detected"
134
224
  };
135
225
  }
136
- async function recoverClaudeNativeSession(worker, workerDir) {
137
- if (worker.engine !== "claude") {
226
+ async function recoverClaudeNativeSession(worker, workerDir, profile) {
227
+ if (profile !== "claude") {
138
228
  return null;
139
229
  }
140
230
  const prompt = await readTextIfExists(join(workerDir, "prompt.md"));
@@ -154,7 +244,7 @@ async function recoverClaudeNativeSession(worker, workerDir) {
154
244
  return null;
155
245
  }
156
246
  return {
157
- engine: "claude",
247
+ engine: worker.engine,
158
248
  role: worker.role,
159
249
  worker_id: worker.id,
160
250
  session_id: match.sessionId,
@@ -249,5 +339,5 @@ function renderTemplate(value, sessionId, modelConfig) {
249
339
  .replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => process.env[name] ?? "");
250
340
  }
251
341
  export function supportsNativeAttach(engine) {
252
- return engine === "codex" || engine === "claude" || engine === "mock";
342
+ return Boolean(engine);
253
343
  }