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.
- package/.parallel-codex/config.example.toml +90 -3
- package/README.md +269 -12
- package/dist/bootstrap.js +50 -18
- package/dist/cli-args.js +96 -14
- package/dist/cli-help.js +20 -0
- package/dist/cli-startup-recovery.js +70 -0
- package/dist/cli-workspace-picker.js +330 -0
- package/dist/cli-workspace-transition.js +33 -0
- package/dist/cli-workspace.js +40 -0
- package/dist/cli.js +291 -35
- package/dist/core/app-root.js +8 -0
- package/dist/core/collaboration-timeline.js +261 -0
- package/dist/core/config-errors.js +14 -0
- package/dist/core/config.js +191 -23
- package/dist/core/file-store.js +130 -6
- package/dist/core/lease-finalization.js +22 -0
- package/dist/core/paths.js +10 -0
- package/dist/core/process-identity.js +48 -0
- package/dist/core/process-mutation-turn.js +128 -0
- package/dist/core/process-ownership.js +276 -0
- package/dist/core/process-tree.js +90 -0
- package/dist/core/router-audit.js +155 -0
- package/dist/core/router-redaction.js +31 -0
- package/dist/core/router.js +473 -42
- package/dist/core/session-index.js +225 -30
- package/dist/core/session-manager.js +1182 -44
- package/dist/core/task-state-machine.js +17 -0
- package/dist/core/workspace-commit-recovery.js +118 -0
- package/dist/core/workspace.js +126 -0
- package/dist/doctor.js +384 -30
- package/dist/domain/schemas.js +127 -6
- package/dist/orchestrator/collaboration-channel.js +255 -4
- package/dist/orchestrator/feature-plan.js +70 -0
- package/dist/orchestrator/judge-artifacts.js +236 -0
- package/dist/orchestrator/orchestrator.js +1777 -212
- package/dist/orchestrator/prompts.js +126 -2
- package/dist/orchestrator/supervisor-summary.js +56 -2
- package/dist/orchestrator/workspace-sandbox.js +911 -0
- package/dist/tui/App.js +2838 -159
- package/dist/tui/AppShell.js +188 -23
- package/dist/tui/CollaborationTimelineView.js +327 -0
- package/dist/tui/FeatureBoardView.js +227 -0
- package/dist/tui/InputBar.js +514 -57
- package/dist/tui/RouterDiagnosticsView.js +469 -0
- package/dist/tui/StatusBar.js +610 -57
- package/dist/tui/TaskSessionsView.js +207 -0
- package/dist/tui/TerminalOutput.js +53 -9
- package/dist/tui/WorkerOutputView.js +1403 -161
- package/dist/tui/WorkerOverviewView.js +250 -0
- package/dist/tui/chat-history.js +25 -0
- package/dist/tui/chat-input.js +67 -19
- package/dist/tui/chat-paste.js +76 -0
- package/dist/tui/display-width.js +41 -3
- package/dist/tui/incremental-text-file.js +101 -0
- package/dist/tui/keyboard.js +46 -0
- package/dist/tui/markdown-text.js +14 -0
- package/dist/tui/raw-input-decoder.js +3 -0
- package/dist/tui/scrolling.js +2 -1
- package/dist/tui/status-line.js +318 -11
- package/dist/tui/task-memory.js +15 -0
- package/dist/tui/task-result.js +105 -0
- package/dist/tui/terminal-screen.js +13 -1
- package/dist/tui/theme-contrast.js +144 -0
- package/dist/tui/theme-preview.js +109 -0
- package/dist/tui/theme.js +158 -0
- package/dist/version.js +1 -1
- package/dist/workers/capabilities.js +212 -0
- package/dist/workers/live-probe.js +176 -0
- package/dist/workers/mock-adapter.js +39 -6
- package/dist/workers/native-attach.js +147 -8
- package/dist/workers/native-session-detection.js +17 -0
- package/dist/workers/process-adapter.js +580 -81
- package/dist/workers/registry.js +4 -2
- 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.
|
|
13
|
-
await writeText(join(spec.filesDir, "acceptance.md"), "# Acceptance\n\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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|