parallel-codex-tui 0.2.10 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,9 @@ Built with Codex-assisted development.
6
6
 
7
7
  ## Current Release
8
8
 
9
- `v0.2.10` is available from [npm](https://www.npmjs.com/package/parallel-codex-tui/v/0.2.10) and as a [GitHub Release](https://github.com/allendred/parallel-codex-tui/releases/tag/v0.2.10). `Ctrl+T`, then `C`, opens every persisted Main conversation and now provides the same durable management lifecycle as Task sessions: rename, archive, unarchive, scoped export, and confirmed deletion. Restoring one switches the exact `conversation_id`, reloads only that conversation's file-backed history, and restores its archived native Codex or Claude session ids when they are available. `Ctrl+N` still creates an explicit, durable Main conversation boundary without deleting chat, Tasks, or Worker logs.
9
+ `v0.3.0` is available from [npm](https://www.npmjs.com/package/parallel-codex-tui/v/0.3.0) and as a [GitHub Release](https://github.com/allendred/parallel-codex-tui/releases/tag/v0.3.0). `Ctrl+E` opens runtime role/model control for Main, Judge, Actor, and Critic. The complete matrix can apply once to the next request, persist for retries and follow-ups in the current Task, or become the default for future requests. Provider changes reset to that profile's configured model, `M` accepts a model override, every complex Turn records the exact consumed matrix, and historical Worker/native-session identity remains unchanged.
10
+
11
+ `Ctrl+T`, then `C`, opens every persisted Main conversation and provides the same durable management lifecycle as Task sessions: rename, archive, unarchive, scoped export, and confirmed deletion. Restoring one switches the exact `conversation_id`, reloads only that conversation's file-backed history, and restores its archived native Codex or Claude session ids when they are available. `Ctrl+N` creates an explicit, durable Main conversation boundary without deleting chat, Tasks, or Worker logs.
10
12
 
11
13
  A real Claude acceptance retires the first native session, inserts 16 newer records so the secret is absent from the inline transcript, then proves a fresh Claude session can read the scoped snapshot and recover it. The same live TUI presses `Ctrl+N`, verifies the replacement session is retired, starts another native session whose prompt and archive exclude the secret, and then switches between two isolated workspaces. The bounded rollover fallback remains included: every Main call rebuilds recent context from canonical `chat.jsonl`, so a fresh Agent still receives the latest dialogue inside its current scope.
12
14
 
@@ -25,7 +27,7 @@ Highlights:
25
27
  - Named Worker Providers support Codex-compatible, Claude-compatible, OpenAI-compatible, Anthropic-compatible, and custom generic commands with independent role, model, environment, permission, resume, and interactive settings.
26
28
  - Worker overview, Feature board, collaboration timeline, Task and Main conversation centers, status details, rendered Markdown/Diff/error logs, Unicode search, keyboard navigation, mouse scrolling, and configurable themes share one terminal UI system.
27
29
 
28
- Release acceptance includes a real three-Feature Tetris task with parallel Actor/Critic waves and final integration review. A clean `v0.2.5` task also ran Codex Judge and Actor, a buffered Claude Critic that independently executed `node --test` and `npm test`, atomic integration, and a resumed Codex Judge that independently passed all seven acceptance criteria. Real Codex and Claude probes both proved fresh and same-session resume calls; Codex fresh and resume runs executed workspace writes with root-level `-a never`, and Claude automated sessions executed safe Bash tools with `auto` permissions. The semantic Router completed a live classification, and one TUI completed Main calls in two workspaces before restoring the first workspace without leaking chat state. PTY coverage runs in Apple Terminal, tmux, and Zellij profiles at narrow and wide sizes, including status/log equivalence, preserving the native output tail across status-detail round trips, and proving both inline and on-demand file memory after native-session rollover. The deterministic repository suite contains 1,311 tests across 130 files: 1,310 pass by default, while one quota-consuming real-Agent test is skipped and passes through `npm run test:real-agents`.
30
+ Release acceptance includes a real three-Feature Tetris task with parallel Actor/Critic waves and final integration review. A clean `v0.2.5` task also ran Codex Judge and Actor, a buffered Claude Critic that independently executed `node --test` and `npm test`, atomic integration, and a resumed Codex Judge that independently passed all seven acceptance criteria. Real Codex and Claude probes both proved fresh and same-session resume calls; Codex fresh and resume runs executed workspace writes with root-level `-a never`, and Claude automated sessions executed safe Bash tools with `auto` permissions. The semantic Router completed a live classification, and one TUI completed Main calls in two workspaces before restoring the first workspace without leaking chat state. PTY coverage runs in Apple Terminal, tmux, and Zellij profiles at narrow and wide sizes, including status/log equivalence, preserving the native output tail across status-detail round trips, and proving both inline and on-demand file memory after native-session rollover. The deterministic repository suite contains 1,334 tests across 136 files: 1,333 pass by default, while one quota-consuming real-Agent test is skipped and passes through `npm run test:real-agents`.
29
31
 
30
32
  Real Provider probes depend on valid local CLI credentials. In particular, authenticate the Claude CLI before selecting a Claude-compatible Worker, then run `parallel-codex-tui --doctor --probe-agents` to prove fresh and resumed calls on that machine.
31
33
 
@@ -414,7 +416,19 @@ actor = "openai_compat"
414
416
  critic = "anthropic_compat"
415
417
  ```
416
418
 
417
- `extends` inherits the complete command protocol, including capabilities, native-session behavior, watchdogs, and interactive arguments. Override both `command` and `interactive.command` when a wrapper replaces both executables. `assignable = true` exposes a profile to Feature-level Provider cycling; custom profiles default to true, while the reserved deterministic `mock` profile defaults to false. Pairing an unknown profile, unsafe profile IDs, and inheritance cycles fail config validation before startup.
419
+ `extends` inherits the complete command protocol, including capabilities, native-session behavior, watchdogs, and interactive arguments. Override both `command` and `interactive.command` when a wrapper replaces both executables. `assignable = true` exposes a profile to runtime role and Feature-level Provider cycling; custom profiles default to true, while the reserved deterministic `mock` profile defaults to false. A role that is already configured with a nonassignable profile can still display it and cycle away from it. Pairing an unknown profile, unsafe profile IDs, and inheritance cycles fail config validation before startup.
420
+
421
+ ## Runtime Role Control
422
+
423
+ While the TUI is idle, press `Ctrl+E` to configure Main, Judge, Actor, and Critic without editing TOML or restarting. Up/Down selects a role, Left/Right (or `[`/`]`) cycles its configured Worker Provider, `M` edits the model name with a Unicode-safe cursor, and `Tab` changes the persistence scope. Press Enter to apply the complete matrix, `X` to clear the saved override and inherit its parent scope, `R` to discard unsaved edits, and `Esc` or `Ctrl+E` to return. `Ctrl+Y` copies the visible matrix. A Provider switch resets that role to the Provider's configured default model; a blank model edit also means the Provider default.
424
+
425
+ The three scopes are explicit:
426
+
427
+ - `next request` is stored in the current workspace at `.parallel-codex/role-configuration.next.json`, overrides every role once, and is removed only when a routed request consumes it.
428
+ - `current task` is stored beside that Task's `meta.json`, applies to retries and same-Task follow-ups, and updates unfinished Feature assignments without deleting earlier Workers or logs.
429
+ - `future requests` is stored in the app root at `.parallel-codex/role-configuration.json` and becomes the default for new Main calls and Tasks across restarts.
430
+
431
+ Every complex Turn writes the exact consumed matrix to `turns/<turn>/role-configuration.json`. Retries use that evidence instead of silently adopting a pending one-shot selection, native attach uses the selected Worker's persisted Provider/model, and status details show the active Turn matrix separately from historical Worker identity.
418
432
 
419
433
  For a third-party command with its own syntax, inherit `generic` so parallel-codex-tui does not inject `--sandbox`, `--permission-mode`, or other built-in-only flags:
420
434
 
@@ -457,7 +471,7 @@ Keep `[router.codex]` on `read-only`; routing only classifies requests and does
457
471
 
458
472
  The process adapter sends each role prompt to stdin and records stdout/stderr in `output.log`. Worker success is separate from the operating-system exit code: a total, first-output, idle, or stdin watchdog failure remains failed even when the terminated CLI handles `SIGTERM` and exits with code `0`, so the next role never starts from a timed-out checkpoint. `firstOutputTimeoutMs` owns silent startup, `idleTimeoutMs` starts only after real stdout/stderr activity, and `timeoutMs` remains the hard ceiling; equal or longer secondary watchdogs do not race the total deadline. Claude `--print` with `text` or `json` emits only its final result, so those launches display `working · buffered` and use `timeoutMs` without arming a separate first-output watchdog. Claude `stream-json` remains streaming and retains the configured first-output deadline.
459
473
 
460
- In chat, scroll long conversation history with the mouse wheel or PageUp/PageDown; sending a new message returns to the latest reply. A single Up/Down recalls persisted request history, and Ctrl+Up/Down remains an alternative. The outer TUI never enables application mouse tracking, so ordinary left-drag selection and system copy work without Shift while alternate-scroll keeps the wheel active in chat and Worker logs. `Ctrl+Y` also copies the current visible chat, rendered Worker log, native Agent screen, or structured overview without changing terminal modes. macOS uses `pbcopy`; Linux uses `wl-copy`, `xclip`, or `xsel` when available and falls back to OSC 52. After a complex task completes, use the same controls to move through its structured result and press `Ctrl+D` to toggle details. Press `Ctrl+N` to preserve all history and start a fresh conversation, `Ctrl+W` to open Worker logs, and `Tab` to cycle the selected Worker when Workers exist. `Ctrl+S` opens the same status detail view from chat, logs, Worker overview, or an embedded native session; it keeps the footer compact while exposing the complete route, reason, active Main/Judge/Actor/Critic Provider map, selected historical Provider/model, phase, activity timestamp, native session, and config effective/restart state. Press `Ctrl+S` or `Esc` to return without interrupting the underlying Worker. While a request is running, press `Esc` to stop it. After a failed or cancelled Task, press `Ctrl+R` to retry the same turn or `Ctrl+N` to start independently. In Worker-log views, scroll with the mouse wheel, Up/Down, or PageUp/PageDown, press `Tab` to cycle Workers, `Ctrl+N` to start a fresh conversation, and `Esc` to return to chat.
474
+ In chat, scroll long conversation history with the mouse wheel or PageUp/PageDown; sending a new message returns to the latest reply. A single Up/Down recalls persisted request history, and Ctrl+Up/Down remains an alternative. The outer TUI never enables application mouse tracking, so ordinary left-drag selection and system copy work without Shift while alternate-scroll keeps the wheel active in chat and Worker logs. `Ctrl+Y` also copies the current visible chat, rendered Worker log, native Agent screen, or structured overview without changing terminal modes. macOS uses `pbcopy`; Linux uses `wl-copy`, `xclip`, or `xsel` when available and falls back to OSC 52. After a complex task completes, use the same controls to move through its structured result and press `Ctrl+D` to toggle details. Press `Ctrl+N` to preserve all history and start a fresh conversation, `Ctrl+E` to open runtime role/model control, `Ctrl+W` to open Worker logs, and `Tab` to cycle the selected Worker when Workers exist. `Ctrl+S` opens the same status detail view from chat, logs, Worker overview, or an embedded native session; it keeps the footer compact while exposing the complete route, reason, active Main/Judge/Actor/Critic Provider/model map, selected historical Provider/model, phase, activity timestamp, native session, and config effective/restart state. Press `Ctrl+S` or `Esc` to return without interrupting the underlying Worker. While a request is running, press `Esc` to stop it. After a failed or cancelled Task, press `Ctrl+R` to retry the same turn or `Ctrl+N` to start independently. In Worker-log views, scroll with the mouse wheel, Up/Down, or PageUp/PageDown, press `Tab` to cycle Workers, `Ctrl+N` to start a fresh conversation, and `Esc` to return to chat.
461
475
 
462
476
  `Ctrl+B` opens a live Worker overview without replacing the `Ctrl+W` log shortcut. It summarizes every Judge, Actor, and Critic by turn, role, named Provider, persisted model, state, phase, latest summary, and native-session availability. The selected active worker gets a live activity line showing its first-output deadline while starting and its idle deadline after output begins. The line stays muted while healthy, changes to warning for the final 20% and danger once overdue. Up/Down, PageUp/PageDown, the mouse wheel, or `Tab` changes the selected worker; Enter or `Ctrl+W` opens its rendered log, `Ctrl+O` attaches to its native session, and `Esc` returns to the originating chat or log view. Router diagnostics and the project picker also return to the overview when opened from there.
463
477
 
@@ -523,16 +537,17 @@ The release job installs npm `^11.5.1`, runs on Node `24.15.x`, publishes the pr
523
537
  To publish a release, update `package.json` and `src/version.ts` to the same version, then push a matching tag:
524
538
 
525
539
  ```bash
526
- VERSION=0.2.10
540
+ VERSION=0.3.0
527
541
  git tag "v$VERSION"
528
542
  git push origin "v$VERSION"
529
543
  ```
530
544
 
531
- 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.2.10` requires tag `v0.2.10`. Published tags such as `v0.2.9` are immutable and must not be moved or reused.
545
+ 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.3.0` requires tag `v0.3.0`. Published tags such as `v0.2.10` are immutable and must not be moved or reused.
532
546
 
533
547
  ## Publishing Hygiene
534
548
 
535
549
  - `.parallel-codex/config.toml` is local-only and ignored.
550
+ - `.parallel-codex/role-configuration.json` and `.parallel-codex/role-configuration.next.json` are local runtime role/model selections and are ignored.
536
551
  - `.parallel-codex/last-workspace` and `.parallel-codex/workspaces.json` are local workspace-selection state and are ignored.
537
552
  - `.parallel-codex/router/` contains local request classification audit records and is ignored.
538
553
  - `.parallel-codex/sessions/` contains the workspace chat transcript, task prompts, logs, native session ids, isolated feature workspaces, and conflict evidence; never commit it.
package/dist/bootstrap.js CHANGED
@@ -2,6 +2,7 @@ import { prepareAppRoot } from "./core/app-root.js";
2
2
  import { configPath, loadConfig, writeDefaultConfig } from "./core/config.js";
3
3
  import { ensureDir, pathExists } from "./core/file-store.js";
4
4
  import { routerRuntimeDir } from "./core/paths.js";
5
+ import { RoleConfigurationManager } from "./core/role-configuration.js";
5
6
  import { SessionIndex } from "./core/session-index.js";
6
7
  import { SessionManager } from "./core/session-manager.js";
7
8
  import { prepareWorkspace } from "./core/workspace.js";
@@ -25,13 +26,18 @@ export async function createRuntime(appRoot, workspaceRoot = appRoot) {
25
26
  dataDir: config.dataDir,
26
27
  index
27
28
  });
29
+ const roleConfiguration = await RoleConfigurationManager.open({
30
+ config,
31
+ appRoot,
32
+ workspaceRoot: preparedWorkspace
33
+ });
28
34
  const pendingTaskCreations = await sessions.reconcilePendingTaskCreations();
29
35
  const workspaceCommitRecovery = await reconcileWorkspaceCommitIntents(preparedWorkspace, config.dataDir);
30
36
  await sessions.reconcileInterruptedMainSession();
31
37
  await sessions.reconcileNativeSessionState();
32
38
  const recoveredTasks = await sessions.reconcileInterruptedTasks();
33
39
  const workers = createWorkerRegistry(config);
34
- const orchestrator = new Orchestrator(config, sessions, workers, undefined, routerCwd, async () => (await loadConfig(appRoot)).router);
40
+ const orchestrator = new Orchestrator(config, sessions, workers, undefined, routerCwd, async () => (await loadConfig(appRoot)).router, { roleConfiguration });
35
41
  return {
36
42
  config,
37
43
  workspaceRoot: preparedWorkspace,
@@ -40,6 +46,7 @@ export async function createRuntime(appRoot, workspaceRoot = appRoot) {
40
46
  sessions,
41
47
  workers,
42
48
  orchestrator,
49
+ roleConfiguration,
43
50
  pendingTaskCreations,
44
51
  workspaceCommitRecovery,
45
52
  recoveredTasks
package/dist/cli.js CHANGED
@@ -16,6 +16,7 @@ import { configPath, loadConfig, withUiThemeOverride, writeDefaultConfig } from
16
16
  import { pathExists } from "./core/file-store.js";
17
17
  import { exportDiagnostics } from "./core/diagnostics.js";
18
18
  import { readRouterAudit } from "./core/router-audit.js";
19
+ import { RoleConfigurationManager } from "./core/role-configuration.js";
19
20
  import { inheritMacSystemProxy } from "./core/system-proxy.js";
20
21
  import { loadTaskSessionDetails as loadPersistedTaskSessionDetails } from "./core/task-session-details.js";
21
22
  import { listWorkspaceChoices } from "./core/workspace.js";
@@ -191,7 +192,15 @@ async function main() {
191
192
  }, persistChatMessage: (message, taskId) => state.runtime.sessions.appendChatMessage({
192
193
  ...message,
193
194
  taskId
194
- }), reloadConfig: async () => withUiThemeOverride(await loadConfig(cliArgs.appRoot), cliArgs.theme) }, state.runtime.workspaceRoot));
195
+ }), reloadConfig: async () => {
196
+ const latest = await loadConfig(cliArgs.appRoot);
197
+ await RoleConfigurationManager.open({
198
+ config: latest,
199
+ appRoot: cliArgs.appRoot,
200
+ workspaceRoot: state.runtime.workspaceRoot
201
+ });
202
+ return withUiThemeOverride(latest, cliArgs.theme);
203
+ } }, state.runtime.workspaceRoot));
195
204
  const removeSigintHandler = installInteractiveSigintExitHandler(() => shutdownController.abort());
196
205
  enterInteractiveTerminal();
197
206
  try {
@@ -0,0 +1,238 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { pathExists, readJson, removeIfExists, writeJson } from "./file-store.js";
5
+ import { EngineNameSchema } from "../domain/schemas.js";
6
+ export const CONFIGURABLE_ROLES = ["main", "judge", "actor", "critic"];
7
+ const RoleExecutionTargetSchema = z.object({
8
+ engine: EngineNameSchema,
9
+ model: z.string().max(200)
10
+ }).strict();
11
+ const RoleExecutionSelectionSchema = z.object({
12
+ main: RoleExecutionTargetSchema,
13
+ judge: RoleExecutionTargetSchema,
14
+ actor: RoleExecutionTargetSchema,
15
+ critic: RoleExecutionTargetSchema
16
+ }).strict();
17
+ const RoleConfigurationFileSchema = z.object({
18
+ version: z.literal(1),
19
+ updated_at: z.string().datetime(),
20
+ roles: RoleExecutionSelectionSchema
21
+ }).strict();
22
+ export class RoleConfigurationManager {
23
+ config;
24
+ appRoot;
25
+ workspaceRoot;
26
+ baselineSelection;
27
+ futureSelection;
28
+ constructor(config, appRoot, workspaceRoot, baselineSelection, futureSelection) {
29
+ this.config = config;
30
+ this.appRoot = appRoot;
31
+ this.workspaceRoot = workspaceRoot;
32
+ this.baselineSelection = baselineSelection;
33
+ this.futureSelection = cloneRoleSelection(futureSelection);
34
+ applyRoleEngines(this.config, futureSelection);
35
+ }
36
+ static async open(input) {
37
+ const baseline = configuredRoleSelection(input.config);
38
+ const futurePath = roleFutureConfigurationPath(input.appRoot, input.config.dataDir);
39
+ const persisted = await readRoleConfigurationIfValid(futurePath, input.config);
40
+ return new RoleConfigurationManager(input.config, input.appRoot, input.workspaceRoot, baseline, persisted ?? baseline);
41
+ }
42
+ static transient(config) {
43
+ const baseline = configuredRoleSelection(config);
44
+ return new RoleConfigurationManager(config, config.projectRoot, config.projectRoot, baseline, baseline);
45
+ }
46
+ async snapshot(taskDir) {
47
+ const [next, task, activeTurn] = await Promise.all([
48
+ readRoleConfigurationIfValid(this.nextPath(), this.config),
49
+ taskDir ? readRoleConfigurationIfValid(roleTaskConfigurationPath(taskDir), this.config) : null,
50
+ taskDir ? this.readLatestTurnSelection(taskDir) : null
51
+ ]);
52
+ return {
53
+ baseline: cloneRoleSelection(this.baselineSelection),
54
+ future: cloneRoleSelection(this.futureSelection),
55
+ next,
56
+ task,
57
+ activeTurn,
58
+ providers: roleProviderOptions(this.config)
59
+ };
60
+ }
61
+ async apply(scope, roles, taskDir) {
62
+ const parsed = parseRoleSelection(roles, this.config);
63
+ if (scope === "future") {
64
+ await writeRoleConfiguration(this.futurePath(), parsed);
65
+ this.futureSelection = cloneRoleSelection(parsed);
66
+ applyRoleEngines(this.config, parsed);
67
+ return;
68
+ }
69
+ if (scope === "next") {
70
+ await writeRoleConfiguration(this.nextPath(), parsed);
71
+ return;
72
+ }
73
+ if (!taskDir) {
74
+ throw new Error("No active Task is available for a current-task role configuration.");
75
+ }
76
+ await writeRoleConfiguration(roleTaskConfigurationPath(taskDir), parsed);
77
+ }
78
+ async clear(scope, taskDir) {
79
+ if (scope === "future") {
80
+ await removeIfExists(this.futurePath());
81
+ this.futureSelection = cloneRoleSelection(this.baselineSelection);
82
+ applyRoleEngines(this.config, this.futureSelection);
83
+ return;
84
+ }
85
+ if (scope === "next") {
86
+ await removeIfExists(this.nextPath());
87
+ return;
88
+ }
89
+ if (!taskDir) {
90
+ throw new Error("No active Task is available for a current-task role configuration.");
91
+ }
92
+ await removeIfExists(roleTaskConfigurationPath(taskDir));
93
+ }
94
+ async selectionForRequest(taskDir) {
95
+ const taskSelection = await this.selectionForTask(taskDir);
96
+ const next = await readRoleConfigurationIfValid(this.nextPath(), this.config);
97
+ if (next) {
98
+ await removeIfExists(this.nextPath());
99
+ return next;
100
+ }
101
+ return taskSelection;
102
+ }
103
+ async selectionForTask(taskDir) {
104
+ const taskSelection = taskDir
105
+ ? await readRoleConfigurationIfValid(roleTaskConfigurationPath(taskDir), this.config)
106
+ : null;
107
+ return cloneRoleSelection(taskSelection ?? this.futureSelection);
108
+ }
109
+ async writeTurnSelection(turnDir, roles) {
110
+ await writeRoleConfiguration(roleTurnConfigurationPath(turnDir), parseRoleSelection(roles, this.config));
111
+ }
112
+ async readTurnSelection(turnDir) {
113
+ return readRoleConfigurationIfValid(roleTurnConfigurationPath(turnDir), this.config);
114
+ }
115
+ async modelForTurn(turnDir, role, engine) {
116
+ const selection = await this.readTurnSelection(turnDir);
117
+ const target = selection?.[role];
118
+ return roleModelConfiguration(this.config, target?.engine === engine ? target : { engine, model: "" });
119
+ }
120
+ modelForTarget(target) {
121
+ return roleModelConfiguration(this.config, target);
122
+ }
123
+ futureRoles() {
124
+ return cloneRoleSelection(this.futureSelection);
125
+ }
126
+ baselineRoles() {
127
+ return cloneRoleSelection(this.baselineSelection);
128
+ }
129
+ futurePath() {
130
+ return roleFutureConfigurationPath(this.appRoot, this.config.dataDir);
131
+ }
132
+ nextPath() {
133
+ return roleNextConfigurationPath(this.workspaceRoot, this.config.dataDir);
134
+ }
135
+ async readLatestTurnSelection(taskDir) {
136
+ const turnsDir = join(taskDir, "turns");
137
+ if (!(await pathExists(turnsDir))) {
138
+ return null;
139
+ }
140
+ const entries = await readdir(turnsDir, { withFileTypes: true });
141
+ const latest = entries
142
+ .filter((entry) => entry.isDirectory() && /^\d{4}$/.test(entry.name))
143
+ .map((entry) => entry.name)
144
+ .sort()
145
+ .at(-1);
146
+ return latest
147
+ ? readRoleConfigurationIfValid(roleTurnConfigurationPath(join(turnsDir, latest)), this.config)
148
+ : null;
149
+ }
150
+ }
151
+ export function configuredRoleSelection(config) {
152
+ return Object.fromEntries(CONFIGURABLE_ROLES.map((role) => {
153
+ const engine = config.pairing[role];
154
+ return [role, {
155
+ engine,
156
+ model: config.workers[engine]?.model.name ?? ""
157
+ }];
158
+ }));
159
+ }
160
+ export function roleSelectionWithEngines(route, roles) {
161
+ return {
162
+ ...cloneRoleSelection(roles),
163
+ judge: roleTargetForEngine(roles.judge, route.judge_engine),
164
+ actor: roleTargetForEngine(roles.actor, route.actor_engine),
165
+ critic: roleTargetForEngine(roles.critic, route.critic_engine)
166
+ };
167
+ }
168
+ export function roleTargetForEngine(target, engine) {
169
+ return target.engine === engine ? { ...target } : { engine, model: "" };
170
+ }
171
+ export function cloneRoleSelection(roles) {
172
+ return {
173
+ main: { ...roles.main },
174
+ judge: { ...roles.judge },
175
+ actor: { ...roles.actor },
176
+ critic: { ...roles.critic }
177
+ };
178
+ }
179
+ export function roleFutureConfigurationPath(appRoot, dataDir) {
180
+ return join(appRoot, dataDir, "role-configuration.json");
181
+ }
182
+ export function roleNextConfigurationPath(workspaceRoot, dataDir) {
183
+ return join(workspaceRoot, dataDir, "role-configuration.next.json");
184
+ }
185
+ export function roleTaskConfigurationPath(taskDir) {
186
+ return join(taskDir, "role-configuration.json");
187
+ }
188
+ export function roleTurnConfigurationPath(turnDir) {
189
+ return join(turnDir, "role-configuration.json");
190
+ }
191
+ async function readRoleConfigurationIfValid(path, config) {
192
+ if (!(await pathExists(path))) {
193
+ return null;
194
+ }
195
+ const parsed = await readJson(path, RoleConfigurationFileSchema);
196
+ return parseRoleSelection(parsed.roles, config);
197
+ }
198
+ async function writeRoleConfiguration(path, roles) {
199
+ await writeJson(path, RoleConfigurationFileSchema.parse({
200
+ version: 1,
201
+ updated_at: new Date().toISOString(),
202
+ roles
203
+ }));
204
+ }
205
+ function parseRoleSelection(roles, config) {
206
+ const parsed = RoleExecutionSelectionSchema.parse(roles);
207
+ for (const role of CONFIGURABLE_ROLES) {
208
+ if (!config.workers[parsed[role].engine]) {
209
+ throw new Error(`Worker provider is not configured: ${parsed[role].engine}`);
210
+ }
211
+ }
212
+ return cloneRoleSelection(parsed);
213
+ }
214
+ function roleProviderOptions(config) {
215
+ return Object.entries(config.workers)
216
+ .map(([id, provider]) => ({
217
+ id,
218
+ model: provider.model.name,
219
+ modelProvider: provider.model.provider,
220
+ assignable: provider.assignable
221
+ }))
222
+ .sort((left, right) => left.id.localeCompare(right.id));
223
+ }
224
+ function roleModelConfiguration(config, target) {
225
+ const worker = config.workers[target.engine];
226
+ if (!worker) {
227
+ throw new Error(`Worker provider is not configured: ${target.engine}`);
228
+ }
229
+ return {
230
+ ...worker.model,
231
+ name: target.model.trim() || worker.model.name
232
+ };
233
+ }
234
+ function applyRoleEngines(config, roles) {
235
+ for (const role of CONFIGURABLE_ROLES) {
236
+ config.pairing[role] = roles[role].engine;
237
+ }
238
+ }