parallel-codex-tui 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,15 @@ Built with Codex-assisted development.
6
6
 
7
7
  ## Current Release
8
8
 
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.
9
+ `v0.4.0` is available from [npm](https://www.npmjs.com/package/parallel-codex-tui/v/0.4.0) and as a [GitHub Release](https://github.com/allendred/parallel-codex-tui/releases/tag/v0.4.0). Main and parallel executions now run in a detached, per-request Supervisor after the Router resolves in the foreground. `Ctrl+C` closes the outer TUI without stopping an attached run, reopening the same workspace restores its live events or terminal result, and `Esc` remains the explicit cancellation command. One TUI owns the control lease while additional TUIs observe the same run; an observer takes control automatically after the prior controller detaches or exits.
10
+
11
+ Supervisor requests, state, events, commands, controller ownership, acknowledgements, and diagnostic output are retained under `.parallel-codex/supervisor/runs/<run-id>/`. The Supervisor writes the final chat result itself, so completion survives a closed terminal without duplicate history when the TUI is reopened. Unexpected Supervisor exit is converted into a durable failed state instead of leaving an indefinitely running UI.
12
+
13
+ In the Task session center, `Ctrl+F` searches durable evidence across Tasks and Turns, Feature names, roles, Providers, models, Worker states, summaries, and native session ids. Filters such as `turn:"中文输入" role:actor provider:codex state:done` can be combined with ordinary Unicode text; live async results reject stale responses, Enter keeps the filter, Esc restores the previously applied query, and `X` clears it.
14
+
15
+ Every Task export now includes human-readable `report.md` and structured `report.json` beside the complete raw `session/` snapshot. Reports preserve each Turn request and route, Judge requirements/plans/acceptance criteria, supervisor summaries, Feature assignments and latest review evidence, Wave verification results, Worker Provider/model/state, active native session metadata, completion contracts, and final acceptance evidence. Integrated changed paths are compared with the current workspace as `match`, `drift`, `missing`, `unexpected`, or `unavailable`; the newest integrated wave for each path is authoritative, deleted paths are represented correctly, and unsafe or symlink-traversing paths are never read.
16
+
17
+ `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 validates every selected Provider before it writes configuration, and concurrent TUI instances atomically consume a one-shot matrix exactly once.
10
18
 
11
19
  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
20
 
@@ -29,7 +37,7 @@ Highlights:
29
37
  - 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.
30
38
  - 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.
31
39
 
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`.
40
+ 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. Supervisor PTY coverage proves detached Main and complex execution, restart recovery, controller/observer takeover, explicit cancellation, and process-owner crash recovery. The deterministic repository suite contains 1,357 tests across 141 files: 1,356 pass by default, while one quota-consuming real-Agent test is skipped and passes through `npm run test:real-agents`.
33
41
 
34
42
  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.
35
43
 
@@ -246,7 +254,7 @@ The doctor output includes `preview:` and `semantic:` ANSI swatch rows so you ca
246
254
  - `Ctrl+N` leaves the active complex Task and all Worker logs intact on disk, clears only the live selection and retry state, starts a new Main `conversation_id`, and retires the old Main native session. It works from ordinary chat too, so the next request cannot inherit an unrelated native or file-backed conversation; the next complex request creates an independent Task from turn `0001`.
247
255
  - Single-feature follow-ups reuse the same Actor/Critic native sessions when available while moving them to the new turn's isolated workspace. File-backed prompt memory keeps the root Turn and the latest 19 previous Turn summaries within a 12,000-character ceiling; longer histories retain an inline count and path to every immutable Turn summary on disk. A failed follow-up retry reuses a complete saved Judge plan, or restores Judge first when the earlier Judge run never produced one.
248
256
  - Automated Judge, Actor, Critic, Wave Actor, and Wave Critic runs enforce process-level isolation: Codex is clamped to root-level `-a never` plus `workspace-write`, and Claude is clamped to non-interactive `auto`, even when private command arguments request manual approval or contain a broader bypass mode. Native attach remains an explicit interactive path.
249
- - Pressing `Esc` while a request is running stops the router or active worker and records an interrupted complex task as `cancelled`; exiting the outer TUI also terminates the active run.
257
+ - Routing remains in the foreground so fallback choices stay interactive. After a route resolves, Main or parallel execution moves into a detached per-run Supervisor. Pressing `Esc` as the controller cancels the active run and records an interrupted complex task as `cancelled`; pressing `Ctrl+C` detaches and exits the outer TUI while execution continues. Reopening the workspace follows persisted events and restores the final result. A second TUI observes the run and automatically acquires the control lease after the previous controller detaches or exits.
250
258
  - Failed and cancelled tasks expose `Ctrl+R` retry. Retry keeps the same task and turn, reuses recorded native worker sessions, preserves prior output behind a retry separator, does not route the request again, and reuses the persisted feature dependency plan. A complete Judge snapshot and fully integrated waves are skipped; an unchanged in-progress wave reuses successful Actor and Critic checkpoints and runs only unfinished workers. If the live workspace no longer matches the saved baseline, the stale wave checkpoint is rejected and rebuilt from the current project before workers continue.
251
259
  - The shared Main chat holds its own `sessions/main/run-owner.json` lease across prompt initialization, native-session reuse, and Worker completion. A second TUI is rejected before it can clear or overwrite the active Main prompt, log, status, or native session, and startup native-session reconciliation skips a Main session owned by another live TUI. After a hard exit, startup atomically claims the stale Main lease, terminates every verifiable orphan process group, marks active Main Workers `cancelled`, preserves native session ids, and records recovery before exposing the runtime. An unverifiable or still-running Main process blocks startup without changing its status or checkpoints.
252
260
  - Task and Main runs use the same lease finalizer, so a successful run cannot report success until its lease is released. If both the run and lease release fail, the top-level error preserves both causes in an `AggregateError` instead of replacing the original Worker failure.
@@ -473,7 +481,7 @@ Keep `[router.codex]` on `read-only`; routing only classifies requests and does
473
481
 
474
482
  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.
475
483
 
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.
484
+ 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 cancel it or `Ctrl+C` to close the TUI and leave the Supervisor running. 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.
477
485
 
478
486
  `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.
479
487
 
@@ -483,7 +491,9 @@ For a failed, cancelled, or paused task, select an unfinished Feature and press
483
491
 
484
492
  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.
485
493
 
486
- `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.
494
+ `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, `Ctrl+F` to search, `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. Search accepts plain Unicode text plus `task:`, `turn:`, `feature:`, `role:`, `provider:`, `model:`, and `state:` filters; quote a value containing spaces. Search is an AND across terms and reads the rebuildable SQLite evidence index rather than scanning lifetime Worker logs. Archived sessions are hidden by default, cannot be restored until unarchived, and are excluded from automatic startup selection.
495
+
496
+ 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, `report.md`, `report.json`, the raw `session/`, and no transient lease files. The report compares every authoritative integrated path with the current workspace while blocking relative-path escape and parent-symlink traversal. 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. The current index stores bounded Turn request text and each Worker's Feature, role, Provider, model, state, and native session binding for structured search. 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`, Turn files, and Worker files remain the rebuild authority.
487
497
 
488
498
  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.
489
499
 
@@ -491,7 +501,7 @@ The Task detail view reads authoritative task files and presents `Project -> Tas
491
501
 
492
502
  In a Worker log, `Ctrl+F` searches the final rendered Worker log rather than raw file offsets. Type Unicode text normally; Enter moves to the next match and Up/Down moves backward or forward. `Esc` closes search. The current match is marked with `>` without shifting Diff or source line-number columns. With search closed, press `E` to cycle rendered error lines or `D` to cycle Diff files and hunks.
493
503
 
494
- In chat or worker-log views, press `Ctrl+O` to attach to the selected worker's native session inside `parallel-codex-tui`. Native attach runs through a PTY, so full-screen CLIs such as `codex resume {sessionId}` receive a real terminal instead of pipe stdin. Native attach follows outer terminal resize, updating both the rendered screen and child PTY so Codex or Claude reflows at the current dimensions. Input is forwarded to the configured interactive command and output is shown in the native attach panel. When a Codex feature session needs its recorded worker, turn, or feature directories and the interactive command does not already choose a sandbox, native attach adds `--sandbox workspace-write` before its `--add-dir` arguments; an explicitly configured native sandbox remains unchanged. A non-zero native exit recognizes read-only sandbox/add-dir conflicts, untrusted directories, misplaced `--skip-git-repo-check`, missing PTY input, and host permission failures, then appends the exact configuration or terminal action before the exit line scrolls away. Overlapping attach preparations keep only the latest request. Press `Ctrl+]` to return to worker logs; this and closing the outer App terminate the active PTY. An attach preparation that finishes after App shutdown is discarded instead of starting a detached agent. `Ctrl+C` is forwarded to the native agent while attached. In chat and worker-log views, press `Ctrl+C` to exit the outer TUI. An operating-system SIGINT first asks the App to abort its Router or Worker and clean up any PTY; a second SIGINT restores terminal modes and forces exit.
504
+ In chat or worker-log views, press `Ctrl+O` to attach to the selected worker's native session inside `parallel-codex-tui`. Native attach runs through a PTY, so full-screen CLIs such as `codex resume {sessionId}` receive a real terminal instead of pipe stdin. Native attach follows outer terminal resize, updating both the rendered screen and child PTY so Codex or Claude reflows at the current dimensions. Input is forwarded to the configured interactive command and output is shown in the native attach panel. When a Codex feature session needs its recorded worker, turn, or feature directories and the interactive command does not already choose a sandbox, native attach adds `--sandbox workspace-write` before its `--add-dir` arguments; an explicitly configured native sandbox remains unchanged. A non-zero native exit recognizes read-only sandbox/add-dir conflicts, untrusted directories, misplaced `--skip-git-repo-check`, missing PTY input, and host permission failures, then appends the exact configuration or terminal action before the exit line scrolls away. Overlapping attach preparations keep only the latest request. Press `Ctrl+]` to return to worker logs; this and closing the outer App terminate the active PTY. An attach preparation that finishes after App shutdown is discarded instead of starting a detached agent. `Ctrl+C` is forwarded to the native agent while attached. In chat and worker-log views, `Ctrl+C` detaches an active Supervisor run and exits the outer TUI; during foreground routing it cancels classification before exiting. A second operating-system SIGINT restores terminal modes and forces exit.
495
505
 
496
506
  If a native resume fails because the underlying CLI reports that its context window is full, configure `fallback = "new"` under `[workers.<engine>.nativeSession]`. The old native session is archived as `native-session.retired.json`, removed from active use, and the worker is retried once with the normal fresh-session command. A valid retirement tombstone is also a cross-turn inheritance barrier: a retry or later turn cannot resurrect the same session from an older worker copy, while a newer replacement session remains reusable. A `process-cleanup-error` or `process-ownership-error` always blocks native-session fallback, even when the CLI output also mentions a full context window, so a fresh Worker cannot overlap an untracked or still-running resume process.
497
507
 
@@ -539,12 +549,12 @@ The release job installs npm `^11.5.1`, runs on Node `24.15.x`, publishes the pr
539
549
  To publish a release, update `package.json` and `src/version.ts` to the same version, then push a matching tag:
540
550
 
541
551
  ```bash
