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.
- package/.parallel-codex/config.example.toml +136 -3
- package/README.md +299 -21
- package/dist/bootstrap.js +37 -17
- package/dist/cli-args.js +34 -3
- package/dist/cli-help.js +20 -0
- package/dist/cli-startup-preflight.js +18 -0
- package/dist/cli-startup-recovery.js +82 -0
- package/dist/cli-workspace-picker.js +330 -0
- package/dist/cli-workspace-transition.js +33 -0
- package/dist/cli-workspace.js +7 -71
- package/dist/cli.js +234 -24
- package/dist/core/collaboration-timeline.js +268 -0
- package/dist/core/config-errors.js +14 -0
- package/dist/core/config.js +297 -109
- package/dist/core/file-store.js +119 -6
- package/dist/core/lease-finalization.js +22 -0
- package/dist/core/paths.js +7 -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 +462 -35
- package/dist/core/session-index.js +412 -88
- package/dist/core/session-manager.js +1110 -40
- package/dist/core/task-session-details.js +175 -0
- package/dist/core/task-state-machine.js +18 -0
- package/dist/core/workspace-commit-recovery.js +118 -0
- package/dist/core/workspace.js +19 -11
- package/dist/doctor.js +373 -34
- package/dist/domain/schemas.js +142 -7
- package/dist/orchestrator/collaboration-channel.js +289 -6
- package/dist/orchestrator/feature-plan.js +70 -0
- package/dist/orchestrator/final-acceptance.js +86 -0
- package/dist/orchestrator/judge-artifacts.js +236 -0
- package/dist/orchestrator/orchestrator.js +2086 -203
- package/dist/orchestrator/prompts.js +168 -5
- package/dist/orchestrator/supervisor-summary.js +56 -2
- package/dist/orchestrator/workspace-sandbox.js +927 -0
- package/dist/tui/App.js +3187 -161
- package/dist/tui/AppShell.js +196 -25
- package/dist/tui/CollaborationTimelineView.js +327 -0
- package/dist/tui/FeatureBoardView.js +232 -0
- package/dist/tui/InputBar.js +581 -57
- package/dist/tui/RouterDiagnosticsView.js +469 -0
- package/dist/tui/StatusBar.js +610 -57
- package/dist/tui/StatusDetailView.js +164 -0
- package/dist/tui/TaskSessionDetailView.js +222 -0
- package/dist/tui/TaskSessionsView.js +210 -0
- package/dist/tui/TerminalOutput.js +53 -9
- package/dist/tui/WorkerOutputView.js +1404 -161
- package/dist/tui/WorkerOverviewView.js +269 -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 +49 -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 +360 -11
- package/dist/tui/task-memory.js +13 -1
- 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 +213 -0
- package/dist/workers/live-probe.js +177 -0
- package/dist/workers/mock-adapter.js +73 -8
- package/dist/workers/native-attach.js +106 -16
- package/dist/workers/process-adapter.js +572 -77
- package/dist/workers/provider.js +26 -0
- package/dist/workers/registry.js +12 -20
- 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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:
|
|
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
|
|
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 (
|
|
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:
|
|
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 (
|
|
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:
|
|
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
|
|
342
|
+
return Boolean(engine);
|
|
253
343
|
}
|