parallel-codex-tui 0.2.10 → 0.3.1

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,13 +6,17 @@ 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.1` is available from [npm](https://www.npmjs.com/package/parallel-codex-tui/v/0.3.1) and as a [GitHub Release](https://github.com/allendred/parallel-codex-tui/releases/tag/v0.3.1). `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. Saving now validates every selected Provider before it writes configuration, and concurrent TUI instances atomically consume a one-shot matrix exactly once.
10
+
11
+ Feature assignment now controls both Provider and model. From a retryable Feature, press `M`, use `A`/`C` to cycle the Actor/Critic Provider, and use `1`/`2` to edit the Actor/Critic model. A model change starts a distinct Worker identity, keeps all earlier logs and native sessions, and remains an explicit Feature override when Task defaults change. Status details show separate `actual`, `next`, and `future` role matrices alongside the selected historical Worker's persisted model.
12
+
13
+ `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
14
 
11
15
  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
16
 
13
17
  Complex task memory now retains the root Turn plus the latest 19 previous Turn summaries within a 12,000-character budget. When a longer task omits intermediate summaries inline, the prompt points the Worker to the complete immutable Turn history on disk. Main chat still extracts only the final Codex answer while retaining the complete CLI transcript in `output.log`; legacy transcript-shaped chat records are cleaned when displayed without rewriting their file-backed evidence. Workspace integration ignores known host metadata such as `.DS_Store` and AppleDouble files while continuing to reject real concurrent project edits.
14
18
 
15
- The default role map is Main/Judge/Actor on Codex and Critic on Claude. Status details show the complete active role map separately from each historical Worker's persisted Provider/model. Config monitoring labels Router-only edits as active on the next request and marks role, Worker, model, permission, or UI changes as requiring restart. Claude text/JSON print runs are recognized as buffered work, use the total deadline instead of a false first-output deadline, and run safe isolated tools through non-interactive `auto` permissions. The `v0.2.4` non-interactive Codex permission placement remains included. The release keeps terminal scrolling and copying available at the same time without requiring Shift and preserves the embedded native Agent scrollback across status-detail round trips and real PTY resizes.
19
+ The default role map is Main/Judge/Actor on Codex and Critic on Claude. Status details show the actual, next, and future role maps separately from each historical Worker's persisted Provider/model. Config monitoring labels Router-only edits as active on the next request and marks role, Worker, model, permission, or UI changes as requiring restart. Claude text/JSON print runs are recognized as buffered work, use the total deadline instead of a false first-output deadline, and run safe isolated tools through non-interactive `auto` permissions. The `v0.2.4` non-interactive Codex permission placement remains included. The release keeps terminal scrolling and copying available at the same time without requiring Shift and preserves the embedded native Agent scrollback across status-detail round trips and real PTY resizes.
16
20
 
17
21
  Highlights:
18
22
 
@@ -25,7 +29,7 @@ Highlights:
25
29
  - 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
30
  - 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
31
 
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`.
32
+ 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,338 tests across 136 files: 1,337 pass by default, while one quota-consuming real-Agent test is skipped and passes through `npm run test:real-agents`.
29
33
 
30
34
  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
35
 
@@ -414,7 +418,19 @@ actor = "openai_compat"
414
418
  critic = "anthropic_compat"
415
419
  ```
416
420
 
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.
421
+ `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.
422
+
423
+ ## Runtime Role Control
424
+
425
+ 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 validate every selected CLI, environment, capability contract, and proxy before saving the complete matrix. `X` clears the saved override and inherits its parent scope, `R` discards unsaved edits, and `Esc` or `Ctrl+E` returns. `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.
426
+
427
+ The three scopes are explicit:
428
+
429
+ - `next request` is stored in the current workspace at `.parallel-codex/role-configuration.next.json`, overrides every role once, and is removed only when one routed request atomically claims it. Concurrent TUI instances cannot both consume it.
430
+ - `current task` is stored beside that Task's `meta.json`, applies to retries and same-Task follow-ups, and updates inherited unfinished Feature assignments without deleting earlier Workers or logs. Explicit Feature overrides remain unchanged.
431
+ - `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.
432
+
433
+ 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 actual Turn, next-request, and future-default matrices separately from historical Worker identity.
418
434
 
