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.
Files changed (40) hide show
  1. package/.parallel-codex/config.example.toml +62 -0
  2. package/LICENSE +21 -0
  3. package/README.md +150 -0
  4. package/dist/bootstrap.js +25 -0
  5. package/dist/cli-args.js +26 -0
  6. package/dist/cli.js +48 -0
  7. package/dist/core/config.js +304 -0
  8. package/dist/core/file-store.js +56 -0
  9. package/dist/core/paths.js +17 -0
  10. package/dist/core/router.js +151 -0
  11. package/dist/core/session-index.js +180 -0
  12. package/dist/core/session-manager.js +264 -0
  13. package/dist/doctor.js +121 -0
  14. package/dist/domain/schemas.js +78 -0
  15. package/dist/orchestrator/collaboration-channel.js +103 -0
  16. package/dist/orchestrator/orchestrator.js +545 -0
  17. package/dist/orchestrator/prompts.js +124 -0
  18. package/dist/orchestrator/supervisor-summary.js +38 -0
  19. package/dist/tui/App.js +740 -0
  20. package/dist/tui/AppShell.js +129 -0
  21. package/dist/tui/InputBar.js +141 -0
  22. package/dist/tui/StatusBar.js +288 -0
  23. package/dist/tui/TerminalOutput.js +22 -0
  24. package/dist/tui/WorkerOutputView.js +4015 -0
  25. package/dist/tui/chat-input.js +37 -0
  26. package/dist/tui/display-width.js +132 -0
  27. package/dist/tui/keyboard.js +38 -0
  28. package/dist/tui/native-input.js +120 -0
  29. package/dist/tui/raw-input-decoder.js +18 -0
  30. package/dist/tui/scrolling.js +25 -0
  31. package/dist/tui/status-line.js +90 -0
  32. package/dist/tui/task-memory.js +26 -0
  33. package/dist/tui/terminal-screen.js +168 -0
  34. package/dist/version.js +1 -0
  35. package/dist/workers/mock-adapter.js +62 -0
  36. package/dist/workers/native-attach.js +189 -0
  37. package/dist/workers/process-adapter.js +265 -0
  38. package/dist/workers/registry.js +32 -0
  39. package/dist/workers/types.js +1 -0
  40. 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
+ }