parallel-codex-tui 0.3.0 → 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,7 +6,9 @@ Built with Codex-assisted development.
6
6
 
7
7
  ## Current Release
8
8
 
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.
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.
10
12
 
11
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.
12
14
 
@@ -14,7 +16,7 @@ A real Claude acceptance retires the first native session, inserts 16 newer reco
14
16
 
15
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.
16
18
 
17
- 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.
18
20
 
19
21
  Highlights:
20
22
 
@@ -27,7 +29,7 @@ Highlights:
27
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.
28
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.
29
31
 
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`.
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`.
31
33
 
32
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.
33
35
 
@@ -420,15 +422,15 @@ critic = "anthropic_compat"
420
422
 
421
423
  ## Runtime Role Control
422
424
 
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.
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.
424
426
 
425
427
  The three scopes are explicit:
426
428
 
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
+ - `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.
429
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.
430
432
 
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.
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.
432
434
 
433
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:
434
436
 
@@ -477,7 +479,7 @@ In chat, scroll long conversation history with the mouse wheel or PageUp/PageDow
477
479
 
478
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.
479
481
 
480
- 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.
481
483
 
482
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.
483
485
 
@@ -537,12 +539,12 @@ The release job installs npm `^11.5.1`, runs on Node `24.15.x`, publishes the pr
537
539
  To publish a release, update `package.json` and `src/version.ts` to the same version, then push a matching tag:
538
540
 
539
541
  ```bash
540
- VERSION=0.3.0
542
+ VERSION=0.3.1
541
543
  git tag "v$VERSION"
542
544
  git push origin "v$VERSION"
