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 +25 -8
- package/dist/bootstrap.js +8 -1
- package/dist/cli.js +10 -1
- package/dist/core/collaboration-timeline.js +3 -1
- package/dist/core/role-configuration.js +277 -0
- package/dist/domain/schemas.js +9 -0
- package/dist/orchestrator/collaboration-channel.js +11 -3
- package/dist/orchestrator/orchestrator.js +249 -46
- package/dist/tui/App.js +459 -12
- package/dist/tui/AppShell.js +7 -1
- package/dist/tui/FeatureBoardView.js +4 -1
- package/dist/tui/InputBar.js +37 -6
- package/dist/tui/RoleConfigurationView.js +96 -0
- package/dist/tui/StatusDetailView.js +34 -10
- package/dist/tui/keyboard.js +11 -0
- package/dist/tui/role-configuration-state.js +74 -0
- package/dist/version.js +1 -1
- package/dist/workers/native-attach.js +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,13 +6,17 @@ Built with Codex-assisted development.
|
|
|
6
6
|
|
|
7
7
|
## Current Release
|
|
8
8
|
|
|
9
|
-
`v0.
|
|
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
|
|
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,
|
|
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
|
|
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.
|
|
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.
|
|
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 () =>
|
|
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
|
+
}
|
package/dist/domain/schemas.js
CHANGED
|
@@ -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);
|