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