543
545
  ```
544
546
 
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.
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.
546
548
 
547
549
  ## Publishing Hygiene
548
550
 
@@ -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,
@@ -2,11 +2,12 @@ import { readdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { pathExists, readJson, removeIfExists, writeJson } from "./file-store.js";
5
- import { EngineNameSchema } from "../domain/schemas.js";
5
+ import { claimTaskRunLease, TaskRunLeaseConflictError } from "./process-ownership.js";
6
+ import { EngineNameSchema, WorkerModelNameSchema } from "../domain/schemas.js";
6
7
  export const CONFIGURABLE_ROLES = ["main", "judge", "actor", "critic"];
7
8
  const RoleExecutionTargetSchema = z.object({
8
9
  engine: EngineNameSchema,
9
- model: z.string().max(200)
10
+ model: WorkerModelNameSchema
10
11
  }).strict();
11
12
  const RoleExecutionSelectionSchema = z.object({
12
13
  main: RoleExecutionTargetSchema,
@@ -67,7 +68,7 @@ export class RoleConfigurationManager {
67
68
  return;
68
69
  }
69
70
  if (scope === "next") {
70
- await writeRoleConfiguration(this.nextPath(), parsed);
71
+ await this.withNextConfigurationLease(() => writeRoleConfiguration(this.nextPath(), parsed));
71
72
  return;
72
73
  }
73
74
  if (!taskDir) {
@@ -83,7 +84,7 @@ export class RoleConfigurationManager {
83
84
  return;
84
85
  }
85
86
  if (scope === "next") {
86
- await removeIfExists(this.nextPath());
87
+ await this.withNextConfigurationLease(() => removeIfExists(this.nextPath()));
87
88
  return;
88
89
  }
89
90
  if (!taskDir) {
@@ -93,12 +94,14 @@ export class RoleConfigurationManager {
93
94
  }
94
95
  async selectionForRequest(taskDir) {
95
96
  const taskSelection = await this.selectionForTask(taskDir);
96
- const next = await readRoleConfigurationIfValid(this.nextPath(), this.config);
97
- if (next) {
97
+ return this.withNextConfigurationLease(async () => {
98
+ const next = await readRoleConfigurationIfValid(this.nextPath(), this.config);
99
+ if (!next) {
100
+ return taskSelection;
101
+ }
98
102
  await removeIfExists(this.nextPath());
99
103
  return next;
100
- }
101
- return taskSelection;
104
+ });
102
105
  }
103
106
  async selectionForTask(taskDir) {
104
107
  const taskSelection = taskDir
@@ -126,12 +129,45 @@ export class RoleConfigurationManager {
126
129
  baselineRoles() {
127
130
  return cloneRoleSelection(this.baselineSelection);
128
131
  }
132
+ validate(roles) {
133
+ return parseRoleSelection(roles, this.config);
134
+ }
135
+ workspaceRootPath() {
136
+ return this.workspaceRoot;
137
+ }
129
138
  futurePath() {
130
139
  return roleFutureConfigurationPath(this.appRoot, this.config.dataDir);
131
140
  }
132
141
  nextPath() {
133
142
  return roleNextConfigurationPath(this.workspaceRoot, this.config.dataDir);
134
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
+ }
135
171
  async readLatestTurnSelection(taskDir) {
136
172
  const turnsDir = join(taskDir, "turns");
137
173
  if (!(await pathExists(turnsDir))) {
@@ -236,3 +272,6 @@ function applyRoleEngines(config, roles) {
236
272
  config.pairing[role] = roles[role].engine;
237
273
  }
238
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);
@@ -1,5 +1,7 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { cp, readdir } from "node:fs/promises";
2
3
  import { join } from "node:path";
4
+ import { runRuntimePreflight } from "../doctor.js";
3
5
  import { appendJsonLine, ensureDir, pathExists, readJson, readTextIfExists, removeIfExists, writeJson, writeText } from "../core/file-store.js";
4
6
  import { runWithLeaseFinalization } from "../core/lease-finalization.js";
5
7
  import { extractMainResponse } from "../core/main-response.js";
@@ -184,17 +186,25 @@ export class Orchestrator {
184
186
  };
185
187
  const assignmentPath = join(featureDir, "assignment.json");
186
188
  const current = await readFeatureAssignment({ assignmentPath }, fallback);
187
- const assignment = await writeFeatureAssignment({ assignmentPath }, input.role === "actor" ? input.engine : current.actor_engine, input.role === "critic" ? input.engine : current.critic_engine);
189
+ const nextModel = input.model === undefined
190
+ ? input.role === "actor"
191
+ ? current.actor_engine === input.engine ? current.actor_model : ""
192
+ : current.critic_engine === input.engine ? current.critic_model : ""
193
+ : input.model;
194
+ const assignment = await writeFeatureAssignment({ assignmentPath }, input.role === "actor" ? input.engine : current.actor_engine, input.role === "critic" ? input.engine : current.critic_engine, input.role === "actor" ? nextModel : current.actor_model, input.role === "critic" ? nextModel : current.critic_model, {
195
+ actorOverride: input.role === "actor" ? true : current.actor_override,
196
+ criticOverride: input.role === "critic" ? true : current.critic_override
197
+ });
188
198
  await appendJsonLine(join(task.dir, "dialogue", "actor-critic.jsonl"), {
189
199
  time: new Date().toISOString(),
190
200
  feature_id: input.featureId,
191
201
  turn_id: status.turn_id,
192
202
  type: "feature.assignment_changed",
193
203
  role: input.role,
194
- message: `${capitalize(input.role)} reassigned to ${input.engine}.`,
204
+ message: `${capitalize(input.role)} reassigned to ${input.engine}/${nextModel.trim() || "default"}.`,
195
205
  paths: { assignment: assignmentPath }
196
206
  });
197
- await this.sessions.appendEvent(task, "feature.assignment_changed", `${input.featureId} ${input.role} reassigned to ${input.engine}`);
207
+ await this.sessions.appendEvent(task, "feature.assignment_changed", `${input.featureId} ${input.role} reassigned to ${input.engine}/${nextModel.trim() || "default"}`);
198
208
  return { featureId: input.featureId, assignment };
199
209
  });
200
210
  }
@@ -205,6 +215,18 @@ export class Orchestrator {
205
215
  }
206
216
  return this.roleConfiguration.snapshot(task?.dir);
207
217
  }
218
+ async validateRoleConfiguration(roles) {
219
+ const parsed = this.roleConfiguration.validate(roles);
220
+ const candidate = structuredClone(this.config);
221
+ candidate.router.defaultMode = "auto";
222
+ candidate.pairing.main = parsed.main.engine;
223
+ candidate.pairing.judge = parsed.judge.engine;
224
+ candidate.pairing.actor = parsed.actor.engine;
225
+ candidate.pairing.critic = parsed.critic.engine;
226
+ const preflight = this.dependencies.roleConfigurationPreflight
227
+ ?? ((config, workspaceRoot) => runRuntimePreflight(config, workspaceRoot, process.env, { includeRouter: false }));
228
+ return preflight(candidate, this.roleConfiguration.workspaceRootPath());
229
+ }
208
230
  async updateRoleConfiguration(input) {
209
231
  if (input.scope !== "task") {
210
232
  await this.roleConfiguration.apply(input.scope, input.roles);
@@ -273,7 +295,15 @@ export class Orchestrator {
273
295
  if (!status || status.task_id !== task.id || status.state === "approved") {
274
296
  continue;
275
297
  }
276
- await writeFeatureAssignment({ assignmentPath: join(featureDir, "assignment.json") }, executionRoles.actor.engine, executionRoles.critic.engine);
298
+ const assignmentPath = join(featureDir, "assignment.json");
299
+ const current = await readFeatureAssignment({ assignmentPath }, {
300
+ actor: executionRoles.actor.engine,
301
+ critic: executionRoles.critic.engine
302
+ });
303
+ await writeFeatureAssignment({ assignmentPath }, current.actor_override ? current.actor_engine : executionRoles.actor.engine, current.critic_override ? current.critic_engine : executionRoles.critic.engine, current.actor_override ? current.actor_model : executionRoles.actor.model, current.critic_override ? current.critic_model : executionRoles.critic.model, {
304
+ actorOverride: current.actor_override,
305
+ criticOverride: current.critic_override
306
+ });
277
307
  }
278
308
  }
279
309
  async resumeTaskRun(input, pausedFeatureId) {
@@ -536,6 +566,8 @@ export class Orchestrator {
536
566
  feature,
537
567
  actorEngine: route.actor_engine,
538
568
  criticEngine: route.critic_engine,
569
+ actorModel: selectedRoleModel(input.roleSelection, "actor", route.actor_engine),
570
+ criticModel: selectedRoleModel(input.roleSelection, "critic", route.critic_engine),
539
571
  resume: input.retry
540
572
  })));
541
573
  return await this.runFeaturePlanWithConflictReplan(input, task, route, turn, workers, judge, featurePlan, features);
@@ -547,6 +579,8 @@ export class Orchestrator {
547
579
  judgeDir: judge.dir,
548
580
  actorEngine: route.actor_engine,
549
581
  criticEngine: route.critic_engine,
582
+ actorModel: selectedRoleModel(input.roleSelection, "actor", route.actor_engine),
583
+ criticModel: selectedRoleModel(input.roleSelection, "critic", route.critic_engine),
550
584
  resume: input.retry
551
585
  });
552
586
  features = [feature];
@@ -582,6 +616,8 @@ export class Orchestrator {
582
616
  feature,
583
617
  actorEngine: route.actor_engine,
584
618
  criticEngine: route.critic_engine,
619
+ actorModel: selectedRoleModel(input.roleSelection, "actor", route.actor_engine),
620
+ criticModel: selectedRoleModel(input.roleSelection, "critic", route.critic_engine),
585
621
  resume: input.retry
586
622
  })));
587
623
  return await this.runFeaturePlanWithConflictReplan(input, task, route, turn, workers, judge, featurePlan, features);
@@ -593,6 +629,8 @@ export class Orchestrator {
593
629
  judgeDir: judge.dir,
594
630
  actorEngine: route.actor_engine,
595
631
  criticEngine: route.critic_engine,
632
+ actorModel: selectedRoleModel(input.roleSelection, "actor", route.actor_engine),
633
+ criticModel: selectedRoleModel(input.roleSelection, "critic", route.critic_engine),
596
634
  resume: input.retry
597
635
  });
598
636
  features = [feature];
@@ -761,6 +799,8 @@ export class Orchestrator {
761
799
  feature,
762
800
  actorEngine: route.actor_engine,
763
801
  criticEngine: route.critic_engine,
802
+ actorModel: selectedRoleModel(currentInput.roleSelection, "actor", route.actor_engine),
803
+ criticModel: selectedRoleModel(currentInput.roleSelection, "critic", route.critic_engine),
764
804
  resume: true,
765
805
  refreshDefinition: true
766
806
  })));
@@ -845,7 +885,11 @@ export class Orchestrator {
845
885
  await this.sessions.updateTaskStatus(task, "actor_running");
846
886
  const actorRunById = new Map();
847
887
  if (restoredWave) {
848
- const restoredActors = await Promise.all(wave.map((definition) => this.loadCompletedFeatureActor(task, requiredFeatureAssignment(assignments, requiredChannel(channels, definition)).actor_engine, definition, requiredChannel(channels, definition))));
888
+ const restoredActors = await Promise.all(wave.map((definition) => {
889
+ const channel = requiredChannel(channels, definition);
890
+ const assignment = requiredFeatureAssignment(assignments, channel);
891
+ return this.loadCompletedFeatureActor(task, assignment.actor_engine, assignment.actor_model, definition, channel);
892
+ }));
849
893
  for (const actorRun of restoredActors) {
850
894
  if (actorRun) {
851
895
  actorRunById.set(actorRun.definition.id, actorRun);
@@ -864,7 +908,8 @@ export class Orchestrator {
864
908
  }
865
909
  const freshActorRuns = await mapWithConcurrency(pendingActors, concurrency, async (definition) => {
866
910
  const channel = requiredChannel(channels, definition);
867
- const actor = await this.runActor(input, task, requiredFeatureAssignment(assignments, channel).actor_engine, judge.dir, workers, turn, channel, undefined, true, requiredFeatureWorkspace(workspaceWave.featureDirs, channel));
911
+ const assignment = requiredFeatureAssignment(assignments, channel);
912
+ const actor = await this.runActor(input, task, assignment.actor_engine, judge.dir, workers, turn, channel, assignment.actor_model, undefined, true, requiredFeatureWorkspace(workspaceWave.featureDirs, channel), true);
868
913
  actorCompleted += 1;
869
914
  reportProgress("actor", actorCompleted, wave.length, actorCompleted === wave.length ? "done" : "running", "waiting");
870
915
  return { definition, channel, actor };
@@ -883,7 +928,10 @@ export class Orchestrator {
883
928
  await this.sessions.updateTaskStatus(task, "critic_running");
884
929
  const pairRunById = new Map();
885
930
  if (restoredWave) {
886
- const restoredPairs = await Promise.all(actorRuns.map((actorRun) => this.loadCompletedFeaturePair(task, requiredFeatureAssignment(assignments, actorRun.channel).critic_engine, actorRun)));
931
+ const restoredPairs = await Promise.all(actorRuns.map((actorRun) => {
932
+ const assignment = requiredFeatureAssignment(assignments, actorRun.channel);
933
+ return this.loadCompletedFeaturePair(task, assignment.critic_engine, assignment.critic_model, actorRun);
934
+ }));
887
935
  for (const pairRun of restoredPairs) {
888
936
  if (pairRun) {
889
937
  pairRunById.set(pairRun.definition.id, pairRun);
@@ -898,8 +946,9 @@ export class Orchestrator {
898
946
  await this.sessions.appendEvent(task, "feature.wave_critic_checkpoints_reused", `Reused ${criticCompleted}/${actorRuns.length} completed Critic checkpoints in wave ${waveNumber}/${featureWaves.length}`);
899
947
  }
900
948
  const freshPairRuns = await mapWithConcurrency(pendingCritics, concurrency, async (actorRun) => {
949
+ const assignment = requiredFeatureAssignment(assignments, actorRun.channel);
901
950
  const reviewWorkspace = await workspaceManager.prepareFeatureReviewWorkspace(workspaceWave, actorRun.channel.id);
902
- const critic = await this.runCritic(input, task, requiredFeatureAssignment(assignments, actorRun.channel).critic_engine, judge.dir, actorRun.actor.dir, workers, turn, actorRun.channel, true, reviewWorkspace);
951
+ const critic = await this.runCritic(input, task, assignment.critic_engine, judge.dir, actorRun.actor.dir, workers, turn, actorRun.channel, assignment.critic_model, true, reviewWorkspace);
903
952
  criticCompleted += 1;
904
953
  reportProgress("critic", criticCompleted, actorRuns.length, "done", criticCompleted === actorRuns.length ? "done" : "running");
905
954
  return { ...actorRun, critic };
@@ -959,7 +1008,8 @@ export class Orchestrator {
959
1008
  let revisionCompleted = 0;
960
1009
  reportProgress("revision", revisionCompleted, revisionRuns.length, "revision", "done");
961
1010
  const revisedActors = await mapWithConcurrency(revisionRuns, concurrency, async (pair) => {
962
- const actor = await this.runActor(input, task, requiredFeatureAssignment(assignments, pair.channel).actor_engine, judge.dir, workers, turn, pair.channel, buildRevisionRequest(pair.review, pair.channel, revisionRound, this.config.orchestration.maxRevisionRounds), true, requiredFeatureWorkspace(workspaceWave.featureDirs, pair.channel));
1011
+ const assignment = requiredFeatureAssignment(assignments, pair.channel);
1012
+ const actor = await this.runActor(input, task, assignment.actor_engine, judge.dir, workers, turn, pair.channel, assignment.actor_model, buildRevisionRequest(pair.review, pair.channel, revisionRound, this.config.orchestration.maxRevisionRounds), true, requiredFeatureWorkspace(workspaceWave.featureDirs, pair.channel));
963
1013
  await requireActorFindingReplies(pair.channel, [...(revisionFindingIds.get(pair.channel.id) ?? [])]);
964
1014
  revisionCompleted += 1;
965
1015
  reportProgress("revision", revisionCompleted, revisionRuns.length, "revision", "done");
@@ -970,8 +1020,9 @@ export class Orchestrator {
970
1020
  let recheckCompleted = 0;
971
1021
  reportProgress("critic", recheckCompleted, revisedActors.length, "done", "rerunning");
972
1022
  const revisedPairs = await mapWithConcurrency(revisedActors, concurrency, async (pair) => {
1023
+ const assignment = requiredFeatureAssignment(assignments, pair.channel);
973
1024
  const reviewWorkspace = await workspaceManager.prepareFeatureReviewWorkspace(workspaceWave, pair.channel.id);
974
- const critic = await this.runCritic(input, task, requiredFeatureAssignment(assignments, pair.channel).critic_engine, judge.dir, pair.actor.dir, workers, turn, pair.channel, true, reviewWorkspace);
1025
+ const critic = await this.runCritic(input, task, assignment.critic_engine, judge.dir, pair.actor.dir, workers, turn, pair.channel, assignment.critic_model, true, reviewWorkspace);
975
1026
  recheckCompleted += 1;
976
1027
  reportProgress("critic", recheckCompleted, revisedActors.length, "done", "rerunning");
977
1028
  return {
@@ -1101,6 +1152,10 @@ export class Orchestrator {
1101
1152
  };
1102
1153
  const workspaceRootDir = join(task.dir, "workspaces", `turn-${turn.turnId}`, "wave-0001");
1103
1154
  const integratedCheckpoint = input.retry && await waveIntegrationCheckpointMatches(workspaceRootDir, turn.turnId, 1, [feature.id]);
1155
+ const [actorExecution, criticExecution] = await Promise.all([
1156
+ this.featureWorkerExecution(turn, "actor", assignment.actor_engine, assignment.actor_model),
1157
+ this.featureWorkerExecution(turn, "critic", assignment.critic_engine, assignment.critic_model)
1158
+ ]);
1104
1159
  if (integratedCheckpoint) {
1105
1160
  const recovered = !(await featureIsApproved(feature));
1106
1161
  await this.sessions.appendEvent(task, recovered ? "feature.wave_checkpoint_recovered" : "feature.wave_checkpoint_reused", `${recovered ? "Recovered" : "Reused"} integrated checkpoint for single feature: ${feature.id}`);
@@ -1111,7 +1166,7 @@ export class Orchestrator {
1111
1166
  critic: "done",
1112
1167
  featureProgress: { wave: 1, waves: 1, phase: "integration", completed: 1, total: 1 }
1113
1168
  });
1114
- return this.completeTask(task, turn, judge, this.workerFiles(task, taskWorkerId("actor", assignment.actor_engine, turn.turnId)), this.workerFiles(task, taskWorkerId("critic", assignment.critic_engine, turn.turnId)), feature, input, workers, await integratedWaveChangedPaths(workspaceRootDir), workspaceManager, route.judge_engine);
1169
+ return this.completeTask(task, turn, judge, this.workerFiles(task, taskWorkerId("actor", assignment.actor_engine, turn.turnId, undefined, actorExecution.modelKey)), this.workerFiles(task, taskWorkerId("critic", assignment.critic_engine, turn.turnId, undefined, criticExecution.modelKey)), feature, input, workers, await integratedWaveChangedPaths(workspaceRootDir), workspaceManager, route.judge_engine);
1115
1170
  }
1116
1171
  const restoredWave = input.retry ? await workspaceManager.restoreWave(workspaceInput) : null;
1117
1172
  const workspaceWave = restoredWave ?? await workspaceManager.prepareWave(workspaceInput);
@@ -1122,7 +1177,7 @@ export class Orchestrator {
1122
1177
  throwIfCancelled(input.signal);
1123
1178
  await this.sessions.updateTaskStatus(task, "actor_running");
1124
1179
  const restoredActor = restoredWave
1125
- ? await this.loadCompletedActor(task, assignment.actor_engine, feature, false)
1180
+ ? await this.loadCompletedActor(task, assignment.actor_engine, assignment.actor_model, feature, false)
1126
1181
  : null;
1127
1182
  if (restoredActor) {
1128
1183
  await this.sessions.appendEvent(task, "feature.wave_actor_checkpoints_reused", "Reused 1/1 completed Actor checkpoint in wave 1/1");
@@ -1135,13 +1190,13 @@ export class Orchestrator {
1135
1190
  });
1136
1191
  let actor = restoredActor;
1137
1192
  if (!actor) {
1138
- actor = await this.runActor(input, task, assignment.actor_engine, judge.dir, workers, turn, feature, undefined, false, workspaceDir, true);
1193
+ actor = await this.runActor(input, task, assignment.actor_engine, judge.dir, workers, turn, feature, assignment.actor_model, undefined, false, workspaceDir, true);
1139
1194
  }
1140
1195
  await updateFeatureStatus(feature, "actor_done");
1141
1196
  throwIfCancelled(input.signal);
1142
1197
  await this.sessions.updateTaskStatus(task, "critic_running");
1143
1198
  const restoredCritic = restoredActor
1144
- ? await this.loadCompletedCritic(task, assignment.critic_engine, feature, false)
1199
+ ? await this.loadCompletedCritic(task, assignment.critic_engine, assignment.critic_model, feature, false)
1145
1200
  : null;
1146
1201
  if (restoredCritic) {
1147
1202
  await this.sessions.appendEvent(task, "feature.wave_critic_checkpoints_reused", "Reused 1/1 completed Critic checkpoint in wave 1/1");
@@ -1156,7 +1211,7 @@ export class Orchestrator {
1156
1211
  let critic = restoredCritic;
1157
1212
  if (!critic) {
1158
1213
  reviewWorkspace = await workspaceManager.prepareFeatureReviewWorkspace(workspaceWave, feature.id);
1159
- critic = await this.runCritic(input, task, assignment.critic_engine, judge.dir, actor.dir, workers, turn, feature, false, reviewWorkspace, true);
1214
+ critic = await this.runCritic(input, task, assignment.critic_engine, judge.dir, actor.dir, workers, turn, feature, assignment.critic_model, false, reviewWorkspace, true);
1160
1215
  }
1161
1216
  await updateFeatureStatus(feature, "critic_done");
1162
1217
  throwIfCancelled(input.signal);
@@ -1187,14 +1242,14 @@ export class Orchestrator {
1187
1242
  critic: "done"
1188
1243
  });
1189
1244
  await this.sessions.updateTaskStatus(task, "actor_running");
1190
- actor = await this.runActor(input, task, assignment.actor_engine, judge.dir, workers, turn, feature, buildRevisionRequest(review, feature, revisionRound, this.config.orchestration.maxRevisionRounds), false, workspaceDir, true);
1245
+ actor = await this.runActor(input, task, assignment.actor_engine, judge.dir, workers, turn, feature, assignment.actor_model, buildRevisionRequest(review, feature, revisionRound, this.config.orchestration.maxRevisionRounds), false, workspaceDir, true);
1191
1246
  await requireActorFindingReplies(feature, [...revisionFindingIds]);
1192
1247
  await updateFeatureStatus(feature, "actor_done");
1193
1248
  throwIfCancelled(input.signal);
1194
1249
  reviewWorkspace = await workspaceManager.prepareFeatureReviewWorkspace(workspaceWave, feature.id);
1195
1250
  await this.sessions.updateTaskStatus(task, "critic_running");
1196
1251
  input.onStatus?.({ taskId: task.id, judge: "done", actor: "done", critic: "rerunning" });
1197
- critic = await this.runCritic(input, task, assignment.critic_engine, judge.dir, actor.dir, workers, turn, feature, false, reviewWorkspace, true);
1252
+ critic = await this.runCritic(input, task, assignment.critic_engine, judge.dir, actor.dir, workers, turn, feature, assignment.critic_model, false, reviewWorkspace, true);
1198
1253
  await updateFeatureStatus(feature, "critic_done");
1199
1254
  throwIfCancelled(input.signal);
1200
1255
  review = await readTextIfExists(`${critic.dir}/review.md`);
@@ -1679,8 +1734,9 @@ export class Orchestrator {
1679
1734
  await this.sessions.appendEvent(task, "judge.final_approved", `Final Judge approved ${criterionIds.length} acceptance criteria`);
1680
1735
  input.onStatus?.({ taskId: task.id, judge: "done", actor: "done", critic: "done" });
1681
1736
  }
1682
- async runActor(input, task, engine, judgeDir, workers, turn, feature, revision, featureScoped = false, workspaceDir = input.cwd, isolatedWorkspace = featureScoped) {
1683
- const workerId = taskWorkerId("actor", engine, turn.turnId, featureScoped ? feature.id : undefined);
1737
+ async runActor(input, task, engine, judgeDir, workers, turn, feature, modelName = "", revision, featureScoped = false, workspaceDir = input.cwd, isolatedWorkspace = featureScoped) {
1738
+ const execution = await this.featureWorkerExecution(turn, "actor", engine, modelName);
1739
+ const workerId = taskWorkerId("actor", engine, turn.turnId, featureScoped ? feature.id : undefined, execution.modelKey);
1684
1740
  const workerFiles = this.workerFiles(task, workerId);
1685
1741
  const actor = await this.sessions.initializeWorker(task, {
1686
1742
  workerId,
@@ -1726,7 +1782,7 @@ export class Orchestrator {
1726
1782
  outputLogPath: actor.outputLogPath,
1727
1783
  statusPath: actor.statusPath,
1728
1784
  prompt: await readTextIfExists(actor.promptPath),
1729
- modelConfig: await this.roleConfiguration.modelForTurn(turn.dir, "actor", engine),
1785
+ modelConfig: execution.modelConfig,
1730
1786
  signal: input.signal
1731
1787
  }, task, feature, featureScoped
1732
1788
  ? undefined
@@ -1740,8 +1796,9 @@ export class Orchestrator {
1740
1796
  });
1741
1797
  return actor;
1742
1798
  }
1743
- async runCritic(input, task, engine, judgeDir, actorDir, workers, turn, feature, featureScoped = false, workspaceDir = input.cwd, isolatedWorkspace = featureScoped) {
1744
- const workerId = taskWorkerId("critic", engine, turn.turnId, featureScoped ? feature.id : undefined);
1799
+ async runCritic(input, task, engine, judgeDir, actorDir, workers, turn, feature, modelName = "", featureScoped = false, workspaceDir = input.cwd, isolatedWorkspace = featureScoped) {
1800
+ const execution = await this.featureWorkerExecution(turn, "critic", engine, modelName);
1801
+ const workerId = taskWorkerId("critic", engine, turn.turnId, featureScoped ? feature.id : undefined, execution.modelKey);
1745
1802
  const workerFiles = this.workerFiles(task, workerId);
1746
1803
  const critic = await this.sessions.initializeWorker(task, {
1747
1804
  workerId,
@@ -1787,7 +1844,7 @@ export class Orchestrator {
1787
1844
  outputLogPath: critic.outputLogPath,
1788
1845
  statusPath: critic.statusPath,
1789
1846
  prompt: await readTextIfExists(critic.promptPath),
1790
- modelConfig: await this.roleConfiguration.modelForTurn(turn.dir, "critic", engine),
1847
+ modelConfig: execution.modelConfig,
1791
1848
  signal: input.signal
1792
1849
  }, task, feature, featureScoped
1793
1850
  ? undefined
@@ -2058,35 +2115,39 @@ export class Orchestrator {
2058
2115
  }
2059
2116
  return undefined;
2060
2117
  }
2061
- async loadCompletedFeatureActor(task, engine, definition, channel) {
2062
- const actor = await this.loadCompletedActor(task, engine, channel, true);
2118
+ async loadCompletedFeatureActor(task, engine, modelName, definition, channel) {
2119
+ const actor = await this.loadCompletedActor(task, engine, modelName, channel, true);
2063
2120
  if (!actor) {
2064
2121
  return null;
2065
2122
  }
2066
2123
  return { definition, channel, actor };
2067
2124
  }
2068
- async loadCompletedFeaturePair(task, engine, actorRun) {
2069
- const critic = await this.loadCompletedCritic(task, engine, actorRun.channel, true);
2125
+ async loadCompletedFeaturePair(task, engine, modelName, actorRun) {
2126
+ const critic = await this.loadCompletedCritic(task, engine, modelName, actorRun.channel, true);
2070
2127
  return critic ? { ...actorRun, critic } : null;
2071
2128
  }
2072
- async loadCompletedActor(task, engine, channel, featureScoped) {
2073
- const actor = this.workerFiles(task, taskWorkerId("actor", engine, channel.turnId, featureScoped ? channel.id : undefined));
2129
+ async loadCompletedActor(task, engine, modelName, channel, featureScoped) {
2130
+ const execution = await this.featureWorkerExecution({ dir: join(task.dir, "turns", channel.turnId) }, "actor", engine, modelName);
2131
+ const actor = this.workerFiles(task, taskWorkerId("actor", engine, channel.turnId, featureScoped ? channel.id : undefined, execution.modelKey));
2074
2132
  const status = await readWorkerStatusIfValid(actor.statusPath);
2075
2133
  if (status?.state !== "done"
2076
2134
  || status.role !== "actor"
2077
2135
  || status.engine !== engine
2136
+ || (execution.modelKey && status.model_name !== execution.modelConfig.name)
2078
2137
  || (featureScoped ? status.feature_id !== channel.id : Boolean(status.feature_id))) {
2079
2138
  return null;
2080
2139
  }
2081
2140
  await mirrorWorkerFileToFeature(join(actor.dir, "worklog.md"), channel.actorWorklogPath);
2082
2141
  return actor;
2083
2142
  }
2084
- async loadCompletedCritic(task, engine, channel, featureScoped) {
2085
- const critic = this.workerFiles(task, taskWorkerId("critic", engine, channel.turnId, featureScoped ? channel.id : undefined));
2143
+ async loadCompletedCritic(task, engine, modelName, channel, featureScoped) {
2144
+ const execution = await this.featureWorkerExecution({ dir: join(task.dir, "turns", channel.turnId) }, "critic", engine, modelName);
2145
+ const critic = this.workerFiles(task, taskWorkerId("critic", engine, channel.turnId, featureScoped ? channel.id : undefined, execution.modelKey));
2086
2146
  const status = await readWorkerStatusIfValid(critic.statusPath);
2087
2147
  if (status?.state !== "done"
2088
2148
  || status.role !== "critic"
2089
2149
  || status.engine !== engine
2150
+ || (execution.modelKey && status.model_name !== execution.modelConfig.name)
2090
2151
  || (featureScoped ? status.feature_id !== channel.id : Boolean(status.feature_id))) {
2091
2152
  return null;
2092
2153
  }
@@ -2096,6 +2157,17 @@ export class Orchestrator {
2096
2157
  }
2097
2158
  return await featureCriticCheckpointIsReusable(channel, decision) ? critic : null;
2098
2159
  }
2160
+ async featureWorkerExecution(turn, role, engine, assignedModel) {
2161
+ const inherited = await this.roleConfiguration.modelForTurn(turn.dir, role, engine);
2162
+ const requested = assignedModel.trim();
2163
+ const modelConfig = requested
2164
+ ? this.roleConfiguration.modelForTarget({ engine, model: requested })
2165
+ : inherited;
2166
+ return {
2167
+ modelConfig,
2168
+ modelKey: requested && requested !== inherited.name ? requested : ""
2169
+ };
2170
+ }
2099
2171
  async promptTurnContext(task, turn) {
2100
2172
  return {
2101
2173
  turnId: turn.turnId,
@@ -2479,15 +2551,22 @@ function workerLabelForStatus(status) {
2479
2551
  return workerLabel(status.role, status.engine);
2480
2552
  }
2481
2553
  const base = `${status.role}-${status.engine}`;
2482
- const turnId = status.worker_id.match(new RegExp(`^${base}-(\\d{4,})$`))?.[1];
2554
+ const turnId = status.worker_id.match(new RegExp(`^${base}-(\\d{4,})(?:-model-[a-f0-9]{8})?$`))?.[1];
2483
2555
  return taskWorkerLabel(status.role, status.engine, turnId ?? "0001");
2484
2556
  }
2485
- function taskWorkerId(role, engine, turnId, featureId) {
2557
+ function taskWorkerId(role, engine, turnId, featureId, modelKey = "") {
2486
2558
  const base = `${role}-${engine}`;
2487
- if (featureId) {
2488
- return `${base}-${featureId}`;
2489
- }
2490
- return turnId === "0001" ? base : `${base}-${turnId}`;
2559
+ const scoped = featureId
2560
+ ? `${base}-${featureId}`
2561
+ : turnId === "0001" ? base : `${base}-${turnId}`;
2562
+ return modelKey ? `${scoped}-model-${workerModelKey(modelKey)}` : scoped;
2563
+ }
2564
+ function workerModelKey(model) {
2565
+ return createHash("sha256").update(model).digest("hex").slice(0, 8);
2566
+ }
2567
+ function selectedRoleModel(selection, role, engine) {
2568
+ const target = selection?.[role];
2569
+ return target?.engine === engine ? target.model : "";
2491
2570
  }
2492
2571
  function capitalize(value) {
2493
2572
  return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
@@ -2503,7 +2582,7 @@ function workerStageOrder(worker) {
2503
2582
  function workerTurnOrder(worker) {
2504
2583
  const featureTurn = worker.featureId?.match(/^(\d{4,})(?:-|$)/)?.[1];
2505
2584
  const waveTurn = worker.id.match(/-wave-(\d{4,})-/)?.[1];
2506
- const taskTurn = worker.id.match(/-(\d{4,})$/)?.[1];
2585
+ const taskTurn = worker.id.match(/-(\d{4,})(?:-model-[a-f0-9]{8})?$/)?.[1];
2507
2586
  const parsed = Number(featureTurn ?? waveTurn ?? taskTurn ?? "1");
2508
2587
  return Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
2509
2588
  }
package/dist/tui/App.js CHANGED
@@ -264,7 +264,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
264
264
  && (config.workers[selectedTaskSessionDetailWorker.engine]?.interactive.forkArgs.length ?? 0) > 0);
265
265
  const featureCanCancel = busy && (selectedBoardFeature?.state === "actor_running"
266
266
  || selectedBoardFeature?.state === "critic_running");
267
- const featureCanReassign = assignableProviderIds.length > 1
267
+ const featureCanReassign = assignableProviderIds.length > 0
268
268
  && !busy
269
269
  && canRetryTask
270
270
  && selectedBoardFeature?.state !== "approved";
@@ -1396,6 +1396,24 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1396
1396
  const pendingAssignment = featureAssignmentPromptRef.current;
1397
1397
  if (pendingAssignment) {
1398
1398
  for (const featureChunk of featureChunks) {
1399
+ const modelEdit = featureAssignmentPromptRef.current?.modelEdit;
1400
+ if (modelEdit) {
1401
+ if (featureChunk === "\x1b") {
1402
+ updateFeatureAssignmentPrompt({ ...pendingAssignment, modelEdit: undefined });
1403
+ continue;
1404
+ }
1405
+ const update = applyChatInputChunk(modelEdit.value, featureChunk, modelEdit.cursor);
1406
+ if (update.submit !== null) {
1407
+ void reassignSelectedFeatureRef.current(pendingAssignment, modelEdit.role, update.submit);
1408
+ }
1409
+ else {
1410
+ updateFeatureAssignmentPrompt({
1411
+ ...pendingAssignment,
1412
+ modelEdit: { ...modelEdit, value: update.value, cursor: update.cursor }
1413
+ });
1414
+ }
1415
+ continue;
1416
+ }
1399
1417
  if (featureChunk === "\x1b" || featureChunk === "m" || featureChunk === "M") {
1400
1418
  updateFeatureAssignmentPrompt(null);
1401
1419
  setFeatureBoardNotice(null);
@@ -1409,6 +1427,16 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1409
1427
  void reassignSelectedFeatureRef.current(pendingAssignment, "critic");
1410
1428
  return;
1411
1429
  }
1430
+ if (featureChunk === "1" || featureChunk === "2") {
1431
+ const feature = collaborationTimelineRef.current?.features.find((item) => item.id === pendingAssignment.featureId);
1432
+ const role = featureChunk === "1" ? "actor" : "critic";
1433
+ const value = role === "actor" ? feature?.actorModel ?? "" : feature?.criticModel ?? "";
1434
+ updateFeatureAssignmentPrompt({
1435
+ ...pendingAssignment,
1436
+ modelEdit: { role, value, cursor: Array.from(value).length }
1437
+ });
1438
+ return;
1439
+ }
1412
1440
  }
1413
1441
  return;
1414
1442
  }
@@ -1468,7 +1496,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1468
1496
  featureId: feature.id,
1469
1497
  title: feature.title
1470
1498
  });
1471
- setFeatureBoardNotice(`Reassign ${feature.title} · A Actor (${feature.actorEngine ?? config.pairing.actor}) · C Critic (${feature.criticEngine ?? config.pairing.critic})`);
1499
+ setFeatureBoardNotice(`Reassign ${feature.title} · A Actor (${featureTargetDisplay(config, feature.actorEngine ?? config.pairing.actor, feature.actorModel)}) · C Critic (${featureTargetDisplay(config, feature.criticEngine ?? config.pairing.critic, feature.criticModel)})`);
1472
1500
  return;
1473
1501
  }
1474
1502
  if (featureChunk === "r" || featureChunk === "R") {
@@ -3087,7 +3115,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
3087
3115
  featureAssignmentPromptRef.current = next;
3088
3116
  setFeatureAssignmentPrompt(next);
3089
3117
  }
3090
- async function reassignSelectedFeature(prompt, role) {
3118
+ async function reassignSelectedFeature(prompt, role, model) {
3091
3119
  const feature = collaborationTimelineRef.current?.features.find((item) => item.id === prompt.featureId);
3092
3120
  if (!feature) {
3093
3121
  setFeatureBoardNotice(`Feature no longer exists: ${prompt.featureId}`);
@@ -3097,15 +3125,22 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
3097
3125
  const current = role === "actor"
3098
3126
  ? feature.actorEngine ?? config.pairing.actor
3099
3127
  : feature.criticEngine ?? config.pairing.critic;
3100
- const engine = nextAssignableFeatureEngine(current, assignableProviderIds);
3128
+ const engine = model === undefined
3129
+ ? nextAssignableFeatureEngine(current, assignableProviderIds)
3130
+ : current;
3101
3131
  try {
3102
- await orchestrator.reassignFeature({
3132
+ const result = await orchestrator.reassignFeature({
3103
3133
  taskId: prompt.taskId,
3104
3134
  featureId: prompt.featureId,
3105
3135
  role,
3106
- engine
3136
+ engine,
3137
+ ...(model === undefined ? {} : { model })
3107
3138
  });
3108
- setFeatureBoardNotice(`${role === "actor" ? "Actor" : "Critic"} reassigned to ${featureEngineDisplay(config, engine)} · Ctrl+R resumes the task.`);
3139
+ updateFeatureAssignmentPrompt({ ...prompt, modelEdit: undefined });
3140
+ const assignedModel = role === "actor"
3141
+ ? result.assignment.actor_model
3142
+ : result.assignment.critic_model;
3143
+ setFeatureBoardNotice(`${role === "actor" ? "Actor" : "Critic"} reassigned to ${featureTargetDisplay(config, engine, assignedModel)} · Ctrl+R resumes the task.`);
3109
3144
  await refreshCollaborationTimeline(false);
3110
3145
  }
3111
3146
  catch (error) {
@@ -3383,6 +3418,12 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
3383
3418
  if (typeof orchestrator.updateRoleConfiguration !== "function") {
3384
3419
  throw new Error("Role configuration is unavailable in this runtime");
3385
3420
  }
3421
+ if (typeof orchestrator.validateRoleConfiguration === "function") {
3422
+ const validation = await orchestrator.validateRoleConfiguration(draft);
3423
+ if (!validation.ok) {
3424
+ throw new Error(formatRoleConfigurationPreflightFailure(validation.lines));
3425
+ }
3426
+ }
3386
3427
  const snapshot = await orchestrator.updateRoleConfiguration({
3387
3428
  scope,
3388
3429
  roles: draft,
@@ -3478,7 +3519,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
3478
3519
  if (view === "workspace") {
3479
3520
  return (_jsx(Box, { flexDirection: "column", height: terminalSize.rows, children: _jsx(WorkspacePicker, { cwd: cwd, choices: workspaceChoices, terminalHeight: terminalSize.rows, terminalWidth: terminalWidth, onCancel: closeWorkspacePicker, onSelect: (workspace) => void selectWorkspace(workspace) }) }));
3480
3521
  }
3481
- return (_jsx(AppShell, { view: view, cwd: cwd, taskId: activeTaskId, statusText: [visibleTaskStatus, visibleRouteSummary, configChange?.compact].filter(Boolean).join(" | "), contentHeight: contentHeight, showStatusBar: config.ui.showStatusBar, input: _jsx(InputBar, { mode: view === "worker" && workerSearch.open ? "worker-search" : view, ready: inputReady, busy: busy, routeFallback: Boolean(routeFallbackPrompt), collaborationDetail: collaborationDetailOpen, collaborationUnresolved: collaborationUnresolvedOnly, collaborationBack: collaborationReturnViewRef.current, featureCanCancel: featureCanCancel, featureCanPause: featureCanCancel, featureCanReassign: featureCanReassign, featureCancelConfirm: featureCancelPrompt?.action === "cancel", featurePauseConfirm: featureCancelPrompt?.action === "pause", featureAssignment: Boolean(featureAssignmentPrompt), taskSessionAction: sessionCenterMode === "conversations"
3522
+ return (_jsx(AppShell, { view: view, cwd: cwd, taskId: activeTaskId, statusText: [visibleTaskStatus, visibleRouteSummary, configChange?.compact].filter(Boolean).join(" | "), contentHeight: contentHeight, showStatusBar: config.ui.showStatusBar, input: _jsx(InputBar, { mode: view === "worker" && workerSearch.open ? "worker-search" : view, ready: inputReady, busy: busy, routeFallback: Boolean(routeFallbackPrompt), collaborationDetail: collaborationDetailOpen, collaborationUnresolved: collaborationUnresolvedOnly, collaborationBack: collaborationReturnViewRef.current, featureCanCancel: featureCanCancel, featureCanPause: featureCanCancel, featureCanReassign: featureCanReassign, featureCancelConfirm: featureCancelPrompt?.action === "cancel", featurePauseConfirm: featureCancelPrompt?.action === "pause", featureAssignment: Boolean(featureAssignmentPrompt), featureEditingModel: featureAssignmentPrompt?.modelEdit ?? null, taskSessionAction: sessionCenterMode === "conversations"
3482
3523
  ? mainConversationAction?.type === "rename"
3483
3524
  ? { type: "rename", value: mainConversationAction.value, cursor: mainConversationAction.cursor }
3484
3525
  : mainConversationAction?.type === "delete"
@@ -3494,7 +3535,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
3494
3535
  ? workerSearch.query
3495
3536
  : view === "native" || view === "router" || view === "workers" || view === "features" || view === "sessions" || view === "collaboration" || view === "status" || view === "roles" ? "" : input, cursor: workerSearch.open && view === "worker"
3496
3537
  ? workerSearch.cursor
3497
- : view === "chat" ? inputCursor : undefined, terminalWidth: terminalWidth, onChange: view === "native" ? setNativeInput : setInput, onSubmit: view === "native" ? undefined : submit }), error: attachError, children: view === "roles" ? (_jsx(RoleConfigurationView, { snapshot: roleConfigurationSnapshot, draft: roleConfigurationDraft, scope: roleConfigurationScope, selectedRoleIndex: roleConfigurationSelectedIndex, loading: roleConfigurationLoading, saving: roleConfigurationSaving, notice: roleConfigurationNotice, error: roleConfigurationError, hasActiveTask: Boolean(activeTaskId), height: contentHeight, terminalWidth: terminalWidth })) : view === "status" ? (_jsx(StatusDetailView, { cwd: cwd, taskId: activeTaskId, mode: activeMode, busy: busy, canRetry: canRetryTask, taskStatus: visibleTaskStatus, routeStatus: visibleRouteStatus, routeReason: routePending ? undefined : lastRoute?.reason, pairing: config.pairing, roleSelection: activeRoleSelection, configStatus: configChange?.detail, configRestartRequired: configChange?.kind === "restart", workers: workers, selectedWorkerIndex: selectedWorkerIndex, height: contentHeight, terminalWidth: terminalWidth })) : view === "native" ? (_jsx(NativeAttachView, { attach: nativeAttach, viewportHeight: contentHeight })) : view === "sessions" ? (taskSessionDetails ? (_jsx(TaskSessionDetailView, { details: taskSessionDetails, selectedWorkerIndex: taskSessionDetailSelectedWorkerIndex, loading: taskSessionsLoading, error: taskSessionsError, notice: taskSessionDetailNotice, height: contentHeight, terminalWidth: terminalWidth })) : sessionCenterMode === "conversations" ? (_jsx(MainConversationsView, { conversations: mainConversations, selectedIndex: selectedMainConversationIndex, includeArchived: mainConversationsIncludeArchived, notice: taskSessionsNotice, action: mainConversationAction?.type === "rename"
3538
+ : view === "chat" ? inputCursor : undefined, terminalWidth: terminalWidth, onChange: view === "native" ? setNativeInput : setInput, onSubmit: view === "native" ? undefined : submit }), error: attachError, children: view === "roles" ? (_jsx(RoleConfigurationView, { snapshot: roleConfigurationSnapshot, draft: roleConfigurationDraft, scope: roleConfigurationScope, selectedRoleIndex: roleConfigurationSelectedIndex, loading: roleConfigurationLoading, saving: roleConfigurationSaving, notice: roleConfigurationNotice, error: roleConfigurationError, hasActiveTask: Boolean(activeTaskId), height: contentHeight, terminalWidth: terminalWidth })) : view === "status" ? (_jsx(StatusDetailView, { cwd: cwd, taskId: activeTaskId, mode: activeMode, busy: busy, canRetry: canRetryTask, taskStatus: visibleTaskStatus, routeStatus: visibleRouteStatus, routeReason: routePending ? undefined : lastRoute?.reason, pairing: config.pairing, roleSelection: activeRoleSelection, roleConfigurationSnapshot: roleConfigurationSnapshot, configStatus: configChange?.detail, configRestartRequired: configChange?.kind === "restart", workers: workers, selectedWorkerIndex: selectedWorkerIndex, height: contentHeight, terminalWidth: terminalWidth })) : view === "native" ? (_jsx(NativeAttachView, { attach: nativeAttach, viewportHeight: contentHeight })) : view === "sessions" ? (taskSessionDetails ? (_jsx(TaskSessionDetailView, { details: taskSessionDetails, selectedWorkerIndex: taskSessionDetailSelectedWorkerIndex, loading: taskSessionsLoading, error: taskSessionsError, notice: taskSessionDetailNotice, height: contentHeight, terminalWidth: terminalWidth })) : sessionCenterMode === "conversations" ? (_jsx(MainConversationsView, { conversations: mainConversations, selectedIndex: selectedMainConversationIndex, includeArchived: mainConversationsIncludeArchived, notice: taskSessionsNotice, action: mainConversationAction?.type === "rename"
3498
3539
  ? { type: "rename", title: mainConversationAction.title }
3499
3540
  : mainConversationAction?.type === "delete"
3500
3541
  ? { type: "delete", title: mainConversationAction.title }
@@ -4356,6 +4397,10 @@ function sessionDetailWorkerLabel(worker) {
4356
4397
  function featureEngineDisplay(config, engine) {
4357
4398
  return workerProviderLabel(config, engine);
4358
4399
  }
4400
+ function featureTargetDisplay(config, engine, model) {
4401
+ const effectiveModel = model?.trim() || config.workers[engine]?.model.name || "default";
4402
+ return `${featureEngineDisplay(config, engine)}/${effectiveModel}`;
4403
+ }
4359
4404
  function compactChatText(text, maxLength) {
4360
4405
  if (maxLength <= 5) {
4361
4406
  return takeChatTextStartByDisplayWidth(text, maxLength);
@@ -4745,6 +4790,15 @@ function compactNativeAttachRole(label) {
4745
4790
  function compactNativeSessionForTitle(sessionId, maxLength) {
4746
4791
  return compactEndByDisplayWidth(sessionId, Math.min(maxLength, 16));
4747
4792
  }
4793
+ export function formatRoleConfigurationPreflightFailure(lines) {
4794
+ const clean = lines
4795
+ .map((line) => line.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim())
4796
+ .filter(Boolean);
4797
+ const actionable = clean.filter((line) => (/\b(?:denied|error|failed|invalid|missing|unavailable|unsupported)\b/i.test(line)
4798
+ || /\bnot (?:found|supported|available)\b/i.test(line)));
4799
+ const detail = (actionable.length > 0 ? actionable : clean).slice(0, 4);
4800
+ return ["Provider preflight failed", ...detail].join(" · ");
4801
+ }
4748
4802
  function isAbortError(error) {
4749
4803
  return error instanceof Error && error.name === "AbortError";
4750
4804
  }
@@ -142,7 +142,10 @@ function featureBoardEvidenceText(timeline, feature, width) {
142
142
  ? `deps ${dependencies.map((item) => safeFeatureBoardText(item.title)).join(", ")}`
143
143
  : "independent";
144
144
  const assignmentText = feature.actorEngine && feature.criticEngine
145
- ? `actor ${feature.actorEngine} · critic ${feature.criticEngine}`
145
+ ? [
146
+ `actor ${feature.actorEngine}/${feature.actorModel?.trim() || "default"}`,
147
+ `critic ${feature.criticEngine}/${feature.criticModel?.trim() || "default"}`
148
+ ].join(" · ")
146
149
  : "";
147
150
  const evidence = feature.latestFinding
148
151
  ? `finding · ${safeFeatureBoardText(feature.latestFinding)}`
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { Box, Text } from "ink";
3
3
  import { compactEndByDisplayWidth, compactTailByDisplayWidth, displayWidth } from "./display-width.js";
4
4
  import { TUI_THEME } from "./theme.js";
5
- export function InputBar({ mode, ready = true, busy = false, routeFallback = false, collaborationDetail = false, collaborationUnresolved = false, collaborationBack = "workers", featureCanCancel = false, featureCanPause = false, featureCanReassign = false, featureCancelConfirm = false, featurePauseConfirm = false, featureAssignment = false, taskSessionAction = null, taskSessionsIncludeArchived = false, mainConversationSessions = false, taskSessionDetail = false, taskSessionDetailHasNative = false, taskSessionDetailCanFork = false, canRetry = false, hasWorkers = false, hasActiveTask = false, hasTaskResult = false, taskResultExpanded = false, chatScrollOffset = 0, chatMaxScrollOffset = 0, nativeClosed = false, searchMatchIndex = 0, searchMatchCount = 0, clipboardNotice = null, roleScope = "next", roleEditingModel = null, roleCanApply = true, roleSaving = false, roleHasOverride = false, value, cursor, terminalWidth: providedTerminalWidth, onChange, onSubmit }) {
5
+ export function InputBar({ mode, ready = true, busy = false, routeFallback = false, collaborationDetail = false, collaborationUnresolved = false, collaborationBack = "workers", featureCanCancel = false, featureCanPause = false, featureCanReassign = false, featureCancelConfirm = false, featurePauseConfirm = false, featureAssignment = false, featureEditingModel = null, taskSessionAction = null, taskSessionsIncludeArchived = false, mainConversationSessions = false, taskSessionDetail = false, taskSessionDetailHasNative = false, taskSessionDetailCanFork = false, canRetry = false, hasWorkers = false, hasActiveTask = false, hasTaskResult = false, taskResultExpanded = false, chatScrollOffset = 0, chatMaxScrollOffset = 0, nativeClosed = false, searchMatchIndex = 0, searchMatchCount = 0, clipboardNotice = null, roleScope = "next", roleEditingModel = null, roleCanApply = true, roleSaving = false, roleHasOverride = false, value, cursor, terminalWidth: providedTerminalWidth, onChange, onSubmit }) {
6
6
  const terminalWidth = providedTerminalWidth ?? process.stdout.columns ?? 120;
7
7
  const fillRail = providedTerminalWidth !== undefined || typeof process.stdout.columns === "number";
8
8
  if (clipboardNotice) {
@@ -30,6 +30,13 @@ export function InputBar({ mode, ready = true, busy = false, routeFallback = fal
30
30
  const hints = roleConfigurationInputHints(terminalWidth, roleScope, roleCanApply, roleSaving, roleHasOverride);
31
31
  return (_jsxs(InputRail, { terminalWidth: terminalWidth, textWidth: displayWidth(`${hints.label}${hints.detail}`), fill: fillRail, children: [_jsx(Text, { backgroundColor: TUI_THEME.rail, color: roleSaving ? TUI_THEME.warning : TUI_THEME.accent, bold: true, children: hints.label }), hints.detail ? _jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.muted, children: hints.detail }) : null] }));
32
32
  }
33
+ if (mode === "features" && featureEditingModel) {
34
+ const prefix = `${featureEditingModel.role} model > `;
35
+ const valueWidth = Math.max(1, terminalWidth - displayWidth(prefix) - 3);
36
+ const display = chatInputDisplayParts(featureEditingModel.value, featureEditingModel.cursor, valueWidth);
37
+ const textWidth = displayWidth(`${prefix}${display.before}|${display.after}`);
38
+ return (_jsxs(InputRail, { terminalWidth: terminalWidth, textWidth: textWidth, fill: fillRail, children: [_jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.accent, bold: true, children: prefix }), _jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.text, children: display.before }), _jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.accent, bold: true, children: "|" }), _jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.text, children: display.after })] }));
39
+ }
33
40
  if (mode === "status") {
34
41
  const hints = statusDetailInputHints(terminalWidth);
35
42
  return (_jsxs(InputRail, { terminalWidth: terminalWidth, textWidth: displayWidth(`${hints.label}${hints.detail}`), fill: fillRail, children: [_jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.accent, bold: true, children: hints.label }), hints.detail ? _jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.muted, children: hints.detail }) : null] }));
@@ -481,9 +488,9 @@ function workerOverviewInputHints(width) {
481
488
  function featureBoardInputHints(width, options) {
482
489
  if (options.assignment) {
483
490
  return selectInputHints(width, [
484
- { label: "assign provider", detail: " · A Actor · C Critic · M/Esc done" },
485
- { label: "assign", detail: " · A Actor · C Critic · Esc done" },
486
- { label: "assign", detail: " · A actor · C critic" },
491
+ { label: "assign", detail: " · A/C provider · 1/2 model · M/Esc done" },
492
+ { label: "assign", detail: " · A/C provider · 1/2 model · Esc done" },
493
+ { label: "assign", detail: " · A/C provider · 1/2 model" },
487
494
  { label: "provider", detail: " · A · C · Esc" },
488
495
  { label: "provider", detail: "" },
489
496
  { label: "M", detail: "" }
@@ -24,10 +24,10 @@ export function statusDetailDisplayLines(input, width, height) {
24
24
  text: fitStatusDetailText(`workspace · ${basename(input.cwd) || input.cwd} · ${input.cwd}`, safeWidth),
25
25
  tone: "muted"
26
26
  },
27
- {
28
- text: fitStatusDetailText(rolePairingDetail(input.pairing, input.roleSelection), safeWidth),
29
- tone: "text"
30
- }
27
+ ...roleMatrixDetailLines(input).map((line) => ({
28
+ text: fitStatusDetailText(line.text, safeWidth),
29
+ tone: line.tone
30
+ }))
31
31
  ];
32
32
  if (input.configStatus?.trim()) {
33
33
  lines.push({
@@ -59,15 +59,36 @@ export function statusDetailDisplayLines(input, width, height) {
59
59
  }
60
60
  return lines.slice(0, maxLines);
61
61
  }
62
- function rolePairingDetail(pairing, selection) {
62
+ function roleMatrixDetail(label, pairing, selection) {
63
63
  return [
64
- "active roles",
64
+ label,
65
65
  rolePairingEntry("main", selection?.main.engine ?? pairing.main, selection?.main.model),
66
66
  rolePairingEntry("judge", selection?.judge.engine ?? pairing.judge, selection?.judge.model),
67
67
  rolePairingEntry("actor", selection?.actor.engine ?? pairing.actor, selection?.actor.model),
68
68
  rolePairingEntry("critic", selection?.critic.engine ?? pairing.critic, selection?.critic.model)
69
69
  ].join(" · ");
70
70
  }
71
+ function roleMatrixDetailLines(input) {
72
+ const snapshot = input.roleConfigurationSnapshot;
73
+ const lines = [{
74
+ text: roleMatrixDetail("actual roles", input.pairing, input.roleSelection),
75
+ tone: "text"
76
+ }];
77
+ if (!snapshot) {
78
+ return lines;
79
+ }
80
+ const next = snapshot.next ?? snapshot.task ?? snapshot.future;
81
+ const source = snapshot.next ? "one shot" : snapshot.task ? "task default" : "future default";
82
+ lines.push({
83
+ text: roleMatrixDetail(`next roles (${source})`, input.pairing, next),
84
+ tone: snapshot.next ? "active" : "muted"
85
+ });
86
+ lines.push({
87
+ text: roleMatrixDetail("future roles", input.pairing, snapshot.future),
88
+ tone: "muted"
89
+ });
90
+ return lines;
91
+ }
71
92
  function rolePairingEntry(role, engine, model) {
72
93
  return [role, engine, model?.trim()].filter(Boolean).join("/");
73
94
  }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "0.3.0";
1
+ export const version = "0.3.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parallel-codex-tui",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "A TypeScript TUI wrapper for routed parallel coding with Codex and Claude workers.",
5
5
  "license": "MIT",
6
6
  "type": "module",