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