parallel-codex-tui 0.1.0
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 +62 -0
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/dist/bootstrap.js +25 -0
- package/dist/cli-args.js +26 -0
- package/dist/cli.js +48 -0
- package/dist/core/config.js +304 -0
- package/dist/core/file-store.js +56 -0
- package/dist/core/paths.js +17 -0
- package/dist/core/router.js +151 -0
- package/dist/core/session-index.js +180 -0
- package/dist/core/session-manager.js +264 -0
- package/dist/doctor.js +121 -0
- package/dist/domain/schemas.js +78 -0
- package/dist/orchestrator/collaboration-channel.js +103 -0
- package/dist/orchestrator/orchestrator.js +545 -0
- package/dist/orchestrator/prompts.js +124 -0
- package/dist/orchestrator/supervisor-summary.js +38 -0
- package/dist/tui/App.js +740 -0
- package/dist/tui/AppShell.js +129 -0
- package/dist/tui/InputBar.js +141 -0
- package/dist/tui/StatusBar.js +288 -0
- package/dist/tui/TerminalOutput.js +22 -0
- package/dist/tui/WorkerOutputView.js +4015 -0
- package/dist/tui/chat-input.js +37 -0
- package/dist/tui/display-width.js +132 -0
- package/dist/tui/keyboard.js +38 -0
- package/dist/tui/native-input.js +120 -0
- package/dist/tui/raw-input-decoder.js +18 -0
- package/dist/tui/scrolling.js +25 -0
- package/dist/tui/status-line.js +90 -0
- package/dist/tui/task-memory.js +26 -0
- package/dist/tui/terminal-screen.js +168 -0
- package/dist/version.js +1 -0
- package/dist/workers/mock-adapter.js +62 -0
- package/dist/workers/native-attach.js +189 -0
- package/dist/workers/process-adapter.js +265 -0
- package/dist/workers/registry.js +32 -0
- package/dist/workers/types.js +1 -0
- package/package.json +53 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { appendText, readTextIfExists, writeJson, writeText } from "../core/file-store.js";
|
|
3
|
+
export class MockWorkerAdapter {
|
|
4
|
+
name = "mock";
|
|
5
|
+
async run(spec) {
|
|
6
|
+
const nativeSessionId = spec.nativeSession?.session_id ?? `mock-${spec.workerId}`;
|
|
7
|
+
await spec.onNativeSession?.(nativeSessionId);
|
|
8
|
+
await setStatus(spec, "running", "mock-running", `${spec.role} mock worker running`, nativeSessionId);
|
|
9
|
+
await appendText(spec.outputLogPath, `[mock:${spec.role}] started\n`);
|
|
10
|
+
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");
|
|
16
|
+
}
|
|
17
|
+
if (spec.role === "actor") {
|
|
18
|
+
await writeText(join(spec.filesDir, "worklog.md"), "# Worklog\n\n- Mock actor completed the implementation.\n");
|
|
19
|
+
await writeText(join(spec.filesDir, "patch.diff"), "diff --git a/mock b/mock\n");
|
|
20
|
+
const featureDir = featureDirFromPrompt(spec.prompt);
|
|
21
|
+
if (featureDir) {
|
|
22
|
+
await writeText(join(featureDir, "actor-worklog.md"), "# Worklog\n\n- Mock actor completed the implementation.\n");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (spec.role === "critic") {
|
|
26
|
+
await writeText(join(spec.filesDir, "review.md"), "# Review\n\nAPPROVED\n\nNo blocking findings in mock review.\n");
|
|
27
|
+
const featureDir = featureDirFromPrompt(spec.prompt);
|
|
28
|
+
if (featureDir) {
|
|
29
|
+
const findingsPath = join(featureDir, "critic-findings.jsonl");
|
|
30
|
+
if (!(await readTextIfExists(findingsPath)).trim()) {
|
|
31
|
+
await writeText(findingsPath, "");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (spec.role === "main") {
|
|
36
|
+
await appendText(spec.outputLogPath, `Mock simple response for: ${spec.prompt.trim()}\n`);
|
|
37
|
+
}
|
|
38
|
+
await appendText(spec.outputLogPath, `[mock:${spec.role}] done\n`);
|
|
39
|
+
await setStatus(spec, "done", "mock-done", `${spec.role} mock worker done`, nativeSessionId);
|
|
40
|
+
return {
|
|
41
|
+
workerId: spec.workerId,
|
|
42
|
+
exitCode: 0,
|
|
43
|
+
signal: null
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function featureDirFromPrompt(prompt) {
|
|
48
|
+
const line = prompt.split("\n").find((item) => item.startsWith("Feature directory: "));
|
|
49
|
+
return line ? line.replace("Feature directory: ", "").trim() : null;
|
|
50
|
+
}
|
|
51
|
+
async function setStatus(spec, state, phase, summary, nativeSessionId) {
|
|
52
|
+
await writeJson(spec.statusPath, {
|
|
53
|
+
worker_id: spec.workerId,
|
|
54
|
+
role: spec.role,
|
|
55
|
+
engine: spec.engine,
|
|
56
|
+
state,
|
|
57
|
+
phase,
|
|
58
|
+
last_event_at: new Date().toISOString(),
|
|
59
|
+
summary,
|
|
60
|
+
...(nativeSessionId ? { native_session_id: nativeSessionId } : {})
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { accessSync, chmodSync, constants, existsSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { spawn } from "node-pty";
|
|
7
|
+
import { pathExists, readJson, readTextIfExists, writeJson } from "../core/file-store.js";
|
|
8
|
+
import { NativeSessionSchema, TaskMetaSchema } from "../domain/schemas.js";
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
export async function buildNativeAttachLaunch(input) {
|
|
11
|
+
const nativeSession = await readWorkerNativeSession(input.worker);
|
|
12
|
+
const workerConfig = input.config.workers[input.worker.engine];
|
|
13
|
+
const modelConfig = workerConfig.model;
|
|
14
|
+
return {
|
|
15
|
+
command: workerConfig.interactive.command,
|
|
16
|
+
args: workerConfig.interactive.args.map((arg) => renderTemplate(arg, nativeSession.session_id, modelConfig)),
|
|
17
|
+
cwd: nativeSession.cwd,
|
|
18
|
+
sessionId: nativeSession.session_id,
|
|
19
|
+
label: input.worker.label
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function startNativeAttachProcess(launch, handlers = {}) {
|
|
23
|
+
ensureNodePtySpawnHelperExecutable();
|
|
24
|
+
const child = spawn(launch.command, launch.args, {
|
|
25
|
+
name: "xterm-256color",
|
|
26
|
+
cols: launch.cols ?? process.stdout.columns ?? 120,
|
|
27
|
+
rows: launch.rows ?? process.stdout.rows ?? 30,
|
|
28
|
+
cwd: launch.cwd,
|
|
29
|
+
env: {
|
|
30
|
+
...process.env,
|
|
31
|
+
TERM: process.env.TERM || "xterm-256color"
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
child.onData((chunk) => {
|
|
35
|
+
handlers.onOutput?.(chunk);
|
|
36
|
+
});
|
|
37
|
+
child.onExit(({ exitCode }) => {
|
|
38
|
+
handlers.onClose?.(exitCode);
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
write(input) {
|
|
42
|
+
child.write(input);
|
|
43
|
+
},
|
|
44
|
+
kill() {
|
|
45
|
+
child.kill("SIGTERM");
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function ensureNodePtySpawnHelperExecutable() {
|
|
50
|
+
if (process.platform === "win32") {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const packageRoot = dirname(dirname(require.resolve("node-pty")));
|
|
54
|
+
const helperPath = join(packageRoot, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper");
|
|
55
|
+
if (!existsSync(helperPath)) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
accessSync(helperPath, constants.X_OK);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
chmodSync(helperPath, 0o755);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function readWorkerNativeSession(worker) {
|
|
66
|
+
const workerDir = dirname(worker.statusPath);
|
|
67
|
+
const nativePath = join(workerDir, "native-session.json");
|
|
68
|
+
if (!(await pathExists(nativePath))) {
|
|
69
|
+
const recovered = await recoverClaudeNativeSession(worker, workerDir);
|
|
70
|
+
if (recovered) {
|
|
71
|
+
await writeJson(nativePath, NativeSessionSchema.parse(recovered));
|
|
72
|
+
return recovered;
|
|
73
|
+
}
|
|
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.`);
|
|
75
|
+
}
|
|
76
|
+
const record = await readJson(nativePath, NativeSessionSchema);
|
|
77
|
+
if (record.engine !== worker.engine) {
|
|
78
|
+
throw new Error(`Native session engine mismatch for ${worker.label}: expected ${worker.engine}, got ${record.engine}`);
|
|
79
|
+
}
|
|
80
|
+
return record;
|
|
81
|
+
}
|
|
82
|
+
async function recoverClaudeNativeSession(worker, workerDir) {
|
|
83
|
+
if (worker.engine !== "claude") {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const prompt = await readTextIfExists(join(workerDir, "prompt.md"));
|
|
87
|
+
if (!prompt.trim()) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const taskDir = dirname(workerDir);
|
|
91
|
+
const metaPath = join(taskDir, "meta.json");
|
|
92
|
+
const cwd = (await pathExists(metaPath)) ? (await readJson(metaPath, TaskMetaSchema)).cwd : null;
|
|
93
|
+
if (!cwd) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const match = await findClaudeProjectSession({
|
|
97
|
+
cwd,
|
|
98
|
+
prompt
|
|
99
|
+
});
|
|
100
|
+
if (!match) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
engine: "claude",
|
|
105
|
+
role: worker.role,
|
|
106
|
+
worker_id: worker.id,
|
|
107
|
+
session_id: match.sessionId,
|
|
108
|
+
scope: "task",
|
|
109
|
+
cwd,
|
|
110
|
+
created_at: match.timestamp,
|
|
111
|
+
last_used_at: match.timestamp,
|
|
112
|
+
source: "claude-project-log"
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async function findClaudeProjectSession(input) {
|
|
116
|
+
const projectDir = join(claudeProjectsDir(), claudeProjectSlug(input.cwd));
|
|
117
|
+
if (!(await pathExists(projectDir))) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const entries = await readdir(projectDir, { withFileTypes: true });
|
|
121
|
+
const matches = [];
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const candidate = await matchClaudeSessionFile(join(projectDir, entry.name), input);
|
|
127
|
+
if (candidate) {
|
|
128
|
+
matches.push(candidate);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return matches.sort((left, right) => left.timestamp.localeCompare(right.timestamp)).at(-1) ?? null;
|
|
132
|
+
}
|
|
133
|
+
async function matchClaudeSessionFile(path, input) {
|
|
134
|
+
const lines = (await readTextIfExists(path)).split(/\r?\n/).filter(Boolean);
|
|
135
|
+
for (const line of lines) {
|
|
136
|
+
const parsed = safeParseJson(line);
|
|
137
|
+
if (!parsed || typeof parsed !== "object") {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const record = parsed;
|
|
141
|
+
if (record.cwd !== input.cwd && record.type !== "queue-operation") {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (extractClaudePrompt(record) !== input.prompt) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const sessionId = typeof record.sessionId === "string" ? record.sessionId : null;
|
|
148
|
+
const timestamp = typeof record.timestamp === "string" ? record.timestamp : null;
|
|
149
|
+
if (sessionId && timestamp) {
|
|
150
|
+
return { sessionId, timestamp };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
function extractClaudePrompt(record) {
|
|
156
|
+
if (typeof record.content === "string") {
|
|
157
|
+
return record.content;
|
|
158
|
+
}
|
|
159
|
+
const message = record.message;
|
|
160
|
+
if (!message || typeof message !== "object") {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const content = message.content;
|
|
164
|
+
return typeof content === "string" ? content : null;
|
|
165
|
+
}
|
|
166
|
+
function claudeProjectsDir() {
|
|
167
|
+
return process.env.PARALLEL_CODEX_CLAUDE_PROJECTS_DIR ?? join(homedir(), ".claude", "projects");
|
|
168
|
+
}
|
|
169
|
+
function claudeProjectSlug(cwd) {
|
|
170
|
+
return cwd.replace(/[^A-Za-z0-9]/g, "-");
|
|
171
|
+
}
|
|
172
|
+
function safeParseJson(value) {
|
|
173
|
+
try {
|
|
174
|
+
return JSON.parse(value);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function renderTemplate(value, sessionId, modelConfig) {
|
|
181
|
+
return value
|
|
182
|
+
.replaceAll("{sessionId}", sessionId)
|
|
183
|
+
.replaceAll("{model}", modelConfig?.name ?? "")
|
|
184
|
+
.replaceAll("{provider}", modelConfig?.provider ?? "")
|
|
185
|
+
.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => process.env[name] ?? "");
|
|
186
|
+
}
|
|
187
|
+
export function supportsNativeAttach(engine) {
|
|
188
|
+
return engine === "codex" || engine === "claude" || engine === "mock";
|
|
189
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { appendText, writeJson } from "../core/file-store.js";
|
|
3
|
+
export class ProcessWorkerAdapter {
|
|
4
|
+
name;
|
|
5
|
+
command;
|
|
6
|
+
args;
|
|
7
|
+
defaults;
|
|
8
|
+
constructor(command, args, name = "mock", defaults = {}) {
|
|
9
|
+
this.command = command;
|
|
10
|
+
this.args = args;
|
|
11
|
+
this.name = name;
|
|
12
|
+
this.defaults = defaults;
|
|
13
|
+
}
|
|
14
|
+
async run(spec) {
|
|
15
|
+
const model = spec.modelConfig ?? this.defaults.model;
|
|
16
|
+
const launch = buildLaunch(this.args, spec.nativeSession, spec.nativeSessionConfig, model);
|
|
17
|
+
const runSpec = {
|
|
18
|
+
...spec,
|
|
19
|
+
timeoutMs: spec.timeoutMs ?? this.defaults.timeoutMs,
|
|
20
|
+
idleTimeoutMs: spec.idleTimeoutMs ?? this.defaults.idleTimeoutMs,
|
|
21
|
+
firstOutputTimeoutMs: spec.firstOutputTimeoutMs ?? this.defaults.firstOutputTimeoutMs,
|
|
22
|
+
nativeSession: launch.nativeSession,
|
|
23
|
+
modelConfig: model
|
|
24
|
+
};
|
|
25
|
+
const first = await this.runAttempt(runSpec, launch);
|
|
26
|
+
if (!shouldFallbackToNewNativeSession(first, runSpec.nativeSessionConfig)) {
|
|
27
|
+
return first.result;
|
|
28
|
+
}
|
|
29
|
+
const retiredSessionId = launch.nativeSession?.session_id;
|
|
30
|
+
if (retiredSessionId) {
|
|
31
|
+
await runSpec.onNativeSessionRetired?.(retiredSessionId, first.output);
|
|
32
|
+
}
|
|
33
|
+
await appendText(runSpec.outputLogPath, `\nNative resume for ${retiredSessionId ?? "unknown session"} is unrecoverable; starting a fresh native session.\n`);
|
|
34
|
+
const freshLaunch = buildFreshLaunch(this.args, model);
|
|
35
|
+
return (await this.runAttempt({
|
|
36
|
+
...runSpec,
|
|
37
|
+
nativeSession: null
|
|
38
|
+
}, freshLaunch, {
|
|
39
|
+
initialNativeSessionId: undefined,
|
|
40
|
+
startPhase: "native-resume-fallback",
|
|
41
|
+
startSummary: `${this.command} starting fresh session after unrecoverable native resume`
|
|
42
|
+
})).result;
|
|
43
|
+
}
|
|
44
|
+
async runAttempt(runSpec, launch, options = {}) {
|
|
45
|
+
await setStatus(runSpec, "starting", options.startPhase ?? "process-starting", options.startSummary ?? `Starting ${this.command}`);
|
|
46
|
+
await appendText(runSpec.outputLogPath, `$ ${this.command} ${launch.args.join(" ")}\n`);
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const child = spawn(this.command, launch.args, {
|
|
49
|
+
cwd: runSpec.cwd,
|
|
50
|
+
env: {
|
|
51
|
+
...process.env,
|
|
52
|
+
...buildModelEnv(runSpec.modelConfig),
|
|
53
|
+
PARALLEL_CODEX_WORKER_ID: runSpec.workerId,
|
|
54
|
+
PARALLEL_CODEX_ROLE: runSpec.role,
|
|
55
|
+
PARALLEL_CODEX_FILES_DIR: runSpec.filesDir
|
|
56
|
+
},
|
|
57
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
58
|
+
});
|
|
59
|
+
let settled = false;
|
|
60
|
+
let timeout;
|
|
61
|
+
let idleTimeout;
|
|
62
|
+
let firstOutputTimeout;
|
|
63
|
+
let terminalPhase;
|
|
64
|
+
let terminalSummary;
|
|
65
|
+
let outputWrites = Promise.resolve();
|
|
66
|
+
let detectedNativeSessionId = options.initialNativeSessionId ?? runSpec.nativeSession?.session_id;
|
|
67
|
+
let sawOutput = false;
|
|
68
|
+
const outputChunks = [];
|
|
69
|
+
const finish = async (result) => {
|
|
70
|
+
if (settled) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
settled = true;
|
|
74
|
+
if (timeout) {
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
}
|
|
77
|
+
if (idleTimeout) {
|
|
78
|
+
clearTimeout(idleTimeout);
|
|
79
|
+
}
|
|
80
|
+
if (firstOutputTimeout) {
|
|
81
|
+
clearTimeout(firstOutputTimeout);
|
|
82
|
+
}
|
|
83
|
+
await outputWrites;
|
|
84
|
+
const phase = terminalPhase ?? (launch.isResume && result.exitCode !== 0 ? "native-resume-failed" : "process-exited");
|
|
85
|
+
const summary = terminalSummary ??
|
|
86
|
+
(launch.isResume && result.exitCode !== 0
|
|
87
|
+
? `${this.command} native resume exited with code ${result.exitCode}`
|
|
88
|
+
: `${this.command} exited with code ${result.exitCode}`);
|
|
89
|
+
await setStatus(runSpec, result.exitCode === 0 ? "done" : "failed", phase, summary, detectedNativeSessionId);
|
|
90
|
+
resolve({
|
|
91
|
+
result,
|
|
92
|
+
output: outputChunks.join(""),
|
|
93
|
+
launch
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
const resetIdleTimeout = () => {
|
|
97
|
+
if (!runSpec.idleTimeoutMs || runSpec.idleTimeoutMs <= 0 || settled) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (idleTimeout) {
|
|
101
|
+
clearTimeout(idleTimeout);
|
|
102
|
+
}
|
|
103
|
+
idleTimeout = setTimeout(() => {
|
|
104
|
+
terminalPhase = "process-idle-timeout";
|
|
105
|
+
terminalSummary = `${this.command} produced no output for ${runSpec.idleTimeoutMs}ms`;
|
|
106
|
+
void appendText(runSpec.outputLogPath, `\nProcess idle timed out after ${runSpec.idleTimeoutMs}ms\n`);
|
|
107
|
+
void setStatus(runSpec, "failed", terminalPhase, terminalSummary);
|
|
108
|
+
child.kill("SIGTERM");
|
|
109
|
+
}, runSpec.idleTimeoutMs);
|
|
110
|
+
};
|
|
111
|
+
const recordOutput = (chunk) => {
|
|
112
|
+
sawOutput = true;
|
|
113
|
+
if (firstOutputTimeout) {
|
|
114
|
+
clearTimeout(firstOutputTimeout);
|
|
115
|
+
firstOutputTimeout = undefined;
|
|
116
|
+
}
|
|
117
|
+
const text = chunk.toString("utf8");
|
|
118
|
+
outputChunks.push(text);
|
|
119
|
+
outputWrites = outputWrites.then(async () => {
|
|
120
|
+
await appendText(runSpec.outputLogPath, text);
|
|
121
|
+
const sessionId = detectNativeSessionId(text);
|
|
122
|
+
if (sessionId && sessionId !== detectedNativeSessionId && runSpec.nativeSessionConfig?.detectSessionId !== false) {
|
|
123
|
+
detectedNativeSessionId = sessionId;
|
|
124
|
+
await runSpec.onNativeSession?.(sessionId);
|
|
125
|
+
}
|
|
126
|
+
if (!settled) {
|
|
127
|
+
await setStatus(runSpec, "running", "process-output", summarizeOutput(text), detectedNativeSessionId);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
resetIdleTimeout();
|
|
131
|
+
};
|
|
132
|
+
child.stdout.on("data", (chunk) => {
|
|
133
|
+
recordOutput(chunk);
|
|
134
|
+
});
|
|
135
|
+
child.stderr.on("data", (chunk) => {
|
|
136
|
+
recordOutput(chunk);
|
|
137
|
+
});
|
|
138
|
+
child.on("error", (error) => {
|
|
139
|
+
if (settled) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
settled = true;
|
|
143
|
+
if (timeout) {
|
|
144
|
+
clearTimeout(timeout);
|
|
145
|
+
}
|
|
146
|
+
if (idleTimeout) {
|
|
147
|
+
clearTimeout(idleTimeout);
|
|
148
|
+
}
|
|
149
|
+
if (firstOutputTimeout) {
|
|
150
|
+
clearTimeout(firstOutputTimeout);
|
|
151
|
+
}
|
|
152
|
+
void setStatus(runSpec, "failed", "process-error", error.message, detectedNativeSessionId).finally(() => reject(error));
|
|
153
|
+
});
|
|
154
|
+
child.on("close", (code, signal) => {
|
|
155
|
+
void finish({
|
|
156
|
+
workerId: runSpec.workerId,
|
|
157
|
+
exitCode: code ?? 1,
|
|
158
|
+
signal
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
if (runSpec.timeoutMs && runSpec.timeoutMs > 0) {
|
|
162
|
+
timeout = setTimeout(() => {
|
|
163
|
+
terminalPhase = "process-timeout";
|
|
164
|
+
terminalSummary = `${this.command} exceeded ${runSpec.timeoutMs}ms`;
|
|
165
|
+
void setStatus(runSpec, "failed", terminalPhase, terminalSummary, detectedNativeSessionId);
|
|
166
|
+
child.kill("SIGTERM");
|
|
167
|
+
void appendText(runSpec.outputLogPath, `\nProcess timed out after ${runSpec.timeoutMs}ms\n`);
|
|
168
|
+
}, runSpec.timeoutMs);
|
|
169
|
+
}
|
|
170
|
+
if (runSpec.firstOutputTimeoutMs && runSpec.firstOutputTimeoutMs > 0) {
|
|
171
|
+
firstOutputTimeout = setTimeout(() => {
|
|
172
|
+
if (sawOutput || settled) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
terminalPhase = "process-first-output-timeout";
|
|
176
|
+
terminalSummary = `${this.command} produced no first output for ${runSpec.firstOutputTimeoutMs}ms`;
|
|
177
|
+
void setStatus(runSpec, "failed", terminalPhase, terminalSummary, detectedNativeSessionId);
|
|
178
|
+
child.kill("SIGTERM");
|
|
179
|
+
void appendText(runSpec.outputLogPath, `\nProcess produced no first output after ${runSpec.firstOutputTimeoutMs}ms\n`);
|
|
180
|
+
}, runSpec.firstOutputTimeoutMs);
|
|
181
|
+
}
|
|
182
|
+
void setStatus(runSpec, "running", "process-running", `${this.command} running`, detectedNativeSessionId);
|
|
183
|
+
resetIdleTimeout();
|
|
184
|
+
child.stdin.write(runSpec.prompt);
|
|
185
|
+
child.stdin.end();
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function buildLaunch(defaultArgs, nativeSession, nativeSessionConfig, modelConfig) {
|
|
190
|
+
const modelArgs = buildModelArgs(modelConfig);
|
|
191
|
+
if (!nativeSession || !nativeSessionConfig?.enabled || nativeSessionConfig.resumeArgs.length === 0) {
|
|
192
|
+
return buildFreshLaunch(defaultArgs, modelConfig);
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
args: [
|
|
196
|
+
...nativeSessionConfig.resumeArgs.map((arg) => renderTemplate(arg, nativeSession.session_id, modelConfig)),
|
|
197
|
+
...modelArgs
|
|
198
|
+
],
|
|
199
|
+
isResume: true,
|
|
200
|
+
nativeSession
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function buildFreshLaunch(defaultArgs, modelConfig) {
|
|
204
|
+
return {
|
|
205
|
+
args: [...defaultArgs, ...buildModelArgs(modelConfig)],
|
|
206
|
+
isResume: false,
|
|
207
|
+
nativeSession: null
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function buildModelArgs(modelConfig) {
|
|
211
|
+
if (!modelConfig || modelConfig.args.length === 0) {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
return modelConfig.args.map((arg) => renderTemplate(arg, undefined, modelConfig));
|
|
215
|
+
}
|
|
216
|
+
function buildModelEnv(modelConfig) {
|
|
217
|
+
if (!modelConfig?.env) {
|
|
218
|
+
return {};
|
|
219
|
+
}
|
|
220
|
+
return Object.fromEntries(Object.entries(modelConfig.env).map(([key, value]) => [key, renderTemplate(value, undefined, modelConfig)]));
|
|
221
|
+
}
|
|
222
|
+
function shouldFallbackToNewNativeSession(attempt, nativeSessionConfig) {
|
|
223
|
+
return (attempt.launch.isResume &&
|
|
224
|
+
attempt.result.exitCode !== 0 &&
|
|
225
|
+
nativeSessionConfig?.fallback === "new" &&
|
|
226
|
+
isUnrecoverableNativeResumeOutput(attempt.output));
|
|
227
|
+
}
|
|
228
|
+
function isUnrecoverableNativeResumeOutput(output) {
|
|
229
|
+
const normalized = output.toLowerCase();
|
|
230
|
+
return (normalized.includes("context window") ||
|
|
231
|
+
normalized.includes("ran out of room") ||
|
|
232
|
+
normalized.includes("clear earlier history") ||
|
|
233
|
+
normalized.includes("start a new thread"));
|
|
234
|
+
}
|
|
235
|
+
function renderTemplate(value, sessionId, modelConfig) {
|
|
236
|
+
return value
|
|
237
|
+
.replaceAll("{sessionId}", sessionId ?? "")
|
|
238
|
+
.replaceAll("{model}", modelConfig?.name ?? "")
|
|
239
|
+
.replaceAll("{provider}", modelConfig?.provider ?? "")
|
|
240
|
+
.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => process.env[name] ?? "");
|
|
241
|
+
}
|
|
242
|
+
function summarizeOutput(text) {
|
|
243
|
+
const lines = text
|
|
244
|
+
.split(/\r?\n/)
|
|
245
|
+
.map((line) => line.trim())
|
|
246
|
+
.filter(Boolean);
|
|
247
|
+
const summary = lines.at(-1) ?? "Worker produced output";
|
|
248
|
+
return summary.length > 160 ? `${summary.slice(0, 157)}...` : summary;
|
|
249
|
+
}
|
|
250
|
+
async function setStatus(spec, state, phase, summary, nativeSessionId) {
|
|
251
|
+
await writeJson(spec.statusPath, {
|
|
252
|
+
worker_id: spec.workerId,
|
|
253
|
+
role: spec.role,
|
|
254
|
+
engine: spec.engine,
|
|
255
|
+
state,
|
|
256
|
+
phase,
|
|
257
|
+
last_event_at: new Date().toISOString(),
|
|
258
|
+
summary,
|
|
259
|
+
...(nativeSessionId ? { native_session_id: nativeSessionId } : {})
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
function detectNativeSessionId(text) {
|
|
263
|
+
const match = text.match(/\b(?:session id|session_id|session)\s*[:=]\s*([A-Za-z0-9._:@-]{4,})/i);
|
|
264
|
+
return match?.[1] ?? null;
|
|
265
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { MockWorkerAdapter } from "./mock-adapter.js";
|
|
2
|
+
import { ProcessWorkerAdapter } from "./process-adapter.js";
|
|
3
|
+
export function createWorkerRegistry(config) {
|
|
4
|
+
return new Map([
|
|
5
|
+
["mock", new MockWorkerAdapter()],
|
|
6
|
+
[
|
|
7
|
+
"codex",
|
|
8
|
+
new ProcessWorkerAdapter(config.workers.codex.command, config.workers.codex.args, "codex", {
|
|
9
|
+
timeoutMs: config.workers.codex.timeoutMs,
|
|
10
|
+
idleTimeoutMs: config.workers.codex.idleTimeoutMs,
|
|
11
|
+
firstOutputTimeoutMs: config.workers.codex.firstOutputTimeoutMs,
|
|
12
|
+
model: config.workers.codex.model
|
|
13
|
+
})
|
|
14
|
+
],
|
|
15
|
+
[
|
|
16
|
+
"claude",
|
|
17
|
+
new ProcessWorkerAdapter(config.workers.claude.command, config.workers.claude.args, "claude", {
|
|
18
|
+
timeoutMs: config.workers.claude.timeoutMs,
|
|
19
|
+
idleTimeoutMs: config.workers.claude.idleTimeoutMs,
|
|
20
|
+
firstOutputTimeoutMs: config.workers.claude.firstOutputTimeoutMs,
|
|
21
|
+
model: config.workers.claude.model
|
|
22
|
+
})
|
|
23
|
+
]
|
|
24
|
+
]);
|
|
25
|
+
}
|
|
26
|
+
export function getAdapter(registry, engine) {
|
|
27
|
+
const adapter = registry.get(engine);
|
|
28
|
+
if (!adapter) {
|
|
29
|
+
throw new Error(`No worker adapter registered for engine: ${engine}`);
|
|
30
|
+
}
|
|
31
|
+
return adapter;
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "parallel-codex-tui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A TypeScript TUI wrapper for routed parallel coding with Codex and Claude workers.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"codex",
|
|
9
|
+
"claude",
|
|
10
|
+
"tui",
|
|
11
|
+
"parallel-coding",
|
|
12
|
+
"agent-orchestration"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22.5.0"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"parallel-codex-tui": "dist/cli.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist/",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE",
|
|
24
|
+
".parallel-codex/config.example.toml"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"dev": "tsx src/cli.tsx",
|
|
28
|
+
"build": "tsc -p tsconfig.json",
|
|
29
|
+
"postbuild": "node -e \"require('node:fs').chmodSync('dist/cli.js', 0o755)\"",
|
|
30
|
+
"prepack": "npm run build",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"test:watch": "vitest",
|
|
33
|
+
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
|
34
|
+
"lint": "tsc --noEmit -p tsconfig.typecheck.json"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@iarna/toml": "^2.2.5",
|
|
38
|
+
"@xterm/headless": "^6.0.0",
|
|
39
|
+
"ink": "^5.0.1",
|
|
40
|
+
"ink-text-input": "^6.0.0",
|
|
41
|
+
"node-pty": "^1.1.0",
|
|
42
|
+
"react": "^18.3.1",
|
|
43
|
+
"zod": "^3.25.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^22.13.0",
|
|
47
|
+
"@types/react": "^18.3.0",
|
|
48
|
+
"ink-testing-library": "^3.0.0",
|
|
49
|
+
"tsx": "^4.19.0",
|
|
50
|
+
"typescript": "^5.8.0",
|
|
51
|
+
"vitest": "^3.2.0"
|
|
52
|
+
}
|
|
53
|
+
}
|