542
- VERSION=0.3.1
552
+ VERSION=0.4.0
543
553
  git tag "v$VERSION"
544
554
  git push origin "v$VERSION"
545
555
  ```
546
556
 
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.
557
+ 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.4.0` requires tag `v0.4.0`. Published tags such as `v0.2.10` are immutable and must not be moved or reused.
548
558
 
549
559
  ## Publishing Hygiene
550
560
 
@@ -552,6 +562,7 @@ You can also run the Release workflow manually and enter the same tag value. The
552
562
  - `.parallel-codex/role-configuration.json` and `.parallel-codex/role-configuration.next.json` are local runtime role/model selections and are ignored.
553
563
  - `.parallel-codex/last-workspace` and `.parallel-codex/workspaces.json` are local workspace-selection state and are ignored.
554
564
  - `.parallel-codex/router/` contains local request classification audit records and is ignored.
565
+ - `.parallel-codex/supervisor/` contains detached-run requests, lifecycle state, control commands, event streams, controller leases, acknowledgements, and Supervisor diagnostics; it is local evidence and is ignored.
555
566
  - `.parallel-codex/sessions/` contains the workspace chat transcript, task prompts, logs, native session ids, isolated feature workspaces, and conflict evidence; never commit it.
556
567
  - `.parallel-codex/diagnostics/` contains redacted support bundles and is ignored; review a bundle before sharing it.