419
435
  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
436
 
@@ -457,13 +473,13 @@ Keep `[router.codex]` on `read-only`; routing only classifies requests and does
457
473
 
458
474
  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
475
 
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.
476
+ 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
477
 
462
478
  `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
479
 
464
480
  From Worker overview, press `F` to open the file-backed Feature board. It summarizes every planned feature by turn, state, blocked dependencies and open Critic findings while preserving descriptions and Actor replies from the same collaboration files. Use Up/Down, PageUp/PageDown, the mouse wheel, or `Tab` to select a feature; Enter opens the selected feature's collaboration timeline; `R` refreshes immediately; and `Esc` returns to Worker overview. When a timeline was opened from this board, `Esc` returns to the Feature board instead of skipping back to Workers. While the selected feature is running, press `P` twice to pause only its active Actor or Critic process, or press `X` twice to cancel it; the first press opens a confirmation and `Esc` keeps it running. A paused feature preserves completed peers and same-wave checkpoints; select it and press `Ctrl+R` to resume the same task, turn, and native Worker session. After cancellation, already-running peers finish, queued workers stop, and integration remains blocked, so a partial wave never reaches the live workspace. Once the task settles as cancelled, `Ctrl+R` retries the cancelled task with persisted worker sessions; completed peers are loaded from their same-wave checkpoints instead of running again. Checkpoint load, reuse, and recovery events appear in the collaboration timeline.
465
481
 
466
- For a failed, cancelled, or paused task, select an unfinished Feature and press `M` to edit its Provider assignment. `A` cycles that Feature's Actor through every `assignable` Worker Provider, `C` cycles its Critic, and `M` or `Esc` closes the assignment prompt. The choice is persisted in `features/<turn>-<feature>/assignment.json`, appears on the Feature board and collaboration artifacts, and takes effect on the next `Ctrl+R` retry. A changed role starts a new Provider-specific Worker while every previous Worker log and native-session record remains available. Assignment writes hold the same task lease as execution, so another live TUI cannot change a Feature while it is resuming.
482
+ For a failed, cancelled, or paused task, select an unfinished Feature and press `M` to edit its assignment. `A` cycles that Feature's Actor through every `assignable` Worker Provider, `C` cycles its Critic, `1` edits the Actor model, `2` edits the Critic model, and `M` or `Esc` closes the prompt. The choice is persisted in `features/<turn>-<feature>/assignment.json`, appears on the Feature board and collaboration artifacts, and takes effect on the next `Ctrl+R` retry. A changed Provider or model starts a distinct Worker while every previous Worker log and native-session record remains available. Feature overrides survive later Task-default changes. Assignment writes hold the same task lease as execution, so another live TUI cannot change a Feature while it is resuming.
467
483
 
468
484
  From Worker overview, press `C` to open the file-backed Actor/Critic collaboration timeline. It merges `dialogue/actor-critic.jsonl`, feature status, Critic findings, Actor replies, finding resolution, and Wave events into one chronological view. The timeline follows new file evidence automatically and shows verified `fixed`/`open` counts when resolution evidence exists. Up/Down selects a collaboration event; `Enter` opens its complete event detail, including artifact paths from dialogue, status, Critic findings, Actor replies, and finding resolution; `U` filters to unresolved feature evidence; `Tab` cycles all features and each individual feature; `R` refreshes immediately; the mouse wheel or PageUp/PageDown scrolls history; and `Esc` returns to Worker overview.
469
485
 
@@ -523,16 +539,17 @@ The release job installs npm `^11.5.1`, runs on Node `24.15.x`, publishes the pr
523
539
  To publish a release, update `package.json` and `src/version.ts` to the same version, then push a matching tag:
524
540
 
525
541
  ```bash
526
- VERSION=0.2.10
542
+ VERSION=0.3.1
527
543
  git tag "v$VERSION"
528
544
  git push origin "v$VERSION"
529
545
  ```
530
546
 
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.
547
+ 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.1` requires tag `v0.3.1`. Published tags such as `v0.2.10` are immutable and must not be moved or reused.
532
548
 
