parallel-codex-tui 0.1.0 → 0.1.3
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/README.md +39 -1
- package/dist/bootstrap.js +17 -5
- package/dist/cli-args.js +65 -14
- package/dist/cli-workspace.js +104 -0
- package/dist/cli.js +82 -24
- package/dist/core/app-root.js +8 -0
- package/dist/core/config.js +38 -0
- package/dist/core/file-store.js +11 -0
- package/dist/core/paths.js +3 -0
- package/dist/core/router.js +13 -9
- package/dist/core/session-index.js +53 -9
- package/dist/core/session-manager.js +101 -9
- package/dist/core/workspace.js +118 -0
- package/dist/doctor.js +58 -24
- package/dist/orchestrator/orchestrator.js +37 -19
- package/dist/tui/App.js +10 -8
- package/dist/tui/task-memory.js +3 -0
- package/dist/version.js +1 -1
- package/dist/workers/native-attach.js +69 -5
- package/dist/workers/native-session-detection.js +17 -0
- package/dist/workers/process-adapter.js +11 -5
- package/package.json +14 -2
package/README.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
A standalone TypeScript TUI wrapper for routed parallel coding workflows. It keeps a main chat open while Codex routes larger tasks into Judge, Actor, and Critic workers that can write prompts, logs, session metadata, and outputs to disk.
|
|
4
4
|
|
|
5
|
+
Built with Codex-assisted development.
|
|
6
|
+
|
|
5
7
|
## Requirements
|
|
6
8
|
|
|
7
|
-
- Node.js
|
|
9
|
+
- Node.js 26+.
|
|
8
10
|
- Codex CLI available as `codex` for Codex routing and Codex workers.
|
|
9
11
|
- Claude CLI available as `claude` only when you configure Claude workers.
|
|
10
12
|
- A project workspace you are comfortable letting configured workers edit.
|
|
@@ -24,6 +26,15 @@ parallel-codex-tui --doctor
|
|
|
24
26
|
parallel-codex-tui --workspace /path/to/project
|
|
25
27
|
```
|
|
26
28
|
|
|
29
|
+
Startup resolves the worker project before routing:
|
|
30
|
+
|
|
31
|
+
- `--workspace <path>` opens that project when it already exists.
|
|
32
|
+
- If `--workspace <path>` does not exist in an interactive terminal, the CLI shows remembered projects from `.parallel-codex/workspaces.json`; press Enter to create the requested folder, pick a remembered project, or enter another path.
|
|
33
|
+
- If `--workspace <path>` points to an existing file, the CLI reports that it is not a directory and will not use that file path as the default folder to create.
|
|
34
|
+
- Without `--workspace`, an interactive terminal shows remembered projects from `.parallel-codex/workspaces.json`; choose a number or enter `n <path>` to create/open another folder.
|
|
35
|
+
- In non-interactive startup, the CLI reuses the last remembered workspace, falls back to the current directory if none was saved, and creates an explicit `--workspace` path if needed.
|
|
36
|
+
- The selected workspace is prepared before any router or worker process starts.
|
|
37
|
+
|
|
27
38
|
From a source checkout, install dependencies and link the local binary:
|
|
28
39
|
|
|
29
40
|
```bash
|
|
@@ -38,6 +49,8 @@ For development without linking, run:
|
|
|
38
49
|
npm run dev -- --workspace /path/to/project
|
|
39
50
|
```
|
|
40
51
|
|
|
52
|
+
CLI options with values can also be passed as `--workspace=/path/to/project`, `--app-root=/path/to/app`, `--task=task-id`, `-w=/path/to/project`, and `-t=task-id`.
|
|
53
|
+
|
|
41
54
|
Check available flags or the installed version without starting the TUI:
|
|
42
55
|
|
|
43
56
|
```bash
|
|
@@ -46,6 +59,8 @@ parallel-codex-tui --doctor
|
|
|
46
59
|
parallel-codex-tui --version
|
|
47
60
|
```
|
|
48
61
|
|
|
62
|
+
`--doctor` checks the configured commands and any `{env:NAME}` references in active worker model environment settings before workers start.
|
|
63
|
+
|
|
49
64
|
## Quick Start
|
|
50
65
|
|
|
51
66
|
Create a local config in the app root:
|
|
@@ -79,6 +94,7 @@ parallel-codex-tui --workspace /path/to/project
|
|
|
79
94
|
## Behavior
|
|
80
95
|
|
|
81
96
|
- Requests are routed by Codex by default, with a configured simple/complex fallback if the router process fails.
|
|
97
|
+
- Router classification only receives the user request; workspace selection and session files are kept out of the router prompt.
|
|
82
98
|
- Simple requests stay in the main TUI flow and do not create Judge, Actor, or Critic workers.
|
|
83
99
|
- Complex requests create a session under `.parallel-codex/sessions/`.
|
|
84
100
|
- Complex requests run Judge -> Actor -> Critic.
|
|
@@ -122,6 +138,14 @@ Configure Codex and Claude commands in `.parallel-codex/config.toml`:
|
|
|
122
138
|
command = "codex"
|
|
123
139
|
args = ["exec", "--skip-git-repo-check", "--sandbox", "workspace-write", "--color", "never", "-"]
|
|
124
140
|
|
|
141
|
+
[workers.codex.model]
|
|
142
|
+
name = "gpt-5"
|
|
143
|
+
provider = "openai"
|
|
144
|
+
args = ["--model", "{model}"]
|
|
145
|
+
|
|
146
|
+
[workers.codex.model.env]
|
|
147
|
+
OPENAI_API_KEY = "{env:OPENAI_API_KEY}"
|
|
148
|
+
|
|
125
149
|
[workers.codex.interactive]
|
|
126
150
|
command = "codex"
|
|
127
151
|
args = ["resume", "{sessionId}"]
|
|
@@ -143,8 +167,22 @@ While viewing a worker log, press `Ctrl+O` to attach to the worker's native sess
|
|
|
143
167
|
|
|
144
168
|
If a native resume fails because the underlying CLI reports that its context window is full, configure `fallback = "new"` under `[workers.<engine>.nativeSession]`. The old native session is archived as `native-session.retired.json`, removed from active use, and the worker is retried once with the normal fresh-session command.
|
|
145
169
|
|
|
170
|
+
## Release
|
|
171
|
+
|
|
172
|
+
GitHub Actions runs CI on pushes and pull requests to `main`.
|
|
173
|
+
|
|
174
|
+
Configure npm Trusted Publishing for this GitHub Actions workflow with organization/user `allendred`, repository `parallel-codex-tui`, and workflow filename `release.yml`. The release job installs npm `^11.5.1`, which is required for trusted publishing. Alternatively, add an `NPM_TOKEN` repository secret with npm publish permission; it must be an npm automation token so CI can publish without an interactive one-time password. If npm returns `EOTP`, replace the secret with an automation token or remove the secret and use Trusted Publishing. To publish a release, update `package.json` and `src/version.ts` to the same version, then push a matching tag:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
git tag v0.1.3
|
|
178
|
+
git push origin v0.1.3
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
You can also run the Release workflow manually and enter the same tag value. The release tag must match `package.json`; for example, package version `0.1.3` requires tag `v0.1.3`.
|
|
182
|
+
|
|
146
183
|
## Publishing Hygiene
|
|
147
184
|
|
|
148
185
|
- `.parallel-codex/config.toml` is local-only and ignored.
|
|
186
|
+
- `.parallel-codex/last-workspace` and `.parallel-codex/workspaces.json` are local workspace-selection state and are ignored.
|
|
149
187
|
- `.parallel-codex/sessions/` contains task prompts, logs, native session ids, and worker output; never commit it.
|
|
150
188
|
- `docs/superpowers/` contains internal planning notes and is ignored for public releases.
|
package/dist/bootstrap.js
CHANGED
|
@@ -1,22 +1,34 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { prepareAppRoot } from "./core/app-root.js";
|
|
2
|
+
import { configPath, loadConfig, writeDefaultConfig } from "./core/config.js";
|
|
3
|
+
import { ensureDir, pathExists } from "./core/file-store.js";
|
|
4
|
+
import { routerRuntimeDir } from "./core/paths.js";
|
|
2
5
|
import { SessionIndex } from "./core/session-index.js";
|
|
3
6
|
import { SessionManager } from "./core/session-manager.js";
|
|
7
|
+
import { prepareWorkspace } from "./core/workspace.js";
|
|
4
8
|
import { Orchestrator } from "./orchestrator/orchestrator.js";
|
|
5
9
|
import { createWorkerRegistry } from "./workers/registry.js";
|
|
6
10
|
export async function createRuntime(appRoot, workspaceRoot = appRoot) {
|
|
11
|
+
await prepareAppRoot(appRoot);
|
|
12
|
+
if (!(await pathExists(configPath(appRoot)))) {
|
|
13
|
+
await writeDefaultConfig(appRoot);
|
|
14
|
+
}
|
|
7
15
|
const config = await loadConfig(appRoot);
|
|
8
|
-
const
|
|
16
|
+
const routerCwd = routerRuntimeDir(appRoot, config.dataDir);
|
|
17
|
+
await ensureDir(routerCwd);
|
|
18
|
+
const preparedWorkspace = await prepareWorkspace(appRoot, workspaceRoot);
|
|
19
|
+
const index = await SessionIndex.open(preparedWorkspace, config.dataDir);
|
|
9
20
|
await index.rebuildFromFiles();
|
|
10
21
|
const sessions = new SessionManager({
|
|
11
|
-
projectRoot:
|
|
22
|
+
projectRoot: preparedWorkspace,
|
|
12
23
|
dataDir: config.dataDir,
|
|
13
24
|
index
|
|
14
25
|
});
|
|
15
26
|
const workers = createWorkerRegistry(config);
|
|
16
|
-
const orchestrator = new Orchestrator(config, sessions, workers);
|
|
27
|
+
const orchestrator = new Orchestrator(config, sessions, workers, undefined, routerCwd);
|
|
17
28
|
return {
|
|
18
29
|
config,
|
|
19
|
-
workspaceRoot,
|
|
30
|
+
workspaceRoot: preparedWorkspace,
|
|
31
|
+
routerCwd,
|
|
20
32
|
index,
|
|
21
33
|
sessions,
|
|
22
34
|
workers,
|
package/dist/cli-args.js
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
const allowedValueOptions = new Set(["--app-root", "--workspace", "-w", "--task", "-t"]);
|
|
4
|
+
const allowedBooleanOptions = new Set(["--doctor", "--help", "-h", "--init", "--version", "-v"]);
|
|
2
5
|
export function parseCliArgs(args, cwd) {
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const taskId = taskFlagIndex >= 0 && args[taskFlagIndex + 1] ? args[taskFlagIndex + 1] : null;
|
|
6
|
+
const optionArgs = argsBeforeTerminator(args);
|
|
7
|
+
const appRootFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--app-root" || arg.startsWith("--app-root="));
|
|
8
|
+
const workspaceFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--workspace" || arg.startsWith("--workspace=") || arg === "-w" || arg.startsWith("-w="));
|
|
9
|
+
const taskFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--task" || arg.startsWith("--task=") || arg === "-t" || arg.startsWith("-t="));
|
|
10
|
+
const doctor = optionArgs.includes("--doctor");
|
|
11
|
+
const help = optionArgs.includes("--help") || optionArgs.includes("-h");
|
|
12
|
+
const init = optionArgs.includes("--init");
|
|
13
|
+
const version = optionArgs.includes("--version") || optionArgs.includes("-v");
|
|
14
|
+
const appRootValue = flagValue(optionArgs, appRootFlagIndex);
|
|
15
|
+
const appRoot = appRootValue ? resolvePathArg(cwd, appRootValue) : cwd;
|
|
16
|
+
const explicitWorkspace = flagValue(optionArgs, workspaceFlagIndex);
|
|
17
|
+
const workspaceRoot = explicitWorkspace ? resolvePathArg(cwd, explicitWorkspace) : cwd;
|
|
18
|
+
const taskId = flagValue(optionArgs, taskFlagIndex);
|
|
17
19
|
return {
|
|
18
20
|
appRoot,
|
|
19
21
|
doctor,
|
|
22
|
+
explicitWorkspace,
|
|
20
23
|
help,
|
|
21
24
|
init,
|
|
22
25
|
workspaceRoot,
|
|
@@ -24,3 +27,51 @@ export function parseCliArgs(args, cwd) {
|
|
|
24
27
|
version
|
|
25
28
|
};
|
|
26
29
|
}
|
|
30
|
+
function resolvePathArg(cwd, value) {
|
|
31
|
+
if (value === "~") {
|
|
32
|
+
return homedir();
|
|
33
|
+
}
|
|
34
|
+
if (value.startsWith("~/")) {
|
|
35
|
+
return resolve(homedir(), value.slice(2));
|
|
36
|
+
}
|
|
37
|
+
return resolve(cwd, value);
|
|
38
|
+
}
|
|
39
|
+
export function validateCliArgs(args) {
|
|
40
|
+
const errors = [];
|
|
41
|
+
for (const arg of argsBeforeTerminator(args)) {
|
|
42
|
+
if (!arg.startsWith("-") || arg === "-") {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (allowedBooleanOptions.has(arg) || allowedValueOptions.has(arg)) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const equalsIndex = arg.indexOf("=");
|
|
49
|
+
const optionName = equalsIndex >= 0 ? arg.slice(0, equalsIndex) : arg;
|
|
50
|
+
if (allowedValueOptions.has(optionName)) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
errors.push(`Unknown option: ${arg}`);
|
|
54
|
+
}
|
|
55
|
+
return errors;
|
|
56
|
+
}
|
|
57
|
+
function argsBeforeTerminator(args) {
|
|
58
|
+
const terminatorIndex = args.indexOf("--");
|
|
59
|
+
return terminatorIndex >= 0 ? args.slice(0, terminatorIndex) : args;
|
|
60
|
+
}
|
|
61
|
+
function lastFlagIndex(args, predicate) {
|
|
62
|
+
for (let index = args.length - 1; index >= 0; index -= 1) {
|
|
63
|
+
if (predicate(args[index] ?? "")) {
|
|
64
|
+
return index;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return -1;
|
|
68
|
+
}
|
|
69
|
+
function flagValue(args, flagIndex) {
|
|
70
|
+
const flag = flagIndex >= 0 ? args[flagIndex] : null;
|
|
71
|
+
const inlineMatch = flag?.match(/^-{1,2}[^=]+=(.*)$/);
|
|
72
|
+
if (inlineMatch) {
|
|
73
|
+
return inlineMatch[1] || null;
|
|
74
|
+
}
|
|
75
|
+
const value = flagIndex >= 0 ? args[flagIndex + 1] : null;
|
|
76
|
+
return value && !value.startsWith("-") ? value : null;
|
|
77
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { pathExists, pathIsDirectory } from "./core/file-store.js";
|
|
4
|
+
import { listWorkspaceChoices, resolveWorkspacePath, resolveWorkspaceSelection } from "./core/workspace.js";
|
|
5
|
+
export async function selectWorkspaceForCli(input) {
|
|
6
|
+
if (input.explicitWorkspace?.trim()) {
|
|
7
|
+
const explicit = resolveWorkspacePath(input.cwd, input.explicitWorkspace);
|
|
8
|
+
const stdin = input.stdin ?? process.stdin;
|
|
9
|
+
const stdout = input.stdout ?? process.stdout;
|
|
10
|
+
const explicitExists = await pathExists(explicit);
|
|
11
|
+
const explicitIsDirectory = explicitExists && (await pathIsDirectory(explicit));
|
|
12
|
+
if (!explicitIsDirectory && input.interactive !== false && shouldPromptForWorkspace(stdin, stdout)) {
|
|
13
|
+
return promptForWorkspace({
|
|
14
|
+
cwd: input.cwd,
|
|
15
|
+
choices: await listWorkspaceChoices(input.appRoot),
|
|
16
|
+
invalidExplicitWorkspace: {
|
|
17
|
+
path: explicit,
|
|
18
|
+
reason: explicitExists ? "file" : "missing"
|
|
19
|
+
},
|
|
20
|
+
stdin: stdin,
|
|
21
|
+
stdout: stdout
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return resolveWorkspaceSelection(input);
|
|
25
|
+
}
|
|
26
|
+
const stdin = input.stdin ?? process.stdin;
|
|
27
|
+
const stdout = input.stdout ?? process.stdout;
|
|
28
|
+
const choices = await listWorkspaceChoices(input.appRoot);
|
|
29
|
+
if (input.interactive === false || !shouldPromptForWorkspace(stdin, stdout)) {
|
|
30
|
+
return resolveWorkspaceSelection(input);
|
|
31
|
+
}
|
|
32
|
+
return promptForWorkspace({
|
|
33
|
+
cwd: input.cwd,
|
|
34
|
+
choices,
|
|
35
|
+
stdin: stdin,
|
|
36
|
+
stdout: stdout
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function shouldPromptForWorkspace(stdin, stdout) {
|
|
40
|
+
return Boolean(stdin.isTTY && stdout.isTTY);
|
|
41
|
+
}
|
|
42
|
+
async function promptForWorkspace(input) {
|
|
43
|
+
if (input.invalidExplicitWorkspace?.reason === "missing") {
|
|
44
|
+
input.stdout.write(`Workspace does not exist: ${input.invalidExplicitWorkspace.path}\n`);
|
|
45
|
+
}
|
|
46
|
+
else if (input.invalidExplicitWorkspace?.reason === "file") {
|
|
47
|
+
input.stdout.write(`Workspace is not a directory: ${input.invalidExplicitWorkspace.path}\n`);
|
|
48
|
+
}
|
|
49
|
+
if (input.choices.length === 0) {
|
|
50
|
+
input.stdout.write("No workspace selected yet.\n");
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
input.stdout.write("Select workspace:\n");
|
|
54
|
+
for (const [index, choice] of input.choices.entries()) {
|
|
55
|
+
input.stdout.write(` ${index + 1}. ${workspaceLabel(choice)}\n`);
|
|
56
|
+
}
|
|
57
|
+
input.stdout.write(" n. Create/open another folder\n");
|
|
58
|
+
}
|
|
59
|
+
const rl = createInterface({ input: input.stdin, output: input.stdout });
|
|
60
|
+
try {
|
|
61
|
+
const defaultWorkspace = createableDefaultWorkspace(input.invalidExplicitWorkspace);
|
|
62
|
+
if (input.choices.length === 0) {
|
|
63
|
+
return await promptForNewWorkspace(rl, input.cwd, defaultWorkspace);
|
|
64
|
+
}
|
|
65
|
+
const defaultLabel = defaultWorkspace ? `${defaultWorkspace}, ` : "";
|
|
66
|
+
const answer = (await rl.question(`Workspace [${defaultLabel}1/${input.choices.length}, n]: `)).trim();
|
|
67
|
+
if (!answer && defaultWorkspace) {
|
|
68
|
+
return defaultWorkspace;
|
|
69
|
+
}
|
|
70
|
+
if (!answer || answer === "1") {
|
|
71
|
+
return input.choices[0]?.path ?? input.cwd;
|
|
72
|
+
}
|
|
73
|
+
const newWorkspaceMatch = answer.match(/^n(?:ew)?\s+(.+)$/i);
|
|
74
|
+
if (newWorkspaceMatch?.[1]?.trim()) {
|
|
75
|
+
return resolveWorkspacePath(input.cwd, newWorkspaceMatch[1].trim());
|
|
76
|
+
}
|
|
77
|
+
if (/^n(?:ew)?$/i.test(answer)) {
|
|
78
|
+
return await promptForNewWorkspace(rl, input.cwd, defaultWorkspace);
|
|
79
|
+
}
|
|
80
|
+
const index = Number.parseInt(answer, 10);
|
|
81
|
+
if (Number.isInteger(index) && index >= 1 && index <= input.choices.length) {
|
|
82
|
+
return input.choices[index - 1]?.path ?? input.cwd;
|
|
83
|
+
}
|
|
84
|
+
return resolveWorkspacePath(input.cwd, answer);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
rl.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function promptForNewWorkspace(rl, cwd, defaultWorkspace) {
|
|
91
|
+
const prompt = defaultWorkspace ? `Workspace path [${defaultWorkspace}]: ` : "Workspace path: ";
|
|
92
|
+
const answer = (await rl.question(prompt)).trim();
|
|
93
|
+
if (answer) {
|
|
94
|
+
return resolveWorkspacePath(cwd, answer);
|
|
95
|
+
}
|
|
96
|
+
return defaultWorkspace ?? cwd;
|
|
97
|
+
}
|
|
98
|
+
function createableDefaultWorkspace(invalidExplicitWorkspace) {
|
|
99
|
+
return invalidExplicitWorkspace?.reason === "missing" ? invalidExplicitWorkspace.path : undefined;
|
|
100
|
+
}
|
|
101
|
+
function workspaceLabel(choice) {
|
|
102
|
+
const marker = choice.exists ? "" : " (will create)";
|
|
103
|
+
return `${basename(choice.path) || choice.path} ${choice.path}${marker}`;
|
|
104
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { render } from "ink";
|
|
4
|
-
import {
|
|
4
|
+
import { ZodError } from "zod";
|
|
5
|
+
import { parseCliArgs, validateCliArgs } from "./cli-args.js";
|
|
6
|
+
import { selectWorkspaceForCli } from "./cli-workspace.js";
|
|
5
7
|
import { createRuntime } from "./bootstrap.js";
|
|
8
|
+
import { prepareAppRoot } from "./core/app-root.js";
|
|
6
9
|
import { configPath, writeDefaultConfig } from "./core/config.js";
|
|
7
10
|
import { pathExists } from "./core/file-store.js";
|
|
8
11
|
import { runDoctor } from "./doctor.js";
|
|
@@ -17,32 +20,87 @@ Options:
|
|
|
17
20
|
--init Write .parallel-codex/config.toml if missing
|
|
18
21
|
--doctor Check local configuration and agent commands
|
|
19
22
|
-v, --version Print the current version
|
|
20
|
-
-h, --help Print this help message
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
-h, --help Print this help message
|
|
24
|
+
|
|
25
|
+
Options with values also accept --name=value and -x=value forms.`;
|
|
26
|
+
main().catch((error) => {
|
|
27
|
+
process.stderr.write(`${formatStartupError(error)}\n`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
});
|
|
30
|
+
async function main() {
|
|
31
|
+
const rawArgs = process.argv.slice(2);
|
|
32
|
+
const cliArgErrors = validateCliArgs(rawArgs);
|
|
33
|
+
if (cliArgErrors.length > 0) {
|
|
34
|
+
process.stderr.write(`${cliArgErrors.join("\n")}\n`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
const cliArgs = parseCliArgs(rawArgs, process.cwd());
|
|
38
|
+
if (!cliArgs.help && !cliArgs.version) {
|
|
39
|
+
await prepareAppRoot(cliArgs.appRoot);
|
|
40
|
+
}
|
|
41
|
+
const localConfigPath = configPath(cliArgs.appRoot);
|
|
42
|
+
if (cliArgs.help) {
|
|
43
|
+
console.log(helpText);
|
|
44
|
+
}
|
|
45
|
+
else if (cliArgs.version) {
|
|
46
|
+
console.log(`parallel-codex-tui ${version}`);
|
|
47
|
+
}
|
|
48
|
+
else if (cliArgs.doctor) {
|
|
49
|
+
const workspaceRoot = await selectWorkspaceForCli({
|
|
50
|
+
appRoot: cliArgs.appRoot,
|
|
51
|
+
cwd: process.cwd(),
|
|
52
|
+
explicitWorkspace: cliArgs.explicitWorkspace,
|
|
53
|
+
interactive: false
|
|
54
|
+
});
|
|
55
|
+
const result = await runDoctor(cliArgs.appRoot, workspaceRoot);
|
|
56
|
+
process.stdout.write(result.text);
|
|
57
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
58
|
+
}
|
|
59
|
+
else if (cliArgs.init) {
|
|
60
|
+
if (await pathExists(localConfigPath)) {
|
|
61
|
+
console.log(`Config already exists: ${localConfigPath}`);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
await writeDefaultConfig(cliArgs.appRoot);
|
|
65
|
+
console.log(`Wrote ${localConfigPath}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const workspaceRoot = await selectWorkspaceForCli({
|
|
70
|
+
appRoot: cliArgs.appRoot,
|
|
71
|
+
cwd: process.cwd(),
|
|
72
|
+
explicitWorkspace: cliArgs.explicitWorkspace
|
|
73
|
+
});
|
|
74
|
+
const runtime = await createRuntime(cliArgs.appRoot, workspaceRoot);
|
|
75
|
+
if (cliArgs.taskId && !(await runtime.sessions.hasTask(cliArgs.taskId))) {
|
|
76
|
+
throw new Error(`Task session not found in workspace ${runtime.workspaceRoot}: ${cliArgs.taskId}`);
|
|
77
|
+
}
|
|
78
|
+
if (!canRenderInteractiveTui()) {
|
|
79
|
+
throw new Error("parallel-codex-tui requires an interactive terminal. Use --help, --version, --init, or --doctor for non-interactive command modes.");
|
|
80
|
+
}
|
|
81
|
+
const latestTask = await runtime.sessions.latestTask();
|
|
82
|
+
const initialTaskId = cliArgs.taskId ?? latestTask?.id ?? null;
|
|
83
|
+
render(_jsx(App, { config: runtime.config, orchestrator: runtime.orchestrator, cwd: runtime.workspaceRoot, initialTaskId: initialTaskId }), { exitOnCtrlC: false });
|
|
84
|
+
}
|
|
25
85
|
}
|
|
26
|
-
|
|
27
|
-
|
|
86
|
+
function canRenderInteractiveTui() {
|
|
87
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
28
88
|
}
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
89
|
+
function formatStartupError(error) {
|
|
90
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
91
|
+
if (isConfigStartupError(error)) {
|
|
92
|
+
return `Config error: ${message}\nRun parallel-codex-tui --doctor for details.`;
|
|
93
|
+
}
|
|
94
|
+
return `Startup error: ${message}`;
|
|
33
95
|
}
|
|
34
|
-
|
|
35
|
-
if (
|
|
36
|
-
|
|
96
|
+
function isConfigStartupError(error) {
|
|
97
|
+
if (error instanceof ZodError) {
|
|
98
|
+
return true;
|
|
37
99
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
console.log(`Wrote ${localConfigPath}`);
|
|
100
|
+
if (!(error instanceof Error)) {
|
|
101
|
+
return false;
|
|
41
102
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const latestTask = await runtime.sessions.latestTask();
|
|
46
|
-
const initialTaskId = cliArgs.taskId ?? latestTask?.id ?? null;
|
|
47
|
-
render(_jsx(App, { config: runtime.config, orchestrator: runtime.orchestrator, cwd: runtime.workspaceRoot, initialTaskId: initialTaskId }), { exitOnCtrlC: false });
|
|
103
|
+
return (error.message.startsWith("Invalid config section ") ||
|
|
104
|
+
error.name.toLowerCase().includes("toml") ||
|
|
105
|
+
error.message.toLowerCase().includes("toml"));
|
|
48
106
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ensureDir, pathExists, pathIsDirectory } from "./file-store.js";
|
|
2
|
+
export async function prepareAppRoot(appRoot) {
|
|
3
|
+
if ((await pathExists(appRoot)) && !(await pathIsDirectory(appRoot))) {
|
|
4
|
+
throw new Error(`App root path exists but is not a directory: ${appRoot}`);
|
|
5
|
+
}
|
|
6
|
+
await ensureDir(appRoot);
|
|
7
|
+
return appRoot;
|
|
8
|
+
}
|
package/dist/core/config.js
CHANGED
|
@@ -188,6 +188,7 @@ export async function loadConfig(projectRoot) {
|
|
|
188
188
|
return base;
|
|
189
189
|
}
|
|
190
190
|
const parsed = parse(await readTextIfExists(file));
|
|
191
|
+
assertObjectSections(parsed);
|
|
191
192
|
const merged = {
|
|
192
193
|
...base,
|
|
193
194
|
...parsed,
|
|
@@ -291,6 +292,43 @@ export async function loadConfig(projectRoot) {
|
|
|
291
292
|
};
|
|
292
293
|
return AppConfigSchema.parse(merged);
|
|
293
294
|
}
|
|
295
|
+
function assertObjectSections(parsed) {
|
|
296
|
+
const sections = [
|
|
297
|
+
["router", parsed.router],
|
|
298
|
+
["router.codex", parsed.router?.codex],
|
|
299
|
+
["workers", parsed.workers],
|
|
300
|
+
["workers.codex", parsed.workers?.codex],
|
|
301
|
+
["workers.codex.model", parsed.workers?.codex?.model],
|
|
302
|
+
["workers.codex.model.env", parsed.workers?.codex?.model?.env],
|
|
303
|
+
["workers.codex.nativeSession", parsed.workers?.codex?.nativeSession],
|
|
304
|
+
["workers.codex.interactive", parsed.workers?.codex?.interactive],
|
|
305
|
+
["workers.claude", parsed.workers?.claude],
|
|
306
|
+
["workers.claude.model", parsed.workers?.claude?.model],
|
|
307
|
+
["workers.claude.model.env", parsed.workers?.claude?.model?.env],
|
|
308
|
+
["workers.claude.nativeSession", parsed.workers?.claude?.nativeSession],
|
|
309
|
+
["workers.claude.interactive", parsed.workers?.claude?.interactive],
|
|
310
|
+
["workers.mock", parsed.workers?.mock],
|
|
311
|
+
["workers.mock.model", parsed.workers?.mock?.model],
|
|
312
|
+
["workers.mock.model.env", parsed.workers?.mock?.model?.env],
|
|
313
|
+
["workers.mock.nativeSession", parsed.workers?.mock?.nativeSession],
|
|
314
|
+
["workers.mock.interactive", parsed.workers?.mock?.interactive],
|
|
315
|
+
["pairing", parsed.pairing],
|
|
316
|
+
["roles", parsed.roles],
|
|
317
|
+
["roles.main", parsed.roles?.main],
|
|
318
|
+
["roles.judge", parsed.roles?.judge],
|
|
319
|
+
["roles.actor", parsed.roles?.actor],
|
|
320
|
+
["roles.critic", parsed.roles?.critic],
|
|
321
|
+
["ui", parsed.ui]
|
|
322
|
+
];
|
|
323
|
+
for (const [path, value] of sections) {
|
|
324
|
+
if (value !== undefined && !isPlainObject(value)) {
|
|
325
|
+
throw new Error(`Invalid config section [${path}]: expected a table`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function isPlainObject(value) {
|
|
330
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
331
|
+
}
|
|
294
332
|
export async function writeDefaultConfig(projectRoot) {
|
|
295
333
|
const config = defaultConfig(projectRoot);
|
|
296
334
|
const tomlText = stringify({
|
package/dist/core/file-store.js
CHANGED
|
@@ -15,6 +15,17 @@ export async function pathExists(path) {
|
|
|
15
15
|
throw error;
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
export async function pathIsDirectory(path) {
|
|
19
|
+
try {
|
|
20
|
+
return (await stat(path)).isDirectory();
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code === "ENOENT") {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
18
29
|
export async function writeJson(path, value) {
|
|
19
30
|
const dir = dirname(path);
|
|
20
31
|
const tempPath = join(dir, `.${basename(path)}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
|
package/dist/core/paths.js
CHANGED
|
@@ -12,6 +12,9 @@ export function formatTaskTimestamp(date) {
|
|
|
12
12
|
export function sessionsRoot(projectRoot, dataDir) {
|
|
13
13
|
return join(projectRoot, dataDir, "sessions");
|
|
14
14
|
}
|
|
15
|
+
export function routerRuntimeDir(appRoot, dataDir) {
|
|
16
|
+
return join(appRoot, dataDir, "router");
|
|
17
|
+
}
|
|
15
18
|
export function taskDir(projectRoot, dataDir, taskId) {
|
|
16
19
|
return join(sessionsRoot(projectRoot, dataDir), taskId);
|
|
17
20
|
}
|
package/dist/core/router.js
CHANGED
|
@@ -16,7 +16,7 @@ export async function routeRequestWithCodex(request, config, runner = runCodexRo
|
|
|
16
16
|
const fallback = fallbackRoute(config);
|
|
17
17
|
return {
|
|
18
18
|
...fallback,
|
|
19
|
-
reason: `Codex router failed: ${
|
|
19
|
+
reason: `Codex router failed: ${summarizeRouterError(error)}. ${fallback.reason}`
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
22
|
}
|
|
@@ -86,11 +86,7 @@ function buildCodexRouterPrompt(request, config) {
|
|
|
86
86
|
"JSON schema:",
|
|
87
87
|
JSON.stringify({
|
|
88
88
|
mode: "simple|complex",
|
|
89
|
-
reason: "short explanation"
|
|
90
|
-
suggested_roles: ["judge", "actor", "critic"],
|
|
91
|
-
judge_engine: config.pairing.judge,
|
|
92
|
-
actor_engine: config.pairing.actor,
|
|
93
|
-
critic_engine: config.pairing.critic
|
|
89
|
+
reason: "short explanation"
|
|
94
90
|
}),
|
|
95
91
|
"",
|
|
96
92
|
"User request:",
|
|
@@ -104,9 +100,9 @@ function parseCodexRoute(output, config) {
|
|
|
104
100
|
mode: parsed.mode === "complex" ? "complex" : "simple",
|
|
105
101
|
reason: parsed.reason || "Codex router decision.",
|
|
106
102
|
suggested_roles: parsed.mode === "complex" ? ["judge", "actor", "critic"] : [],
|
|
107
|
-
judge_engine:
|
|
108
|
-
actor_engine:
|
|
109
|
-
critic_engine:
|
|
103
|
+
judge_engine: config.pairing.judge,
|
|
104
|
+
actor_engine: config.pairing.actor,
|
|
105
|
+
critic_engine: config.pairing.critic
|
|
110
106
|
};
|
|
111
107
|
}
|
|
112
108
|
function extractJsonObject(output) {
|
|
@@ -129,6 +125,14 @@ function fallbackRoute(config) {
|
|
|
129
125
|
}
|
|
130
126
|
return complexRoute("Codex router fallback forced complex.", config);
|
|
131
127
|
}
|
|
128
|
+
function summarizeRouterError(error) {
|
|
129
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
130
|
+
const meaningful = raw
|
|
131
|
+
.split(/\r?\n/)
|
|
132
|
+
.map((line) => line.trim())
|
|
133
|
+
.find((line) => line && !line.toLowerCase().startsWith("tip:") && !line.toLowerCase().startsWith("usage:"));
|
|
134
|
+
return (meaningful || "unknown router error").replace(/[.。]+$/u, "");
|
|
135
|
+
}
|
|
132
136
|
function simpleRoute(reason, config) {
|
|
133
137
|
return {
|
|
134
138
|
mode: "simple",
|