557
568
  - `.parallel-codex/probes/` contains failed live Agent probe evidence and is ignored.
package/dist/cli.js CHANGED
@@ -27,6 +27,8 @@ import { formatTuiThemeCatalog } from "./tui/theme-preview.js";
27
27
  import { configureTuiTheme } from "./tui/theme.js";
28
28
  import { routerDiagnosticsPolicy } from "./tui/RouterDiagnosticsView.js";
29
29
  import { version } from "./version.js";
30
+ import { SupervisorOrchestrator } from "./supervisor/client.js";
31
+ import { runSupervisorJob } from "./supervisor/runner.js";
30
32
  main().catch((error) => {
31
33
  if (error instanceof WorkspaceSelectionCancelledError) {
32
34
  return;
@@ -35,6 +37,12 @@ main().catch((error) => {
35
37
  process.exit(1);
36
38
  });
37
39
  async function main() {
40
+ const supervisorRunDir = process.env.PCT_SUPERVISOR_RUN_DIR;
41
+ if (supervisorRunDir) {
42
+ delete process.env.PCT_SUPERVISOR_RUN_DIR;
43
+ await runSupervisorJob(supervisorRunDir);
44
+ return;
45
+ }
38
46
  const rawArgs = process.argv.slice(2);
39
47
  const cliArgErrors = validateCliArgs(rawArgs);
40
48
  if (cliArgErrors.length > 0) {
@@ -119,7 +127,7 @@ async function main() {
119
127
  let instance = null;
120
128
  const shutdownController = new AbortController();
121
129
  const deferredWorkspaceClosures = new Set();
122
- const appElement = (state) => (_jsx(App, { config: withUiThemeOverride(state.runtime.config, cliArgs.theme), orchestrator: state.runtime.orchestrator, cwd: state.runtime.workspaceRoot, initialTaskId: state.initialTaskId, initialRoute: state.initialRoute, initialWorkers: state.initialWorkers, initialCanRetryTask: state.initialCanRetryTask, initialMessages: state.initialMessages, workspaceChoices: state.workspaceChoices, shutdownSignal: shutdownController.signal, loadRouterDiagnostics: async () => {
130
+ const appElement = (state) => (_jsx(App, { config: withUiThemeOverride(state.runtime.config, cliArgs.theme), orchestrator: state.supervisor, cwd: state.runtime.workspaceRoot, initialTaskId: state.initialTaskId, initialRoute: state.initialRoute, initialWorkers: state.initialWorkers, initialCanRetryTask: state.initialCanRetryTask, initialMessages: state.initialMessages, workspaceChoices: state.workspaceChoices, shutdownSignal: shutdownController.signal, loadRouterDiagnostics: async () => {
123
131
  const [records, latestConfig] = await Promise.all([
124
132
  readRouterAudit(join(state.runtime.routerCwd, "routes.jsonl"), 100),
125
133
  loadConfig(cliArgs.appRoot)
@@ -128,7 +136,9 @@ async function main() {
128
136
  records,
129
137
  policy: routerDiagnosticsPolicy(latestConfig.router)
130
138
  };
131
- }, loadTaskSessions: (options) => state.runtime.index.listTasks(100, options), loadMainConversations: (options) => state.runtime.sessions.listMainConversations(100, options), activateMainConversation: async (conversationId) => {
139
+ }, loadTaskSessions: (options) => options?.query?.trim()
140
+ ? state.runtime.index.searchTasks(options.query, 100, options)
141
+ : state.runtime.index.listTasks(100, options), loadMainConversations: (options) => state.runtime.sessions.listMainConversations(100, options), activateMainConversation: async (conversationId) => {
132
142
  const restored = await state.runtime.sessions.activateMainConversation(conversationId);
133
143
  await state.runtime.index.setActiveTaskId(null);
134
144
  return restored;
@@ -210,6 +220,7 @@ async function main() {
210
220
  finally {
211
221
  removeSigintHandler();
212
222
  restoreInteractiveTerminal();
223
+ closeInteractiveWorkspace(current);
213
224
  retryDeferredWorkspaceClosures(deferredWorkspaceClosures);
214
225
  }
215
226
  }
@@ -217,6 +228,13 @@ async function main() {
217
228
  async function loadInteractiveWorkspace(appRoot, workspaceRoot, requestedTaskId) {
218
229
  const runtime = await createRuntime(appRoot, workspaceRoot);
219
230
  try {
231
+ const supervisor = await SupervisorOrchestrator.open({
232
+ delegate: runtime.orchestrator,
233
+ sessions: runtime.sessions,
234
+ appRoot,
235
+ workspaceRoot: runtime.workspaceRoot,
236
+ dataDir: runtime.config.dataDir
237
+ });
220
238
  const preflightPromise = runRuntimePreflight(runtime.config, runtime.workspaceRoot, process.env).catch((error) => ({
221
239
  ok: false,
222
240
  lines: [`preflight: failed (${error instanceof Error ? error.message : String(error)})`]
@@ -269,6 +287,7 @@ async function loadInteractiveWorkspace(appRoot, workspaceRoot, requestedTaskId)
269
287
  const recoveryMessages = startupRecoveryMessages(runtime.recoveredTasks, initialTaskId, runtime.pendingTaskCreations, runtime.index.recovery);
270
288
  return {
271
289
  runtime,
290
+ supervisor,
272
291
  initialTaskId,
273
292
  initialRoute,
274
293
  initialWorkers,
@@ -291,6 +310,7 @@ async function loadInteractiveWorkspace(appRoot, workspaceRoot, requestedTaskId)
291
310
  }
292
311
  }
293
312
  function closeInteractiveWorkspace(state) {
313
+ state.supervisor.detachBackgroundRuns();
294
314
  state.runtime.index.close();
295
315
  }
296
316
  function retryDeferredWorkspaceClosures(states) {
@@ -3,7 +3,8 @@ import { join } from "node:path";
3
3
  import { DatabaseSync } from "node:sqlite";
4
4
  import { NativeSessionSchema, RetiredNativeSessionSchema, RouteDecisionSchema, TaskIdSchema, TaskMetaSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
5
5
  import { ensureDir, pathExists, readJson, readTextIfExists } from "./file-store.js";
6
- const SESSION_INDEX_SCHEMA_VERSION = 2;
6
+ import { matchTaskSearchDocument, parseTaskSearchQuery } from "./task-search.js";
7
+ const SESSION_INDEX_SCHEMA_VERSION = 3;
7
8
  const SESSION_INDEX_FILENAME = "session-index.sqlite";
8
9
  export class SessionIndex {
9
10
  db;
@@ -86,24 +87,33 @@ export class SessionIndex {
86
87
  archived_at=excluded.archived_at`)
87
88
  .run(task.id, task.title, task.created_at, task.cwd, task.mode, task.status, task.archived_at ?? null);
88
89
  }
89
- async upsertTurn(taskId, turn) {
90
+ async upsertTurn(taskId, turn, request = "") {
90
91
  this.db
91
- .prepare(`INSERT INTO turns (task_id, turn_id, created_at, request_path)
92
- VALUES (?, ?, ?, ?)
92
+ .prepare(`INSERT INTO turns (task_id, turn_id, created_at, request_path, request_text)
93
+ VALUES (?, ?, ?, ?, ?)
93
94
  ON CONFLICT(task_id, turn_id) DO UPDATE SET
94
95
  created_at=excluded.created_at,
95
- request_path=excluded.request_path`)
96
- .run(taskId, turn.turn_id, turn.created_at, turn.request_path);
96
+ request_path=excluded.request_path,
97
+ request_text=CASE
98
+ WHEN excluded.request_text <> '' THEN excluded.request_text
99
+ ELSE turns.request_text
100
+ END`)
101
+ .run(taskId, turn.turn_id, turn.created_at, turn.request_path, indexedRequestText(request));
97
102
  }
98
103
  async upsertWorker(taskId, status, paths) {
99
104
  this.db
100
105
  .prepare(`INSERT INTO workers (
101
- task_id, worker_id, role, engine, state, phase, summary, status_path, output_log_path, dir, native_session_id
106
+ task_id, worker_id, feature_id, feature_title, role, engine, model_name, model_provider,
107
+ state, phase, summary, status_path, output_log_path, dir, native_session_id
102
108
  )
103
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
109
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
104
110
  ON CONFLICT(task_id, worker_id) DO UPDATE SET
111
+ feature_id=excluded.feature_id,
112
+ feature_title=excluded.feature_title,
105
113
  role=excluded.role,
106
114
  engine=excluded.engine,
115
+ model_name=excluded.model_name,
116
+ model_provider=excluded.model_provider,
107
117
  state=excluded.state,
108
118
  phase=excluded.phase,
109
119
  summary=excluded.summary,
@@ -111,7 +121,7 @@ export class SessionIndex {
111
121
  output_log_path=excluded.output_log_path,
112
122
  dir=excluded.dir,
113
123
  native_session_id=excluded.native_session_id`)
114
- .run(taskId, status.worker_id, status.role, status.engine, status.state, status.phase, status.summary, paths.statusPath, paths.outputLogPath, paths.dir, status.native_session_id ?? null);
124
+ .run(taskId, status.worker_id, status.feature_id ?? null, status.feature_title ?? null, status.role, status.engine, status.model_name ?? null, status.model_provider ?? null, status.state, status.phase, status.summary, paths.statusPath, paths.outputLogPath, paths.dir, status.native_session_id ?? null);
115
125
  }
116
126
  async upsertNativeSession(taskId, record) {
117
127
  this.db
@@ -198,6 +208,89 @@ export class SessionIndex {
198
208
  }];
199
209
  });
200
210
  }
211
+ async searchTasks(query, limit = 100, options = {}) {
212
+ const boundedLimit = Number.isFinite(limit)
213
+ ? Math.min(500, Math.max(0, Math.trunc(limit)))
214
+ : 100;
215
+ if (boundedLimit === 0) {
216
+ return [];
217
+ }
218
+ const terms = parseTaskSearchQuery(query);
219
+ const tasks = await this.listTasks(500, options);
220
+ if (terms.length === 0) {
221
+ return tasks.slice(0, boundedLimit).map((task) => ({
222
+ ...task,
223
+ searchMatch: { fields: [], summary: "" }
224
+ }));
225
+ }
226
+ const documents = new Map(tasks.map((task) => [task.id, {
227
+ task: {
228
+ id: task.id,
229
+ title: task.title,
230
+ cwd: task.cwd,
231
+ mode: task.mode,
232
+ state: task.status
233
+ },
234
+ turns: [],
235
+ workers: [],
236
+ nativeSessions: []
237
+ }]));
238
+ const archivedFilter = options.includeArchived ? "" : "WHERE tasks.archived_at IS NULL";
239
+ const turns = this.db.prepare(`SELECT turns.task_id, turns.turn_id, turns.request_text
240
+ FROM turns
241
+ INNER JOIN tasks ON tasks.id = turns.task_id
242
+ ${archivedFilter}`).all();
243
+ for (const turn of turns) {
244
+ documents.get(turn.task_id)?.turns.push({
245
+ turnId: turn.turn_id,
246
+ request: turn.request_text
247
+ });
248
+ }
249
+ const workers = this.db.prepare(`SELECT
250
+ workers.task_id,
251
+ workers.worker_id,
252
+ workers.feature_id,
253
+ workers.feature_title,
254
+ workers.role,
255
+ workers.engine,
256
+ workers.model_name,
257
+ workers.model_provider,
258
+ workers.state,
259
+ workers.phase,
260
+ workers.summary
261
+ FROM workers
262
+ INNER JOIN tasks ON tasks.id = workers.task_id
263
+ ${archivedFilter}`).all();
264
+ for (const worker of workers) {
265
+ documents.get(worker.task_id)?.workers.push({
266
+ id: worker.worker_id,
267
+ featureId: worker.feature_id ?? "",
268
+ featureTitle: worker.feature_title ?? "",
269
+ role: worker.role,
270
+ provider: worker.engine,
271
+ model: worker.model_name ?? "",
272
+ modelProvider: worker.model_provider ?? "",
273
+ state: worker.state,
274
+ phase: worker.phase,
275
+ summary: worker.summary
276
+ });
277
+ }
278
+ const nativeSessions = this.db.prepare(`SELECT native_sessions.task_id, native_sessions.session_id, native_sessions.engine
279
+ FROM native_sessions
280
+ INNER JOIN tasks ON tasks.id = native_sessions.task_id
281
+ ${archivedFilter}`).all();
282
+ for (const session of nativeSessions) {
283
+ documents.get(session.task_id)?.nativeSessions.push({
284
+ sessionId: session.session_id,
285
+ provider: session.engine
286
+ });
287
+ }
288
+ return tasks.flatMap((task) => {
289
+ const document = documents.get(task.id);
290
+ const searchMatch = document ? matchTaskSearchDocument(terms, document) : null;
291
+ return searchMatch ? [{ ...task, searchMatch }] : [];
292
+ }).slice(0, boundedLimit);
293
+ }
201
294
  async activeTaskId() {
202
295
  const row = this.db
203
296
  .prepare("SELECT value FROM workspace_state WHERE key = 'active_task_id'")
@@ -293,14 +386,19 @@ export class SessionIndex {
293
386
  turn_id TEXT NOT NULL,
294
387
  created_at TEXT NOT NULL,
295
388
  request_path TEXT NOT NULL,
389
+ request_text TEXT NOT NULL DEFAULT '',
296
390
  PRIMARY KEY (task_id, turn_id)
297
391
  );
298
392
 
299
393
  CREATE TABLE IF NOT EXISTS workers (
300
394
  task_id TEXT NOT NULL,
301
395
  worker_id TEXT NOT NULL,
396
+ feature_id TEXT,
397
+ feature_title TEXT,
302
398
  role TEXT NOT NULL,
303
399
  engine TEXT NOT NULL,
400
+ model_name TEXT,
401
+ model_provider TEXT,
304
402
  state TEXT NOT NULL,
305
403
  phase TEXT NOT NULL,
306
404
  summary TEXT NOT NULL,
@@ -335,6 +433,14 @@ export class SessionIndex {
335
433
  this.ensureColumn("tasks", "archived_at", "TEXT");
336
434
  return;
337
435
  }
436
+ if (targetVersion === 3) {
437
+ this.ensureColumn("turns", "request_text", "TEXT NOT NULL DEFAULT ''");
438
+ this.ensureColumn("workers", "feature_id", "TEXT");
439
+ this.ensureColumn("workers", "feature_title", "TEXT");
440
+ this.ensureColumn("workers", "model_name", "TEXT");
441
+ this.ensureColumn("workers", "model_provider", "TEXT");
442
+ return;
443
+ }
338
444
  throw new Error(`Missing session index migration for schema v${targetVersion}`);
339
445
  }
340
446
  async writeBackup(targetPath) {
@@ -380,7 +486,7 @@ export class SessionIndex {
380
486
  && turn.task_id === taskId
381
487
  && turn.turn_id === turnEntry.name
382
488
  && turn.request_path === `turns/${turnEntry.name}/user.md`) {
383
- await this.upsertTurn(taskId, turn);
489
+ await this.upsertTurn(taskId, turn, request);
384
490
  }
385
491
  }
386
492
  }
@@ -535,6 +641,13 @@ async function replaceFile(source, target) {
535
641
  await rename(source, target);
536
642
  }
537
643
  }
644
+ function indexedRequestText(request) {
645
+ const clean = request
646
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
647
+ .trim();
648
+ const points = Array.from(clean);
649
+ return points.length > 32768 ? points.slice(0, 32768).join("") : clean;
650
+ }
538
651
  async function readJsonIfValid(path, schema) {
539
652
  if (!(await pathExists(path))) {
540
653
  return null;
@@ -11,6 +11,7 @@ import { formatTaskTimestamp, taskDir, taskSessionIdIsValid } from "./paths.js";
11
11
  import { sessionsRoot } from "./paths.js";
12
12
  import { loadCollaborationTimeline } from "./collaboration-timeline.js";
13
13
  import { taskStateTransitionAllowed } from "./task-state-machine.js";
14
+ import { buildTaskReport } from "./task-report.js";
14
15
  import { processIsAlive, readProcessStartToken } from "./process-identity.js";
15
16
  import { claimTaskRunLease, inspectTaskRunLease, TaskRunLeaseConflictError, terminateOwnedWorkerProcess } from "./process-ownership.js";
16
17
  import { ChatRecordSchema, ConversationIdSchema, MainConversationArchiveSchema, MainConversationStateSchema, EventRecordSchema, FeatureStatusSchema, NativeSessionSchema, RouteDecisionSchema, RetiredNativeSessionSchema, TaskMetaSchema, TaskIdSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
@@ -113,7 +114,7 @@ export class SessionManager {
113
114
  await rename(stagingDir, finalDir);
114
115
  published = true;
115
116
  await this.updateTaskStatus(task, "routed");
116
- await this.index?.upsertTurn(task.id, await readJson(join(task.dir, "turns", turn.turnId, "turn.json"), TurnMetaSchema));
117
+ await this.index?.upsertTurn(task.id, await readJson(join(task.dir, "turns", turn.turnId, "turn.json"), TurnMetaSchema), input.request);
117
118
  await this.index?.setActiveTaskId(id);
118
119
  if (!options.retainCreationClaim) {
119
120
  await removeIfExists(creation.claimPath);
@@ -633,11 +634,21 @@ export class SessionManager {
633
634
  const destination = join(exportsRoot, `${taskId}-${stamp}-${suffix}`);
634
635
  try {
635
636
  await this.appendEvent(task, "task.exported", "Task session exported");
637
+ const taskReport = await buildTaskReport({
638
+ task: meta,
639
+ taskDir: task.dir,
640
+ workspaceRoot: this.projectRoot,
641
+ generatedAt: createdAt
642
+ });
643
+ await writeJson(join(staging, "report.json"), taskReport.report);
644
+ await writeText(join(staging, "report.md"), taskReport.markdown);
636
645
  await writeJson(join(staging, "manifest.json"), {
637
646
  format: "parallel-codex-task-export-v1",
638
647
  exported_at: createdAt,
639
648
  source_workspace: this.projectRoot,
640
649
  session_path: "session",
650
+ report_path: "report.md",
651
+ report_json_path: "report.json",
641
652
  task: await this.readMeta(task)
642
653
  });
643
654
  await cp(task.dir, join(staging, "session"), {
@@ -1583,7 +1594,7 @@ export class SessionManager {
1583
1594
  await this.index.upsertTask(meta);
1584
1595
  const snapshot = await this.readCompleteTaskCreation(this.taskFromId(taskId).dir, taskId);
1585
1596
  if (snapshot) {
1586
- await this.index.upsertTurn(taskId, snapshot.turn);
1597
+ await this.index.upsertTurn(taskId, snapshot.turn, await readTextIfExists(join(this.taskFromId(taskId).dir, snapshot.turn.request_path)));
1587
1598
  }
1588
1599
  }
1589
1600
  async quarantinePendingTaskCreation(pending) {
@@ -1637,7 +1648,7 @@ export class SessionManager {
1637
1648
  && pendingMeta.request_path === `turns/${pending.turnId}/user.md`);
1638
1649
  if (request && pendingRoute && pendingMeta && metaMatches) {
1639
1650
  await rename(pending.dir, files.dir);
1640
- await this.index?.upsertTurn(task.id, pendingMeta);
1651
+ await this.index?.upsertTurn(task.id, pendingMeta, request);
1641
1652
  await this.appendEvent(task, "turn.recovered_after_restart", `Published complete pending turn ${pending.turnId} after restart`);
1642
1653
  summary.published += 1;
1643
1654
  continue;
@@ -1716,7 +1727,7 @@ export class SessionManager {
1716
1727
  }
1717
1728
  }
1718
1729
  if (projectIndex) {
1719
- await this.index?.upsertTurn(task.id, turnMeta);
1730
+ await this.index?.upsertTurn(task.id, turnMeta, request);
1720
1731
  }
1721
1732
  return files;
1722
1733
  }