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
+ [router]
2
+ defaultMode = "auto"
3
+
4
+ [router.codex]
5
+ command = "codex"
6
+ args = ["exec", "--skip-git-repo-check", "--sandbox", "workspace-write", "--color", "never", "-"]
7
+ timeoutMs = 120000
8
+ fallback = "complex"
9
+
10
+ [workers.codex]
11
+ command = "codex"
12
+ args = ["exec", "--skip-git-repo-check", "--sandbox", "workspace-write", "--color", "never", "-"]
13
+ firstOutputTimeoutMs = 120000
14
+
15
+ [workers.codex.interactive]
16
+ command = "codex"
17
+ args = ["resume", "{sessionId}"]
18
+
19
+ [workers.codex.nativeSession]
20
+ fallback = "new"
21
+
22
+ [workers.claude]
23
+ command = "claude"
24
+ args = ["--print", "--permission-mode", "acceptEdits", "--output-format", "text"]
25
+ firstOutputTimeoutMs = 120000
26
+
27
+ [workers.claude.interactive]
28
+ command = "claude"
29
+ args = ["--resume", "{sessionId}"]
30
+
31
+ [workers.claude.nativeSession]
32
+ fallback = "new"
33
+
34
+ [workers.mock]
35
+ command = "mock"
36
+ args = []
37
+
38
+ [pairing]
39
+ main = "claude"
40
+ judge = "codex"
41
+ actor = "codex"
42
+ critic = "codex"
43
+
44
+ [roles.main]
45
+ title = "Main"
46
+ instructions = ["Answer simple chat and status questions directly."]
47
+
48
+ [roles.judge]
49
+ title = "Judge"
50
+ instructions = ["Clarify requirements and write task files. Do not implement code."]
51
+
52
+ [roles.actor]
53
+ title = "Actor"
54
+ instructions = ["Read Judge files, implement the requested change, and record your work."]
55
+
56
+ [roles.critic]
57
+ title = "Critic"
58
+ instructions = ["Review Actor work against Judge requirements. Lead with blocking findings."]
59
+
60
+ [ui]
61
+ showStatusBar = true
62
+ autoOpenFailedWorker = true
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 parallel-codex-tui contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # parallel-codex-tui
2
+
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
+
5
+ ## Requirements
6
+
7
+ - Node.js 22.5+.
8
+ - Codex CLI available as `codex` for Codex routing and Codex workers.
9
+ - Claude CLI available as `claude` only when you configure Claude workers.
10
+ - A project workspace you are comfortable letting configured workers edit.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install -g parallel-codex-tui
16
+ ```
17
+
18
+ Run it against the project you want the workers to operate on:
19
+
20
+ ```bash
21
+ cd /path/to/project
22
+ parallel-codex-tui --init
23
+ parallel-codex-tui --doctor
24
+ parallel-codex-tui --workspace /path/to/project
25
+ ```
26
+
27
+ From a source checkout, install dependencies and link the local binary:
28
+
29
+ ```bash
30
+ npm install
31
+ npm run build
32
+ npm link
33
+ ```
34
+
35
+ For development without linking, run:
36
+
37
+ ```bash
38
+ npm run dev -- --workspace /path/to/project
39
+ ```
40
+
41
+ Check available flags or the installed version without starting the TUI:
42
+
43
+ ```bash
44
+ parallel-codex-tui --help
45
+ parallel-codex-tui --doctor
46
+ parallel-codex-tui --version
47
+ ```
48
+
49
+ ## Quick Start
50
+
51
+ Create a local config in the app root:
52
+
53
+ ```bash
54
+ parallel-codex-tui --init
55
+ parallel-codex-tui --doctor
56
+ ```
57
+
58
+ From a source checkout, you can also copy the public example:
59
+
60
+ ```bash
61
+ cp .parallel-codex/config.example.toml .parallel-codex/config.toml
62
+ ```
63
+
64
+ For deterministic local testing without real agent CLIs, set all roles to mock workers in `.parallel-codex/config.toml`:
65
+
66
+ ```toml
67
+ [pairing]
68
+ judge = "mock"
69
+ actor = "mock"
70
+ critic = "mock"
71
+ ```
72
+
73
+ Then start the TUI:
74
+
75
+ ```bash
76
+ parallel-codex-tui --workspace /path/to/project
77
+ ```
78
+
79
+ ## Behavior
80
+
81
+ - Requests are routed by Codex by default, with a configured simple/complex fallback if the router process fails.
82
+ - Simple requests stay in the main TUI flow and do not create Judge, Actor, or Critic workers.
83
+ - Complex requests create a session under `.parallel-codex/sessions/`.
84
+ - Complex requests run Judge -> Actor -> Critic.
85
+ - Worker prompts, logs, status, and outputs are written to disk.
86
+ - The bottom status line shows the active task state.
87
+
88
+ ## Router
89
+
90
+ Codex routing is enabled by default:
91
+
92
+ ```toml
93
+ [router]
94
+ defaultMode = "auto"
95
+
96
+ [router.codex]
97
+ command = "codex"
98
+ args = ["exec", "--skip-git-repo-check", "--sandbox", "workspace-write", "--color", "never", "-"]
99
+ timeoutMs = 120000
100
+ fallback = "complex"
101
+ ```
102
+
103
+ Set `defaultMode = "simple"` / `defaultMode = "complex"` to force one path. In `auto` mode, routing is semantic through Codex. If the router process fails or returns invalid JSON, `fallback = "simple"` or `fallback = "complex"` decides the path; there is no keyword-only router.
104
+
105
+ ## Mock Mode
106
+
107
+ For deterministic local testing, use this pairing in `.parallel-codex/config.toml`:
108
+
109
+ ```toml
110
+ [pairing]
111
+ judge = "mock"
112
+ actor = "mock"
113
+ critic = "mock"
114
+ ```
115
+
116
+ ## Real Worker Mode
117
+
118
+ Configure Codex and Claude commands in `.parallel-codex/config.toml`:
119
+
120
+ ```toml
121
+ [workers.codex]
122
+ command = "codex"
123
+ args = ["exec", "--skip-git-repo-check", "--sandbox", "workspace-write", "--color", "never", "-"]
124
+
125
+ [workers.codex.interactive]
126
+ command = "codex"
127
+ args = ["resume", "{sessionId}"]
128
+
129
+ [workers.claude]
130
+ command = "claude"
131
+ args = ["--print", "--permission-mode", "acceptEdits", "--output-format", "text"]
132
+
133
+ [workers.claude.interactive]
134
+ command = "claude"
135
+ args = ["--resume", "{sessionId}"]
136
+ ```
137
+
138
+ Keep `[router.codex]` on `workspace-write`; routing only classifies requests and does not need host-level access. If a trusted local project needs Docker, OrbStack, or other host services, opt into broader worker permissions in your private `.parallel-codex/config.toml` rather than committing them.
139
+
140
+ The process adapter sends each role prompt to stdin and records stdout/stderr in `output.log`.
141
+
142
+ While viewing a worker log, press `Ctrl+O` to attach to the worker's native session inside `parallel-codex-tui`. Native attach runs through a PTY, so full-screen CLIs such as `codex resume {sessionId}` receive a real terminal instead of pipe stdin. Input is forwarded to the configured interactive command and output is shown in the native attach panel. Press `Ctrl+]` to detach and return to worker logs; `Ctrl+C` is forwarded to the native agent while attached. In chat and worker-log views, press `Ctrl+C` to exit the outer TUI.
143
+
144
+ 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
+
146
+ ## Publishing Hygiene
147
+
148
+ - `.parallel-codex/config.toml` is local-only and ignored.
149
+ - `.parallel-codex/sessions/` contains task prompts, logs, native session ids, and worker output; never commit it.
150
+ - `docs/superpowers/` contains internal planning notes and is ignored for public releases.
@@ -0,0 +1,25 @@
1
+ import { loadConfig } from "./core/config.js";
2
+ import { SessionIndex } from "./core/session-index.js";
3
+ import { SessionManager } from "./core/session-manager.js";
4
+ import { Orchestrator } from "./orchestrator/orchestrator.js";
5
+ import { createWorkerRegistry } from "./workers/registry.js";
6
+ export async function createRuntime(appRoot, workspaceRoot = appRoot) {
7
+ const config = await loadConfig(appRoot);
8
+ const index = await SessionIndex.open(workspaceRoot, config.dataDir);
9
+ await index.rebuildFromFiles();
10
+ const sessions = new SessionManager({
11
+ projectRoot: workspaceRoot,
12
+ dataDir: config.dataDir,
13
+ index
14
+ });
15
+ const workers = createWorkerRegistry(config);
16
+ const orchestrator = new Orchestrator(config, sessions, workers);
17
+ return {
18
+ config,
19
+ workspaceRoot,
20
+ index,
21
+ sessions,
22
+ workers,
23
+ orchestrator
24
+ };
25
+ }
@@ -0,0 +1,26 @@
1
+ import { resolve } from "node:path";
2
+ 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;
17
+ return {
18
+ appRoot,
19
+ doctor,
20
+ help,
21
+ init,
22
+ workspaceRoot,
23
+ taskId,
24
+ version
25
+ };
26
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { render } from "ink";
4
+ import { parseCliArgs } from "./cli-args.js";
5
+ import { createRuntime } from "./bootstrap.js";
6
+ import { configPath, writeDefaultConfig } from "./core/config.js";
7
+ import { pathExists } from "./core/file-store.js";
8
+ import { runDoctor } from "./doctor.js";
9
+ import { App } from "./tui/App.js";
10
+ import { version } from "./version.js";
11
+ const helpText = `Usage: parallel-codex-tui [options]
12
+
13
+ Options:
14
+ -w, --workspace <path> Project workspace for worker sessions and edits
15
+ --app-root <path> App root for configuration lookup
16
+ -t, --task <id> Open an existing task session
17
+ --init Write .parallel-codex/config.toml if missing
18
+ --doctor Check local configuration and agent commands
19
+ -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);
25
+ }
26
+ else if (cliArgs.version) {
27
+ console.log(`parallel-codex-tui ${version}`);
28
+ }
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;
33
+ }
34
+ else if (cliArgs.init) {
35
+ if (await pathExists(localConfigPath)) {
36
+ console.log(`Config already exists: ${localConfigPath}`);
37
+ }
38
+ else {
39
+ await writeDefaultConfig(cliArgs.appRoot);
40
+ console.log(`Wrote ${localConfigPath}`);
41
+ }
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 });
48
+ }
@@ -0,0 +1,304 @@
1
+ import { parse, stringify } from "@iarna/toml";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { pathExists, readTextIfExists, writeText } from "./file-store.js";
5
+ const NativeSessionConfigSchema = z.object({
6
+ enabled: z.boolean().default(true),
7
+ resumeArgs: z.array(z.string()).default([]),
8
+ detectSessionId: z.boolean().default(true),
9
+ fallback: z.enum(["fail", "new"]).default("fail")
10
+ });
11
+ const WorkerModelConfigSchema = z.object({
12
+ name: z.string().default(""),
13
+ provider: z.string().default(""),
14
+ args: z.array(z.string()).default([]),
15
+ env: z.record(z.string()).default({})
16
+ });
17
+ const InteractiveCommandSchema = z.object({
18
+ command: z.string().min(1),
19
+ args: z.array(z.string()).default([])
20
+ });
21
+ const RolePromptConfigSchema = z.object({
22
+ title: z.string().min(1),
23
+ instructions: z.array(z.string()).default([])
24
+ });
25
+ const CodexRouterConfigSchema = z.object({
26
+ command: z.string().min(1).default("codex"),
27
+ args: z.array(z.string()).default(["exec", "--skip-git-repo-check", "--sandbox", "workspace-write", "--color", "never", "-"]),
28
+ timeoutMs: z.number().int().positive().default(120000),
29
+ fallback: z.enum(["simple", "complex"]).default("complex")
30
+ });
31
+ const WorkerCommandSchema = z.object({
32
+ command: z.string().min(1),
33
+ args: z.array(z.string()).default([]),
34
+ timeoutMs: z.number().int().positive().optional(),
35
+ idleTimeoutMs: z.number().int().positive().optional(),
36
+ firstOutputTimeoutMs: z.number().int().positive().optional(),
37
+ model: WorkerModelConfigSchema.default({}),
38
+ nativeSession: NativeSessionConfigSchema,
39
+ interactive: InteractiveCommandSchema
40
+ });
41
+ const AppConfigSchema = z.object({
42
+ projectRoot: z.string().min(1),
43
+ dataDir: z.string().min(1),
44
+ router: z.object({
45
+ defaultMode: z.enum(["auto", "simple", "complex"]),
46
+ codex: CodexRouterConfigSchema.default({})
47
+ }),
48
+ workers: z.object({
49
+ codex: WorkerCommandSchema,
50
+ claude: WorkerCommandSchema,
51
+ mock: WorkerCommandSchema
52
+ }),
53
+ pairing: z.object({
54
+ main: z.enum(["codex", "claude", "mock"]),
55
+ judge: z.enum(["codex", "claude", "mock"]),
56
+ actor: z.enum(["codex", "claude", "mock"]),
57
+ critic: z.enum(["codex", "claude", "mock"])
58
+ }),
59
+ roles: z.object({
60
+ main: RolePromptConfigSchema,
61
+ judge: RolePromptConfigSchema,
62
+ actor: RolePromptConfigSchema,
63
+ critic: RolePromptConfigSchema
64
+ }),
65
+ ui: z.object({
66
+ showStatusBar: z.boolean(),
67
+ autoOpenFailedWorker: z.boolean()
68
+ })
69
+ });
70
+ export function defaultConfig(projectRoot) {
71
+ return {
72
+ projectRoot,
73
+ dataDir: ".parallel-codex",
74
+ router: {
75
+ defaultMode: "auto",
76
+ codex: {
77
+ command: "codex",
78
+ args: ["exec", "--skip-git-repo-check", "--sandbox", "workspace-write", "--color", "never", "-"],
79
+ timeoutMs: 120000,
80
+ fallback: "complex"
81
+ }
82
+ },
83
+ workers: {
84
+ codex: {
85
+ command: "codex",
86
+ args: ["exec", "--skip-git-repo-check", "--sandbox", "workspace-write", "--color", "never", "-"],
87
+ timeoutMs: 45 * 60 * 1000,
88
+ idleTimeoutMs: 5 * 60 * 1000,
89
+ firstOutputTimeoutMs: 2 * 60 * 1000,
90
+ model: {
91
+ name: "",
92
+ provider: "",
93
+ args: [],
94
+ env: {}
95
+ },
96
+ nativeSession: {
97
+ enabled: true,
98
+ resumeArgs: ["exec", "resume", "{sessionId}", "--skip-git-repo-check", "-"],
99
+ detectSessionId: true,
100
+ fallback: "new"
101
+ },
102
+ interactive: {
103
+ command: "codex",
104
+ args: ["resume", "{sessionId}"]
105
+ }
106
+ },
107
+ claude: {
108
+ command: "claude",
109
+ args: ["--print", "--permission-mode", "acceptEdits", "--output-format", "text"],
110
+ timeoutMs: 45 * 60 * 1000,
111
+ idleTimeoutMs: 5 * 60 * 1000,
112
+ firstOutputTimeoutMs: 2 * 60 * 1000,
113
+ model: {
114
+ name: "",
115
+ provider: "",
116
+ args: [],
117
+ env: {}
118
+ },
119
+ nativeSession: {
120
+ enabled: true,
121
+ resumeArgs: ["--print", "--resume", "{sessionId}", "--permission-mode", "acceptEdits", "--output-format", "text"],
122
+ detectSessionId: true,
123
+ fallback: "new"
124
+ },
125
+ interactive: {
126
+ command: "claude",
127
+ args: ["--resume", "{sessionId}"]
128
+ }
129
+ },
130
+ mock: {
131
+ command: "mock",
132
+ args: [],
133
+ model: {
134
+ name: "",
135
+ provider: "",
136
+ args: [],
137
+ env: {}
138
+ },
139
+ nativeSession: {
140
+ enabled: true,
141
+ resumeArgs: ["resume", "{sessionId}", "-"],
142
+ detectSessionId: true,
143
+ fallback: "fail"
144
+ },
145
+ interactive: {
146
+ command: "mock",
147
+ args: ["resume", "{sessionId}"]
148
+ }
149
+ }
150
+ },
151
+ pairing: {
152
+ main: "claude",
153
+ judge: "codex",
154
+ actor: "codex",
155
+ critic: "codex"
156
+ },
157
+ roles: {
158
+ main: {
159
+ title: "Main",
160
+ instructions: ["Answer the user directly for simple chat and explanation requests."]
161
+ },
162
+ judge: {
163
+ title: "Judge",
164
+ instructions: ["You clarify requirements and write task files. Do not implement code."]
165
+ },
166
+ actor: {
167
+ title: "Actor",
168
+ instructions: ["Read Judge files, implement the requested change, and record your work."]
169
+ },
170
+ critic: {
171
+ title: "Critic",
172
+ instructions: ["Review Actor work against Judge requirements. Lead with blocking findings."]
173
+ }
174
+ },
175
+ ui: {
176
+ showStatusBar: true,
177
+ autoOpenFailedWorker: true
178
+ }
179
+ };
180
+ }
181
+ export function configPath(projectRoot) {
182
+ return join(projectRoot, ".parallel-codex", "config.toml");
183
+ }
184
+ export async function loadConfig(projectRoot) {
185
+ const base = defaultConfig(projectRoot);
186
+ const file = configPath(projectRoot);
187
+ if (!(await pathExists(file))) {
188
+ return base;
189
+ }
190
+ const parsed = parse(await readTextIfExists(file));
191
+ const merged = {
192
+ ...base,
193
+ ...parsed,
194
+ projectRoot,
195
+ router: {
196
+ ...base.router,
197
+ ...(parsed.router ?? {}),
198
+ codex: {
199
+ ...base.router.codex,
200
+ ...(parsed.router?.codex ?? {})
201
+ }
202
+ },
203
+ workers: {
204
+ codex: {
205
+ ...base.workers.codex,
206
+ ...(parsed.workers?.codex ?? {}),
207
+ model: {
208
+ ...base.workers.codex.model,
209
+ ...(parsed.workers?.codex?.model ?? {}),
210
+ env: {
211
+ ...base.workers.codex.model.env,
212
+ ...(parsed.workers?.codex?.model?.env ?? {})
213
+ }
214
+ },
215
+ nativeSession: {
216
+ ...base.workers.codex.nativeSession,
217
+ ...(parsed.workers?.codex?.nativeSession ?? {})
218
+ },
219
+ interactive: {
220
+ ...base.workers.codex.interactive,
221
+ ...(parsed.workers?.codex?.interactive ?? {})
222
+ }
223
+ },
224
+ claude: {
225
+ ...base.workers.claude,
226
+ ...(parsed.workers?.claude ?? {}),
227
+ model: {
228
+ ...base.workers.claude.model,
229
+ ...(parsed.workers?.claude?.model ?? {}),
230
+ env: {
231
+ ...base.workers.claude.model.env,
232
+ ...(parsed.workers?.claude?.model?.env ?? {})
233
+ }
234
+ },
235
+ nativeSession: {
236
+ ...base.workers.claude.nativeSession,
237
+ ...(parsed.workers?.claude?.nativeSession ?? {})
238
+ },
239
+ interactive: {
240
+ ...base.workers.claude.interactive,
241
+ ...(parsed.workers?.claude?.interactive ?? {})
242
+ }
243
+ },
244
+ mock: {
245
+ ...base.workers.mock,
246
+ ...(parsed.workers?.mock ?? {}),
247
+ model: {
248
+ ...base.workers.mock.model,
249
+ ...(parsed.workers?.mock?.model ?? {}),
250
+ env: {
251
+ ...base.workers.mock.model.env,
252
+ ...(parsed.workers?.mock?.model?.env ?? {})
253
+ }
254
+ },
255
+ nativeSession: {
256
+ ...base.workers.mock.nativeSession,
257
+ ...(parsed.workers?.mock?.nativeSession ?? {})
258
+ },
259
+ interactive: {
260
+ ...base.workers.mock.interactive,
261
+ ...(parsed.workers?.mock?.interactive ?? {})
262
+ }
263
+ }
264
+ },
265
+ pairing: {
266
+ ...base.pairing,
267
+ ...(parsed.pairing ?? {})
268
+ },
269
+ roles: {
270
+ main: {
271
+ ...base.roles.main,
272
+ ...(parsed.roles?.main ?? {})
273
+ },
274
+ judge: {
275
+ ...base.roles.judge,
276
+ ...(parsed.roles?.judge ?? {})
277
+ },
278
+ actor: {
279
+ ...base.roles.actor,
280
+ ...(parsed.roles?.actor ?? {})
281
+ },
282
+ critic: {
283
+ ...base.roles.critic,
284
+ ...(parsed.roles?.critic ?? {})
285
+ }
286
+ },
287
+ ui: {
288
+ ...base.ui,
289
+ ...(parsed.ui ?? {})
290
+ }
291
+ };
292
+ return AppConfigSchema.parse(merged);
293
+ }
294
+ export async function writeDefaultConfig(projectRoot) {
295
+ const config = defaultConfig(projectRoot);
296
+ const tomlText = stringify({
297
+ router: config.router,
298
+ workers: config.workers,
299
+ pairing: config.pairing,
300
+ roles: config.roles,
301
+ ui: config.ui
302
+ });
303
+ await writeText(configPath(projectRoot), tomlText);
304
+ }
@@ -0,0 +1,56 @@
1
+ import { mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ export async function ensureDir(path) {
4
+ await mkdir(path, { recursive: true });
5
+ }
6
+ export async function pathExists(path) {
7
+ try {
8
+ await stat(path);
9
+ return true;
10
+ }
11
+ catch (error) {
12
+ if (error.code === "ENOENT") {
13
+ return false;
14
+ }
15
+ throw error;
16
+ }
17
+ }
18
+ export async function writeJson(path, value) {
19
+ const dir = dirname(path);
20
+ const tempPath = join(dir, `.${basename(path)}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
21
+ await ensureDir(dir);
22
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
23
+ await rename(tempPath, path);
24
+ }
25
+ export async function readJson(path, schema) {
26
+ const text = await readFile(path, "utf8");
27
+ return schema.parse(JSON.parse(text));
28
+ }
29
+ export async function appendJsonLine(path, value) {
30
+ await ensureDir(dirname(path));
31
+ await writeFile(path, `${JSON.stringify(value)}\n`, { encoding: "utf8", flag: "a" });
32
+ }
33
+ export async function writeText(path, value) {
34
+ await ensureDir(dirname(path));
35
+ await writeFile(path, value, "utf8");
36
+ }
37
+ export async function appendText(path, value) {
38
+ await ensureDir(dirname(path));
39
+ await writeFile(path, value, { encoding: "utf8", flag: "a" });
40
+ }
41
+ export async function readTextIfExists(path) {
42
+ if (!(await pathExists(path))) {
43
+ return "";
44
+ }
45
+ return readFile(path, "utf8");
46
+ }
47
+ export async function removeIfExists(path) {
48
+ try {
49
+ await unlink(path);
50
+ }
51
+ catch (error) {
52
+ if (error.code !== "ENOENT") {
53
+ throw error;
54
+ }
55
+ }
56
+ }