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 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 22.5+.
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 { loadConfig } from "./core/config.js";
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 index = await SessionIndex.open(workspaceRoot, config.dataDir);
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: workspaceRoot,
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 appRootFlagIndex = args.findIndex((arg) => arg === "--app-root");
4
- const workspaceFlagIndex = args.findIndex((arg) => arg === "--workspace" || arg === "-w");
5
- const taskFlagIndex = args.findIndex((arg) => arg === "--task" || arg === "-t");
6
- const doctor = args.includes("--doctor");
7
- const help = args.includes("--help") || args.includes("-h");
8
- const init = args.includes("--init");
9
- const version = args.includes("--version") || args.includes("-v");
10
- const appRoot = appRootFlagIndex >= 0 && args[appRootFlagIndex + 1]
11
- ? resolve(cwd, args[appRootFlagIndex + 1])
12
- : cwd;
13
- const workspaceRoot = workspaceFlagIndex >= 0 && args[workspaceFlagIndex + 1]
14
- ? resolve(cwd, args[workspaceFlagIndex + 1])
15
- : cwd;
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 { parseCliArgs } from "./cli-args.js";
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
- const cliArgs = parseCliArgs(process.argv.slice(2), process.cwd());
22
- const localConfigPath = configPath(cliArgs.appRoot);
23
- if (cliArgs.help) {
24
- console.log(helpText);
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
- else if (cliArgs.version) {
27
- console.log(`parallel-codex-tui ${version}`);
86
+ function canRenderInteractiveTui() {
87
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
28
88
  }
29
- else if (cliArgs.doctor) {
30
- const result = await runDoctor(cliArgs.appRoot, cliArgs.workspaceRoot);
31
- process.stdout.write(result.text);
32
- process.exitCode = result.ok ? 0 : 1;
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
- else if (cliArgs.init) {
35
- if (await pathExists(localConfigPath)) {
36
- console.log(`Config already exists: ${localConfigPath}`);
96
+ function isConfigStartupError(error) {
97
+ if (error instanceof ZodError) {
98
+ return true;
37
99
  }
38
- else {
39
- await writeDefaultConfig(cliArgs.appRoot);
40
- console.log(`Wrote ${localConfigPath}`);
100
+ if (!(error instanceof Error)) {
101
+ return false;
41
102
  }
42
- }
43
- else {
44
- const runtime = await createRuntime(cliArgs.appRoot, cliArgs.workspaceRoot);
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
+ }
@@ -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({
@@ -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`);
@@ -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
  }
@@ -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: ${error instanceof Error ? error.message : String(error)}. ${fallback.reason}`
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: parsed.judge_engine ?? config.pairing.judge,
108
- actor_engine: parsed.actor_engine ?? config.pairing.actor,
109
- critic_engine: parsed.critic_engine ?? config.pairing.critic
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",