533
549
  ## Publishing Hygiene
534
550
 
535
551
  - `.parallel-codex/config.toml` is local-only and ignored.
552
+ - `.parallel-codex/role-configuration.json` and `.parallel-codex/role-configuration.next.json` are local runtime role/model selections and are ignored.
536
553
  - `.parallel-codex/last-workspace` and `.parallel-codex/workspaces.json` are local workspace-selection state and are ignored.
537
554
  - `.parallel-codex/router/` contains local request classification audit records and is ignored.
538
555
  - `.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 {
@@ -133,7 +133,9 @@ async function readCollaborationFeatures(taskDir) {
133
133
  updatedAt: status.updated_at,
134
134
  ...(assignment ? {
135
135
  actorEngine: assignment.actor_engine,
136
- criticEngine: assignment.critic_engine
136
+ criticEngine: assignment.critic_engine,
137
+ actorModel: assignment.actor_model,
138
+ criticModel: assignment.critic_model
137
139
  } : {}),
138
140
  findings: findingEvidence.count,
139
141
  replies: replyEvidence.count,
@@ -0,0 +1,277 @@
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 { claimTaskRunLease, TaskRunLeaseConflictError } from "./process-ownership.js";
6
+ import { EngineNameSchema, WorkerModelNameSchema } from "../domain/schemas.js";
7
+ export const CONFIGURABLE_ROLES = ["main", "judge", "actor", "critic"];
8
+ const RoleExecutionTargetSchema = z.object({
9
+ engine: EngineNameSchema,
10
+ model: WorkerModelNameSchema
11
+ }).strict();
12
+ const RoleExecutionSelectionSchema = z.object({
13
+ main: RoleExecutionTargetSchema,
14
+ judge: RoleExecutionTargetSchema,
15
+ actor: RoleExecutionTargetSchema,
16
+ critic: RoleExecutionTargetSchema
17
+ }).strict();
18
+ const RoleConfigurationFileSchema = z.object({
19
+ version: z.literal(1),
20
+ updated_at: z.string().datetime(),
21
+ roles: RoleExecutionSelectionSchema
22
+ }).strict();
23
+ export class RoleConfigurationManager {
24
+ config;
25
+ appRoot;
26
+ workspaceRoot;
27
+ baselineSelection;
28
+ futureSelection;
29
+ constructor(config, appRoot, workspaceRoot, baselineSelection, futureSelection) {
30
+ this.config = config;
31
+ this.appRoot = appRoot;
32
+ this.workspaceRoot = workspaceRoot;
33
+ this.baselineSelection = baselineSelection;
34
+ this.futureSelection = cloneRoleSelection(futureSelection);
35
+ applyRoleEngines(this.config, futureSelection);
36
+ }
37
+ static async open(input) {
38
+ const baseline = configuredRoleSelection(input.config);
39
+ const futurePath = roleFutureConfigurationPath(input.appRoot, input.config.dataDir);
40
+ const persisted = await readRoleConfigurationIfValid(futurePath, input.config);
41
+ return new RoleConfigurationManager(input.config, input.appRoot, input.workspaceRoot, baseline, persisted ?? baseline);
42
+ }
43
+ static transient(config) {
44
+ const baseline = configuredRoleSelection(config);
45
+ return new RoleConfigurationManager(config, config.projectRoot, config.projectRoot, baseline, baseline);
46
+ }
47
+ async snapshot(taskDir) {
48
+ const [next, task, activeTurn] = await Promise.all([
49
+ readRoleConfigurationIfValid(this.nextPath(), this.config),
50
+ taskDir ? readRoleConfigurationIfValid(roleTaskConfigurationPath(taskDir), this.config) : null,
51
+ taskDir ? this.readLatestTurnSelection(taskDir) : null
52
+ ]);
53
+ return {
54
+ baseline: cloneRoleSelection(this.baselineSelection),
55
+ future: cloneRoleSelection(this.futureSelection),
56
+ next,
57
+ task,
58
+ activeTurn,
59
+ providers: roleProviderOptions(this.config)
60
+ };
61
+ }
62
+ async apply(scope, roles, taskDir) {
63
+ const parsed = parseRoleSelection(roles, this.config);
64
+ if (scope === "future") {
65
+ await writeRoleConfiguration(this.futurePath(), parsed);
66
+ this.futureSelection = cloneRoleSelection(parsed);
67
+ applyRoleEngines(this.config, parsed);
68
+ return;
69
+ }
70
+ if (scope === "next") {
71
+ await this.withNextConfigurationLease(() => writeRoleConfiguration(this.nextPath(), parsed));
72
+ return;
73
+ }
74
+ if (!taskDir) {
75
+ throw new Error("No active Task is available for a current-task role configuration.");
76
+ }
77
+ await writeRoleConfiguration(roleTaskConfigurationPath(taskDir), parsed);
78
+ }
79
+ async clear(scope, taskDir) {
80
+ if (scope === "future") {
81
+ await removeIfExists(this.futurePath());
82
+ this.futureSelection = cloneRoleSelection(this.baselineSelection);
83
+ applyRoleEngines(this.config, this.futureSelection);
84
+ return;
85
+ }
86
+ if (scope === "next") {
87
+ await this.withNextConfigurationLease(() => removeIfExists(this.nextPath()));
88
+ return;
89
+ }
90
+ if (!taskDir) {
91
+ throw new Error("No active Task is available for a current-task role configuration.");
92
+ }
93
+ await removeIfExists(roleTaskConfigurationPath(taskDir));
94
+ }
95
+ async selectionForRequest(taskDir) {
96
+ const taskSelection = await this.selectionForTask(taskDir);
97
+ return this.withNextConfigurationLease(async () => {
98
+ const next = await readRoleConfigurationIfValid(this.nextPath(), this.config);
99
+ if (!next) {
100
+ return taskSelection;
101
+ }
102
+ await removeIfExists(this.nextPath());
103
+ return next;
104
+ });
105
+ }
106
+ async selectionForTask(taskDir) {
107
+ const taskSelection = taskDir
108
+ ? await readRoleConfigurationIfValid(roleTaskConfigurationPath(taskDir), this.config)
109
+ : null;
110
+ return cloneRoleSelection(taskSelection ?? this.futureSelection);
111
+ }
112
+ async writeTurnSelection(turnDir, roles) {
113
+ await writeRoleConfiguration(roleTurnConfigurationPath(turnDir), parseRoleSelection(roles, this.config));
114
+ }
115
+ async readTurnSelection(turnDir) {
116
+ return readRoleConfigurationIfValid(roleTurnConfigurationPath(turnDir), this.config);
117
+ }
118
+ async modelForTurn(turnDir, role, engine) {
119
+ const selection = await this.readTurnSelection(turnDir);
120
+ const target = selection?.[role];
121
+ return roleModelConfiguration(this.config, target?.engine === engine ? target : { engine, model: "" });
122
+ }
123
+ modelForTarget(target) {
124
+ return roleModelConfiguration(this.config, target);
125
+ }
126
+ futureRoles() {
127
+ return cloneRoleSelection(this.futureSelection);
128
+ }
129
+ baselineRoles() {
130
+ return cloneRoleSelection(this.baselineSelection);
131
+ }
132
+ validate(roles) {
133
+ return parseRoleSelection(roles, this.config);
134
+ }
135
+ workspaceRootPath() {
136
+ return this.workspaceRoot;
137
+ }
138
+ futurePath() {
139
+ return roleFutureConfigurationPath(this.appRoot, this.config.dataDir);
140
+ }
141
+ nextPath() {
142
+ return roleNextConfigurationPath(this.workspaceRoot, this.config.dataDir);
143
+ }
144
+ nextLeaseDir() {
145
+ return join(this.workspaceRoot, this.config.dataDir, ".role-configuration-next-lock");
146
+ }
147
+ async withNextConfigurationLease(run) {
148
+ let lease = null;
149
+ for (let attempt = 0; attempt < 80; attempt += 1) {
150
+ try {
151
+ lease = await claimTaskRunLease(this.nextLeaseDir());
152
+ break;
153
+ }
154
+ catch (error) {
155
+ if (!(error instanceof TaskRunLeaseConflictError) || attempt === 79) {
156
+ throw error;
157
+ }
158
+ await delay(25);
159
+ }
160
+ }
161
+ if (!lease) {
162
+ throw new Error("Timed out waiting to update the next-request role configuration.");
163
+ }
164
+ try {
165
+ return await run();
166
+ }
167
+ finally {
168
+ await lease.release();
169
+ }
170
+ }
171
+ async readLatestTurnSelection(taskDir) {
172
+ const turnsDir = join(taskDir, "turns");
173
+ if (!(await pathExists(turnsDir))) {
174
+ return null;
175
+ }
176
+ const entries = await readdir(turnsDir, { withFileTypes: true });
177
+ const latest = entries
178
+ .filter((entry) => entry.isDirectory() && /^\d{4}$/.test(entry.name))
179
+ .map((entry) => entry.name)
180
+ .sort()
181
+ .at(-1);
182
+ return latest
183
+ ? readRoleConfigurationIfValid(roleTurnConfigurationPath(join(turnsDir, latest)), this.config)
184
+ : null;
185
+ }
186
+ }
187
+ export function configuredRoleSelection(config) {
188
+ return Object.fromEntries(CONFIGURABLE_ROLES.map((role) => {
189
+ const engine = config.pairing[role];
190
+ return [role, {
191
+ engine,
192
+ model: config.workers[engine]?.model.name ?? ""
193
+ }];
194
+ }));
195
+ }
196
+ export function roleSelectionWithEngines(route, roles) {
197
+ return {
198
+ ...cloneRoleSelection(roles),
199
+ judge: roleTargetForEngine(roles.judge, route.judge_engine),
200
+ actor: roleTargetForEngine(roles.actor, route.actor_engine),
201
+ critic: roleTargetForEngine(roles.critic, route.critic_engine)
202
+ };
203
+ }
204
+ export function roleTargetForEngine(target, engine) {
205
+ return target.engine === engine ? { ...target } : { engine, model: "" };
206
+ }
207
+ export function cloneRoleSelection(roles) {
208
+ return {
209
+ main: { ...roles.main },
210
+ judge: { ...roles.judge },
211
+ actor: { ...roles.actor },
212
+ critic: { ...roles.critic }
213
+ };
214
+ }
215
+ export function roleFutureConfigurationPath(appRoot, dataDir) {
216
+ return join(appRoot, dataDir, "role-configuration.json");
217
+ }
218
+ export function roleNextConfigurationPath(workspaceRoot, dataDir) {
219
+ return join(workspaceRoot, dataDir, "role-configuration.next.json");
220
+ }
221
+ export function roleTaskConfigurationPath(taskDir) {
222
+ return join(taskDir, "role-configuration.json");
223
+ }
224
+ export function roleTurnConfigurationPath(turnDir) {
225
+ return join(turnDir, "role-configuration.json");
226
+ }
227
+ async function readRoleConfigurationIfValid(path, config) {
228
+ if (!(await pathExists(path))) {
229
+ return null;
230
+ }
231
+ const parsed = await readJson(path, RoleConfigurationFileSchema);
232
+ return parseRoleSelection(parsed.roles, config);
233
+ }
234
+ async function writeRoleConfiguration(path, roles) {
235
+ await writeJson(path, RoleConfigurationFileSchema.parse({
236
+ version: 1,
237
+ updated_at: new Date().toISOString(),
238
+ roles
239
+ }));
240
+ }
241
+ function parseRoleSelection(roles, config) {
242
+ const parsed = RoleExecutionSelectionSchema.parse(roles);
243
+ for (const role of CONFIGURABLE_ROLES) {
244
+ if (!config.workers[parsed[role].engine]) {
245
+ throw new Error(`Worker provider is not configured: ${parsed[role].engine}`);
246
+ }
247
+ }
248
+ return cloneRoleSelection(parsed);
249
+ }
250
+ function roleProviderOptions(config) {
251
+ return Object.entries(config.workers)
252
+ .map(([id, provider]) => ({
253
+ id,
254
+ model: provider.model.name,
255
+ modelProvider: provider.model.provider,
256
+ assignable: provider.assignable
257
+ }))
258
+ .sort((left, right) => left.id.localeCompare(right.id));
259
+ }
260
+ function roleModelConfiguration(config, target) {
261
+ const worker = config.workers[target.engine];
262
+ if (!worker) {
263
+ throw new Error(`Worker provider is not configured: ${target.engine}`);
264
+ }
265
+ return {
266
+ ...worker.model,
267
+ name: target.model.trim() || worker.model.name
268
+ };
269
+ }
270
+ function applyRoleEngines(config, roles) {
271
+ for (const role of CONFIGURABLE_ROLES) {
272
+ config.pairing[role] = roles[role].engine;
273
+ }
274
+ }
275
+ function delay(milliseconds) {
276
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
277
+ }
@@ -170,10 +170,19 @@ export const FeatureStatusSchema = z.object({
170
170
  state: FeatureStateSchema,
171
171
  updated_at: z.string().datetime()
172
172
  });
173
+ export const WorkerModelNameSchema = z.string()
174
+ .max(200)
175
+ .refine((value) => !/[\u0000-\u001f\u007f]/.test(value), {
176
+ message: "Model name cannot contain control characters"
177
+ });
173
178
  export const FeatureAssignmentSchema = z.object({
174
179
  version: z.literal(1),
175
180
  actor_engine: EngineNameSchema,
176
181
  critic_engine: EngineNameSchema,
182
+ actor_model: WorkerModelNameSchema.default(""),
183
+ critic_model: WorkerModelNameSchema.default(""),
184
+ actor_override: z.boolean().default(false),
185
+ critic_override: z.boolean().default(false),
177
186
  updated_at: z.string().datetime()
178
187
  });
179
188
  export const TurnMetaSchema = z.object({
@@ -70,7 +70,7 @@ export async function createFeatureChannel(input) {
70
70
  await writeText(channel.actorRepliesPath, "");
71
71
  await writeText(channel.criticFindingsPath, "");
72
72
  if (input.actorEngine && input.criticEngine) {
73
- await writeFeatureAssignment(channel, input.actorEngine, input.criticEngine);
73
+ await writeFeatureAssignment(channel, input.actorEngine, input.criticEngine, input.actorModel, input.criticModel);
74
74
  }
75
75
  await updateFeatureStatus(channel, "created");
76
76
  await appendFeatureDialogue(channel, "feature.created", "actor", "Feature mailbox created for the current turn.");
@@ -89,7 +89,7 @@ async function ensureFeatureChannelFiles(input, channel, refreshDefinition) {
89
89
  }
90
90
  }
91
91
  if (!(await pathExists(channel.assignmentPath)) && input.actorEngine && input.criticEngine) {
92
- await writeFeatureAssignment(channel, input.actorEngine, input.criticEngine);
92
+ await writeFeatureAssignment(channel, input.actorEngine, input.criticEngine, input.actorModel, input.criticModel);
93
93
  }
94
94
  }
95
95
  export async function readFeatureAssignment(channel, fallback) {
@@ -101,15 +101,23 @@ export async function readFeatureAssignment(channel, fallback) {
101
101
  version: 1,
102
102
  actor_engine: fallback.actor,
103
103
  critic_engine: fallback.critic,
104
+ actor_model: "",
105
+ critic_model: "",
106
+ actor_override: false,
107
+ critic_override: false,
104
108
  updated_at: new Date(0).toISOString()
105
109
  });
106
110
  }
107
111
  }
108
- export async function writeFeatureAssignment(channel, actorEngine, criticEngine) {
112
+ export async function writeFeatureAssignment(channel, actorEngine, criticEngine, actorModel = "", criticModel = "", options = {}) {
109
113
  const assignment = FeatureAssignmentSchema.parse({
110
114
  version: 1,
111
115
  actor_engine: actorEngine,
112
116
  critic_engine: criticEngine,
117
+ actor_model: actorModel,
118
+ critic_model: criticModel,
119
+ actor_override: options.actorOverride ?? false,
120
+ critic_override: options.criticOverride ?? false,
113
121
  updated_at: new Date().toISOString()
114
122
  });
115
123
  await writeJson(channel.assignmentPath, assignment);