parallel-codex-tui 0.1.7 → 0.2.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.
@@ -32,6 +32,8 @@ fallback = "simple"
32
32
  maxParallelFeatures = 3
33
33
  # Maximum Actor/Critic revision rounds before the task is stopped for manual intervention (1..10).
34
34
  maxRevisionRounds = 3
35
+ # Maximum automatic Judge DAG replans after isolated Feature merge conflicts (0..8).
36
+ maxConflictReplans = 2
35
37
 
36
38
  [workers.codex]
37
39
  command = "codex"
package/README.md CHANGED
@@ -6,20 +6,23 @@ Built with Codex-assisted development.
6
6
 
7
7
  ## Current Release
8
8
 
9
- `v0.1.7` is available from [npm](https://www.npmjs.com/package/parallel-codex-tui/v/0.1.7) and as a [GitHub Release](https://github.com/allendred/parallel-codex-tui/releases/tag/v0.1.7). It keeps terminal scrolling and copying available at the same time without requiring Shift: the outer TUI explicitly disables application mouse tracking, enters the alternate screen, and enables alternate scroll. Kitty and Zellij therefore keep ordinary left-drag selection while translating the wheel into cursor-scroll input for the active TUI view. `Ctrl+Y` remains an additional fallback that copies the visible chat, rendered Worker log, native Agent screen, or structured overview without changing terminal modes. Unicode text and Diff indentation are preserved.
9
+ `v0.2.0` is available from [npm](https://www.npmjs.com/package/parallel-codex-tui/v/0.2.0) and as a [GitHub Release](https://github.com/allendred/parallel-codex-tui/releases/tag/v0.2.0). It adds automatic Judge-guided recovery for isolated Feature merge conflicts, a sanitized diagnostics bundle, longer restart soak coverage, and real multi-workspace Agent acceptance.
10
10
 
11
- The release also includes the stability, collaboration, session-management, Provider, TUI, and release work completed in `v0.1.5`:
11
+ The release keeps terminal scrolling and copying available at the same time without requiring Shift. Ordinary left-drag selection remains owned by the terminal while alternate-scroll delivers the wheel to chat, Worker logs, and structured views. `Ctrl+Y` copies the visible view as a fallback. It preserves the embedded native Agent scrollback across status-detail round trips and real PTY resizes.
12
12
 
13
+ Highlights:
14
+
15
+ - Judge produces validated requirements, acceptance criteria, and a dependency-aware Feature DAG. Actor/Critic pairs exchange checked JSONL findings and replies, followed by Wave Critic and final Judge acceptance.
16
+ - A conflicting parallel wave is archived with its merge evidence and returned to the same Judge native session for a bounded DAG replan. The replacement plan must retain the same Feature IDs and serialize every conflicting pair; a deterministic serialized fallback is used when the Judge response is invalid.
17
+ - Tasks retain every Turn, historical Worker log, native session binding, model snapshot, and collaboration artifact across follow-ups and restarts. A 20-turn soak verifies 80 ordered Workers before and after restart.
18
+ - `--diagnostics` and `Ctrl+X` export bounded, redacted support evidence without prompts, role instructions, command arguments, source files, or environment-variable values.
13
19
  - Runtime preflight checks Codex, Claude, named third-party Providers, proxy reachability, workspace access, CLI capabilities, and native attach policy before work starts.
14
- - Judge produces validated requirements, acceptance criteria, and a dependency-aware Feature plan. Actor/Critic pairs exchange checked JSONL findings and replies across revision rounds, followed by Wave Critic and final Judge acceptance.
15
- - Tasks retain every Turn, Worker log, native session binding, model snapshot, and collaboration artifact across follow-ups and restarts. Sessions can be inspected, continued, forked, renamed, archived, exported, restored, or deleted from the TUI.
16
20
  - 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.
17
- - Worker overview, Feature board, collaboration timeline, status details, rendered Markdown/Diff/error logs, Unicode search, keyboard navigation, mouse scrolling, and configurable themes share one terminal UI system.
18
- - SQLite migrations and recovery backups, owned-process cleanup, ten-turn Worker-history recovery, packaged CLI installation, and cross-platform CI are covered by automated tests.
21
+ - Worker overview, Feature board, collaboration timeline, Task center, status details, rendered Markdown/Diff/error logs, Unicode search, keyboard navigation, mouse scrolling, and configurable themes share one terminal UI system.
19
22
 
20
- Release acceptance included a real three-Feature Tetris task with parallel Actor/Critic waves, final integration review, 43 project tests, and a clean build. The repository suite passed 1,241 tests across 119 files, including PTY coverage for alternate-scroll delivery with every application mouse mode disabled. A local Zellij 0.44 probe confirmed that one wheel event becomes three cursor-up events while a left-button drag sends no mouse bytes to the app. The npm package was independently installed and returned `parallel-codex-tui 0.1.7`.
23
+ Release acceptance includes a real three-Feature Tetris task with parallel Actor/Critic waves and final integration review. Real Codex and Claude probes both proved fresh and same-session resume calls, 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 and preserving the native output tail across status-detail round trips. The deterministic repository suite passed 1,256 tests across 122 files; one quota-consuming real-Agent test is skipped by default and passes through `npm run test:real-agents`.
21
24
 
22
- Real Provider probes still 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.
25
+ 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.
23
26
 
24
27
  ## Requirements
25
28
 
@@ -43,6 +46,8 @@ parallel-codex-tui --init
43
46
  parallel-codex-tui --doctor
44
47
  parallel-codex-tui --doctor --probe-agents
45
48
  parallel-codex-tui --doctor --probe-router
49
+ parallel-codex-tui --diagnostics
50
+ parallel-codex-tui --diagnostics ./support-bundle
46
51
  parallel-codex-tui --workspace /path/to/project
47
52
  parallel-codex-tui --theme aurora --workspace /path/to/project
48
53
  parallel-codex-tui --theme studio --workspace /path/to/project
@@ -83,11 +88,26 @@ parallel-codex-tui --help
83
88
  parallel-codex-tui --doctor
84
89
  parallel-codex-tui --doctor --probe-agents
85
90
  parallel-codex-tui --doctor --probe-router
91
+ parallel-codex-tui --diagnostics
92
+ parallel-codex-tui --diagnostics ./support-bundle
86
93
  parallel-codex-tui --version
87
94
  ```
88
95
 
89
96
  `--doctor` checks configured automated and interactive commands, `{env:NAME}` references, and the CLI help surfaces required for Codex exec/resume sandboxing and Claude print/resume permissions before workers start. Recognized standard CLI help that lacks a required option fails the check; an opaque wrapper keeps the compatible built-in profile's unverified warning, while an explicit `generic` capability contract is validated without guessing vendor-specific help. Doctor performs these checks for every named Worker Provider used by the active route, rejects an explicitly read-only Codex-compatible interactive sandbox when feature attach needs writable roots, and reminds you that native workspace trust remains an interactive decision. It reports proxy host/port reachability as a local-endpoint check, not as proof that the proxy upstream or model API is healthy. Add `--probe-agents` to make one minimal fresh request and, when configured, one same-session resume request through every active process Worker Provider. This explicit probe uses model quota, removes successful probe artifacts, preserves failed artifacts under `.parallel-codex/probes/`, and exits non-zero when a fresh or resumed request cannot be proven. Fresh sessions use the configured `freshSessionArgs`; Claude-compatible profiles default to a generated native `--session-id`, persisted only after the process emits output, so a silent failed launch cannot create a false resumable session. Add `--probe-router` to run one real classification through the configured Codex Router; the command exits non-zero when that live request falls back or fails. Doctor also reports the loaded TUI theme, core palette values, ANSI swatch previews, and color override values, including any temporary `--theme` override.
90
97
 
98
+ ## Diagnostics
99
+
100
+ Export a support bundle without starting the TUI:
101
+
102
+ ```bash
103
+ parallel-codex-tui --workspace /path/to/project --diagnostics
104
+ parallel-codex-tui --workspace /path/to/project --diagnostics ./support-bundle
105
+ ```
106
+
107
+ Without an explicit destination, the bundle is created under `.parallel-codex/diagnostics/<timestamp>/`. An explicit destination must not already exist. While the TUI is open, `Ctrl+X` creates the same bundle without interrupting chat, logs, Task center, or an active Worker; native attach keeps `Ctrl+X` available to the attached Agent.
108
+
109
+ The bundle contains `manifest.json`, `report.md`, `report.json`, `doctor.txt`, `tasks.json`, `workers.json`, `router-audit.jsonl`, and bounded Worker log tails. It exports at most the latest 20 tasks, 200 Workers, 100 Router rows, and 200 lines or 64 KiB per Worker log. Workspace, app-root, and home paths are aliased; URL credentials and paths, authorization values, secret assignments, common token formats, and environment-variable values are redacted. Prompts, role instructions, command arguments, source files, and lifetime logs are excluded. Review the bundle before sharing it, because application output can still contain project-specific text that no automatic redactor can fully understand.
110
+
91
111
  ## Quick Start
92
112
 
93
113
  Create a local config in the app root:
@@ -126,12 +146,15 @@ Limit how many feature-level Actor or Critic agents may run at once:
126
146
  [orchestration]
127
147
  maxParallelFeatures = 3 # 1..8
128
148
  maxRevisionRounds = 3 # 1..10
149
+ maxConflictReplans = 2 # 0..8
129
150
  ```
130
151
 
131
152
  Judge may plan up to eight dependency-aware features. The orchestrator keeps dependency order while running at most this many agents concurrently. When one worker fails, already-running peers are allowed to finish cleanly and queued features are not started.
132
153
 
133
154
  Actor and Critic reuse their native sessions across revision rounds. Blocking findings and Actor replies remain in the Feature JSONL mailbox; the task stops with an explicit error when `maxRevisionRounds` is exhausted.
134
155
 
156
+ When isolated Features conflict during wave staging, `maxConflictReplans` controls how many times the same Judge native session may replace the remaining dependency DAG. Conflict evidence and every Judge response are archived under the turn workspace. Completed earlier waves remain integrated; only the conflicting and later waves are discarded and rerun. Old configs inherit the default value `2`, so no SQLite migration is required, but adding the field makes the policy explicit.
157
+
135
158
  Queued Feature workers remain `queued` until they acquire a concurrency slot. Their persisted state changes to `actor_running` or `critic_running` only after the Worker is registered as cancellable, then to `actor_done` or `critic_done` before that active registration is removed. As a result, only `actor_running` and `critic_running` expose Feature cancellation; queued and role-complete Features cannot present a cancel action that has no live process behind it.
136
159
 
137
160
  ## Theme
@@ -206,7 +229,7 @@ The doctor output includes `preview:` and `semantic:` ANSI swatch rows so you ca
206
229
  - Every staged wave gets a combined Wave Critic run in a disposable verification copy. It checks the full Judge acceptance criteria, cross-feature behavior, tests, and builds. A missing `APPROVED`/`REVISION_REQUIRED` decision fails safely instead of being treated as approval.
207
230
  - When combined verification requests changes, a session-backed Wave Actor fixes the integration workspace and the same Wave Critic native session reviews it again. Only an explicit final `APPROVED` allows the wave to reach the live workspace.
208
231
  - Wave Actor/Critic prompts, logs, immutable `verification-review-01.md`/`02.md` rounds, native session ids, minimized additional-directory permissions, and `verification.json` audit evidence are stored with the task and remain available through worker logs and native attach.
209
- - Overlapping edits fail the task without partially changing the live workspace. The chat error names the conflicting paths and points to marker files under the wave's `conflicts/` directory; `Ctrl+R` retries in the same task and native worker sessions.
232
+ - Overlapping edits never partially change the live workspace. The orchestrator archives the conflicting paths and merge evidence, returns to `judging`, and asks the same Judge native session to serialize the affected Feature DAG. The replanned run keeps integrated earlier waves and every historical Worker log. If the bounded replan budget is exhausted or the replacement plan is invalid, the task fails with the evidence path and remains retryable through `Ctrl+R`.
210
233
  - If the live workspace changes after a wave baseline is captured, staging or commit stops with the changed paths instead of silently absorbing an escaped worker edit or overwriting concurrent user work.
211
234
  - Feature workspaces persist with the task so native attach can reopen the exact worker cwd. Delete an old task session when its audit trail and attachable workspaces are no longer needed.
212
235
  - Complex follow-ups stay in the active task, append a numbered turn, restore the same Judge session to re-clarify the new direction, and save a turn-local Judge snapshot. A new multi-feature plan can start parallel workers immediately.
@@ -225,7 +248,7 @@ The doctor output includes `preview:` and `semantic:` ANSI swatch rows so you ca
225
248
  - Terminal completion is evidence-guarded. The latest `supervisor-summary.md`, feature `decisions.md`, and approved feature states are published before task status can become `done`; duplicate task and feature state writes are idempotent, every real task transition records its `from` and `to` state, and a complete `done` task cannot regress unless a new follow-up turn has first been created. Each committed task state change carries a unique transition marker in `meta.json`; same-process retries repair missing projections immediately, and startup repairs a missing event or SQLite projection from that marker before another transition can replace it. Startup also audits legacy `done` tasks that have an integrated latest-turn checkpoint: missing summaries or unfinished feature states are recovered to `cancelled`, then `Ctrl+R` rebuilds final evidence without rerunning completed workers. Legacy log-only `done` sessions without integration proof remain untouched.
226
249
  - Simple follow-up questions run through the persistent Main native session with the active task directory, original request, up to five recent turn summaries, valid worker statuses, and log tails as file-backed context. They hold the active task lease while committing the route, taking that context snapshot, and running Main, so the answer cannot race an in-progress complex turn. A failed or cancelled Main answer replaces the transient `running` state with its real terminal state and releases both task and Main leases, so the next question can proceed. They do not start another Judge, Actor, or Critic turn.
227
250
  - Worker prompts, logs, status, and outputs are written to disk.
228
- - The bottom status line shows the active task state and feature progress such as `wave 1/2 · actor 2/3`, `wave 1/2 · integration 0/1`, and `wave 1/2 · verification 0/1`. While classification is running, it follows the real subprocess through `starting`, `waiting output`, `diagnostics`, `receiving`, `parsing`, and `stopping`, keeps live elapsed/limit progress, identifies a non-default Router runner, and identifies the path as `direct` or `via <proxy-host:port>`. Before output arrives it shows the active first-output deadline and total ceiling; after activity begins it shows total elapsed progress and the idle watchdog. As soon as a fresh initial or follow-up route settles, chat temporarily inserts a themed route rail with `route · <mode> · <source>` followed by the indented Router reason before Main or parallel execution finishes, then replaces it with the final answer or error without adding noise to persisted chat history. Task retry restores its saved route without announcing it as a new Router decision. The status line then replaces the wait state with the final route source, duration, or fallback cause.
251
+ - The bottom status line shows the active task state and feature progress such as `wave 1/2 · actor 2/3`, `wave 1/2 · integration 0/1`, and `wave 1/2 · verification 0/1`. Chat, Worker logs, status details, and native attach derive this summary from the same persisted runtime snapshot, so changing views does not rename or reorder task state. While classification is running, it follows the real subprocess through `starting`, `waiting output`, `diagnostics`, `receiving`, `parsing`, and `stopping`, keeps live elapsed/limit progress, identifies a non-default Router runner, and identifies the path as `direct` or `via <proxy-host:port>`. Before output arrives it shows the active first-output deadline and total ceiling; after activity begins it shows total elapsed progress and the idle watchdog. As soon as a fresh initial or follow-up route settles, chat temporarily inserts a themed route rail with `route · <mode> · <source>` followed by the indented Router reason before Main or parallel execution finishes, then replaces it with the final answer or error without adding noise to persisted chat history. Task retry restores its saved route without announcing it as a new Router decision. The status line then replaces the wait state with the final route source, duration, or fallback cause.
229
252
  - Completed complex tasks open as a structured result at the top of the chat viewport. Requirements, the complete bounded Actor worklog, authoritative integrated changed paths, Critic review, verification evidence, and findings render as themed full-width sections instead of losing everything after each section's first line. Changed files come from the workspace integration result rather than an Actor claim; verification keeps the Critic decision and reported test/build evidence. Multi-feature delivery uses the same result protocol and includes combined Wave verification. Long results start at the title and scroll toward findings; `Ctrl+D` toggles the focused result between full detail and its five-line compact summary, and beginning the next message collapses it automatically. Result lookup follows the persisted task id, so restoring one task cannot display another task's result. Empty state and ordinary short chat remain adjacent to the input.
230
253
  - Restarting an existing task restores the latest persisted route evidence in the bottom status line, including fallback cause and duration. Follow-up Router classification has no task-side effects: it records the attempt in shared `routes.jsonl`, then a complex turn or simple question refreshes `sessions/<task>/latest-route.json` only after acquiring the task lease. A conflicting TUI leaves the previous committed route and turn files untouched. A corrupt latest-route record safely falls back to the latest worker turn and then the task's initial route record.
231
254
  - Task session ids are single `task-...` path segments. CLI `--task` input, persisted metadata, startup scanning, and SQLite rebuilding reject path separators and ignore a task whose `meta.json` id differs from its directory, so restoring a session cannot escape or alias the workspace session root.
@@ -449,6 +472,31 @@ In chat or worker-log views, press `Ctrl+O` to attach to the selected worker's n
449
472
 
450
473
  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.
451
474
 
475
+ ## Testing
476
+
477
+ Run the deterministic release checks from a source checkout:
478
+
479
+ ```bash
480
+ npm test
481
+ npm run test:stability
482
+ npm run test:terminal-hosts
483
+ npm run typecheck
484
+ npm run build
485
+ npm run verify:package
486
+ ```
487
+
488
+ `test:stability` covers 20 same-task turns, 80 historical Workers, restart recovery, workspace switching, orphan cleanup, and interrupted startup. `test:terminal-hosts` uses real PTYs and exercises Apple Terminal, tmux, and Zellij profiles when those hosts are installed; unsupported hosts are reported as skipped.
489
+
490
+ Real Agent acceptance is deliberately opt-in because it uses model quota and local credentials:
491
+
492
+ ```bash
493
+ HTTP_PROXY=http://127.0.0.1:7890 \
494
+ HTTPS_PROXY=http://127.0.0.1:7890 \
495
+ npm run test:real-agents
496
+ ```
497
+
498
+ The test performs real Main calls in two temporary workspaces through one running TUI, switches back, and verifies that each persisted `chat.jsonl` contains only its own response. Configure the corresponding Worker environment in local `config.toml` when the CLI does not inherit process proxy variables.
499
+
452
500
  ## Release
453
501
 
454
502
  GitHub Actions runs CI on Ubuntu with Node.js 24.15 and 26, plus macOS with Node.js 24.15, for pushes and pull requests to `main`. Every job also packs the release artifact, installs that tarball into a clean global prefix, and executes the installed `--version` and `--help` entrypoints.
@@ -468,12 +516,12 @@ The release job installs npm `^11.5.1`, runs on Node `24.15.x`, publishes the pr
468
516
  To publish a release, update `package.json` and `src/version.ts` to the same version, then push a matching tag:
469
517
 
470
518
  ```bash
471
- VERSION=0.1.7
519
+ VERSION=0.2.0
472
520
  git tag "v$VERSION"
473
521
  git push origin "v$VERSION"
474
522
  ```
475
523
 
476
- 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.1.7` requires tag `v0.1.7`. Published tags such as `v0.1.6` are immutable and must not be moved or reused.
524
+ You can also run the Release workflow manually and enter the same tag value. The release tag must match `package.json`; for example, package version `0.2.0` requires tag `v0.2.0`. Published tags such as `v0.1.8` are immutable and must not be moved or reused.
477
525
 
478
526
  ## Publishing Hygiene
479
527
 
@@ -481,4 +529,6 @@ You can also run the Release workflow manually and enter the same tag value. The
481
529
  - `.parallel-codex/last-workspace` and `.parallel-codex/workspaces.json` are local workspace-selection state and are ignored.
482
530
  - `.parallel-codex/router/` contains local request classification audit records and is ignored.
483
531
  - `.parallel-codex/sessions/` contains the workspace chat transcript, task prompts, logs, native session ids, isolated feature workspaces, and conflict evidence; never commit it.
532
+ - `.parallel-codex/diagnostics/` contains redacted support bundles and is ignored; review a bundle before sharing it.
533
+ - `.parallel-codex/probes/` contains failed live Agent probe evidence and is ignored.
484
534
  - `docs/superpowers/` contains internal planning notes and is ignored for public releases.
package/dist/cli-args.js CHANGED
@@ -2,7 +2,7 @@ import { resolve } from "node:path";
2
2
  import { homedir } from "node:os";
3
3
  import { normalizeTuiThemeName, TUI_THEME_NAMES } from "./tui/theme.js";
4
4
  import { TaskIdSchema } from "./domain/schemas.js";
5
- const allowedValueOptions = new Set(["--app-root", "--workspace", "-w", "--task", "-t", "--theme"]);
5
+ const allowedValueOptions = new Set(["--app-root", "--workspace", "-w", "--task", "-t", "--theme", "--diagnostics"]);
6
6
  const allowedBooleanOptions = new Set(["--doctor", "--help", "-h", "--init", "--probe-agents", "--probe-router", "--themes", "--version", "-v"]);
7
7
  export function parseCliArgs(args, cwd) {
8
8
  const optionArgs = argsBeforeTerminator(args);
@@ -10,6 +10,8 @@ export function parseCliArgs(args, cwd) {
10
10
  const workspaceFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--workspace" || arg.startsWith("--workspace=") || arg === "-w" || arg.startsWith("-w="));
11
11
  const taskFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--task" || arg.startsWith("--task=") || arg === "-t" || arg.startsWith("-t="));
12
12
  const themeFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--theme" || arg.startsWith("--theme="));
13
+ const diagnosticsFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--diagnostics" || arg.startsWith("--diagnostics="));
14
+ const diagnostics = diagnosticsFlagIndex >= 0;
13
15
  const doctor = optionArgs.includes("--doctor");
14
16
  const help = optionArgs.includes("--help") || optionArgs.includes("-h");
15
17
  const init = optionArgs.includes("--init");
@@ -23,8 +25,12 @@ export function parseCliArgs(args, cwd) {
23
25
  const workspaceRoot = explicitWorkspace ? resolvePathArg(cwd, explicitWorkspace) : cwd;
24
26
  const taskId = flagValue(optionArgs, taskFlagIndex);
25
27
  const theme = cliThemeValue(flagValue(optionArgs, themeFlagIndex));
28
+ const diagnosticsValue = flagValue(optionArgs, diagnosticsFlagIndex);
29
+ const diagnosticsPath = diagnosticsValue ? resolvePathArg(cwd, diagnosticsValue) : null;
26
30
  return {
27
31
  appRoot,
32
+ diagnostics,
33
+ diagnosticsPath,
28
34
  doctor,
29
35
  explicitWorkspace,
30
36
  help,
package/dist/cli-help.js CHANGED
@@ -10,6 +10,7 @@ Options:
10
10
  --themes List built-in TUI theme palettes; combine with --theme to filter
11
11
  --init Write .parallel-codex/config.toml if missing
12
12
  --doctor Check config, agent commands, and theme palette preview
13
+ --diagnostics [dir] Export a sanitized support bundle; defaults inside the workspace
13
14
  --probe-agents With --doctor, run fresh + resume probes (uses model quota)
14
15
  --probe-router With --doctor, run one live Codex Router request
15
16
  -v, --version Print the current version
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { prepareAppRoot } from "./core/app-root.js";
14
14
  import { formatConfigErrorMessage } from "./core/config-errors.js";
15
15
  import { configPath, loadConfig, withUiThemeOverride, writeDefaultConfig } from "./core/config.js";
16
16
  import { pathExists } from "./core/file-store.js";
17
+ import { exportDiagnostics } from "./core/diagnostics.js";
17
18
  import { readRouterAudit } from "./core/router-audit.js";
18
19
  import { loadTaskSessionDetails as loadPersistedTaskSessionDetails } from "./core/task-session-details.js";
19
20
  import { listWorkspaceChoices } from "./core/workspace.js";
@@ -67,6 +68,24 @@ async function main() {
67
68
  process.stdout.write(result.text);
68
69
  process.exitCode = result.ok ? 0 : 1;
69
70
  }
71
+ else if (cliArgs.diagnostics) {
72
+ const workspaceRoot = await selectWorkspaceForCli({
73
+ appRoot: cliArgs.appRoot,
74
+ cwd: process.cwd(),
75
+ explicitWorkspace: cliArgs.explicitWorkspace,
76
+ interactive: false
77
+ });
78
+ const runtime = await createRuntime(cliArgs.appRoot, workspaceRoot);
79
+ try {
80
+ const result = await exportDiagnostics(cliArgs.appRoot, runtime, {
81
+ destinationPath: cliArgs.diagnosticsPath
82
+ });
83
+ console.log(`Diagnostics exported: ${result.path}`);
84
+ }
85
+ finally {
86
+ runtime.index.close();
87
+ }
88
+ }
70
89
  else if (cliArgs.init) {
71
90
  if (await pathExists(localConfigPath)) {
72
91
  console.log(`Config already exists: ${localConfigPath}`);
@@ -114,7 +133,7 @@ async function main() {
114
133
  await state.runtime.sessions.setTaskArchived(taskId, archived);
115
134
  }, deleteTaskSession: async (taskId) => {
116
135
  await state.runtime.sessions.deleteTask(taskId);
117
- }, exportTaskSession: async (taskId) => (await state.runtime.sessions.exportTask(taskId)).path, loadCollaborationTimeline: (taskId) => state.runtime.sessions.readCollaborationTimeline(taskId), activateTaskSession: async (taskId) => {
136
+ }, exportTaskSession: async (taskId) => (await state.runtime.sessions.exportTask(taskId)).path, exportDiagnostics: async () => (await exportDiagnostics(cliArgs.appRoot, state.runtime)).path, loadCollaborationTimeline: (taskId) => state.runtime.sessions.readCollaborationTimeline(taskId), activateTaskSession: async (taskId) => {
118
137
  if (!taskId) {
119
138
  await state.runtime.index.setActiveTaskId(null);
120
139
  return null;
@@ -86,7 +86,8 @@ const CodexRouterConfigSchema = z.object({
86
86
  });
87
87
  const OrchestrationConfigSchema = z.object({
88
88
  maxParallelFeatures: z.number().int().min(1).max(8),
89
- maxRevisionRounds: z.number().int().min(1).max(10)
89
+ maxRevisionRounds: z.number().int().min(1).max(10),
90
+ maxConflictReplans: z.number().int().min(0).max(8)
90
91
  }).strict();
91
92
  const UiConfigSchema = z.object({
92
93
  showStatusBar: z.boolean(),
@@ -177,7 +178,8 @@ export function defaultConfig(projectRoot) {
177
178
  },
178
179
  orchestration: {
179
180
  maxParallelFeatures: 3,
180
- maxRevisionRounds: 3
181
+ maxRevisionRounds: 3,
182
+ maxConflictReplans: 2
181
183
  },
182
184
  workers: {
183
185
  codex: {
@@ -0,0 +1,394 @@
1
+ import { open, mkdtemp, rename, rm } from "node:fs/promises";
2
+ import { homedir, platform, arch } from "node:os";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { runDoctor } from "../doctor.js";
5
+ import { version } from "../version.js";
6
+ import { NativeSessionSchema } from "../domain/schemas.js";
7
+ import { ensureDir, pathExists, readJson, writeJson, writeText } from "./file-store.js";
8
+ import { readRouterAudit } from "./router-audit.js";
9
+ import { sanitizeRouterText } from "./router-redaction.js";
10
+ const DIAGNOSTICS_FORMAT = "parallel-codex-diagnostics-v1";
11
+ const DEFAULT_TASK_LIMIT = 20;
12
+ const DEFAULT_WORKER_LIMIT = 200;
13
+ const DEFAULT_ROUTER_RECORD_LIMIT = 100;
14
+ const DEFAULT_LOG_BYTES = 64 * 1024;
15
+ const DEFAULT_LOG_LINES = 200;
16
+ export async function exportDiagnostics(appRoot, runtime, options = {}) {
17
+ const now = options.now ?? (() => new Date());
18
+ const createdAt = now().toISOString();
19
+ const context = diagnosticsTextContext(appRoot, runtime.workspaceRoot);
20
+ const taskLimit = boundedLimit(options.taskLimit, DEFAULT_TASK_LIMIT, 100);
21
+ const workerLimit = boundedLimit(options.workerLimit, DEFAULT_WORKER_LIMIT, 1000);
22
+ const routerRecordLimit = boundedLimit(options.routerRecordLimit, DEFAULT_ROUTER_RECORD_LIMIT, 500);
23
+ const logBytes = boundedLimit(options.logBytes, DEFAULT_LOG_BYTES, 1024 * 1024);
24
+ const logLines = boundedLimit(options.logLines, DEFAULT_LOG_LINES, 2000);
25
+ const destination = await diagnosticsDestination(runtime, options.destinationPath ?? null, createdAt);
26
+ const stagingParent = dirname(destination);
27
+ await ensureDir(stagingParent);
28
+ if (await pathExists(destination)) {
29
+ throw new Error(`Diagnostics destination already exists: ${destination}`);
30
+ }
31
+ const staging = await mkdtemp(join(stagingParent, ".parallel-codex-diagnostics-"));
32
+ try {
33
+ const [activeTaskId, tasks, routerAudit, doctor] = await Promise.all([
34
+ runtime.index.activeTaskId(),
35
+ runtime.index.listTasks(taskLimit, { includeArchived: true }),
36
+ readRouterAudit(join(runtime.routerCwd, "routes.jsonl"), routerRecordLimit),
37
+ options.doctor
38
+ ? options.doctor()
39
+ : runDoctor(appRoot, runtime.workspaceRoot, options.env ?? process.env)
40
+ ]);
41
+ const workerRecords = [];
42
+ let omittedWorkers = 0;
43
+ for (let taskIndex = 0; taskIndex < tasks.length; taskIndex += 1) {
44
+ const task = tasks[taskIndex];
45
+ if (!task) {
46
+ continue;
47
+ }
48
+ const taskWorkers = await runtime.orchestrator.listTaskWorkers(task.id);
49
+ const remaining = Math.max(0, workerLimit - workerRecords.length);
50
+ const selected = taskWorkers.length <= remaining
51
+ ? taskWorkers
52
+ : latestWorkers(taskWorkers, remaining);
53
+ omittedWorkers += taskWorkers.length - selected.length;
54
+ for (const worker of selected) {
55
+ workerRecords.push(await exportWorkerLog(staging, task.id, worker, context, logBytes, logLines));
56
+ }
57
+ if (workerRecords.length >= workerLimit) {
58
+ omittedWorkers += tasks
59
+ .slice(taskIndex + 1)
60
+ .reduce((total, remainingTask) => total + remainingTask.workerCount, 0);
61
+ break;
62
+ }
63
+ }
64
+ const safeTasks = sanitizeDiagnosticsValue(tasks, context);
65
+ const safeWorkers = sanitizeDiagnosticsValue(workerRecords, context);
66
+ const safeRouterAudit = sanitizeDiagnosticsValue(routerAudit, context);
67
+ const safeDoctorText = sanitizeDiagnosticsText(doctor.text, context);
68
+ const config = diagnosticsConfigSummary(runtime.config, options.env ?? process.env, context);
69
+ const report = {
70
+ format: DIAGNOSTICS_FORMAT,
71
+ createdAt,
72
+ app: {
73
+ name: "parallel-codex-tui",
74
+ version,
75
+ node: process.versions.node,
76
+ platform: platform(),
77
+ arch: arch()
78
+ },
79
+ workspace: "$WORKSPACE",
80
+ appRoot: appRoot === runtime.workspaceRoot ? "$WORKSPACE" : "$APP_ROOT",
81
+ sessionIndex: {
82
+ schemaVersion: runtime.index.schemaVersion(),
83
+ recovery: sanitizeDiagnosticsValue(runtime.index.recovery, context),
84
+ activeTaskId: activeTaskId ?? null
85
+ },
86
+ startupRecovery: sanitizeDiagnosticsValue({
87
+ pendingTaskCreations: runtime.pendingTaskCreations,
88
+ workspaceCommitRecovery: runtime.workspaceCommitRecovery,
89
+ recoveredTasks: runtime.recoveredTasks
90
+ }, context),
91
+ config,
92
+ doctor: {
93
+ ok: doctor.ok,
94
+ path: "doctor.txt"
95
+ },
96
+ tasks: {
97
+ path: "tasks.json",
98
+ exported: tasks.length,
99
+ limit: taskLimit
100
+ },
101
+ workers: {
102
+ path: "workers.json",
103
+ exported: workerRecords.length,
104
+ omitted: omittedWorkers,
105
+ limit: workerLimit
106
+ },
107
+ routerAudit: {
108
+ path: "router-audit.jsonl",
109
+ exported: routerAudit.length,
110
+ limit: routerRecordLimit
111
+ },
112
+ logs: {
113
+ exported: workerRecords.filter((worker) => worker.log).length,
114
+ maxBytesPerWorker: logBytes,
115
+ maxLinesPerWorker: logLines
116
+ }
117
+ };
118
+ await writeJson(join(staging, "report.json"), report);
119
+ await writeJson(join(staging, "tasks.json"), safeTasks);
120
+ await writeJson(join(staging, "workers.json"), safeWorkers);
121
+ await writeText(join(staging, "doctor.txt"), ensureTrailingNewline(safeDoctorText));
122
+ await writeText(join(staging, "router-audit.jsonl"), safeRouterAudit.map((record) => JSON.stringify(record)).join("\n") + (safeRouterAudit.length > 0 ? "\n" : ""));
123
+ await writeText(join(staging, "report.md"), diagnosticsMarkdownReport(report, config));
124
+ await writeJson(join(staging, "manifest.json"), {
125
+ format: DIAGNOSTICS_FORMAT,
126
+ created_at: createdAt,
127
+ report: "report.json",
128
+ redaction: {
129
+ enabled: true,
130
+ pathAliases: ["$WORKSPACE", "$APP_ROOT", "~"],
131
+ secrets: "URL credentials and paths, authorization values, secret assignments, and common raw token formats",
132
+ configValues: "Environment variable values, prompts, role instructions, command arguments, and source files are excluded",
133
+ logs: `Only the latest ${logLines} lines and ${logBytes} bytes per Worker are eligible for export`
134
+ }
135
+ });
136
+ await rename(staging, destination);
137
+ return {
138
+ path: destination,
139
+ createdAt,
140
+ taskCount: tasks.length,
141
+ workerCount: workerRecords.length,
142
+ logCount: workerRecords.filter((worker) => worker.log).length
143
+ };
144
+ }
145
+ catch (error) {
146
+ await rm(staging, { force: true, recursive: true });
147
+ throw error;
148
+ }
149
+ }
150
+ async function diagnosticsDestination(runtime, requested, createdAt) {
151
+ if (requested) {
152
+ return requested;
153
+ }
154
+ const root = join(runtime.workspaceRoot, runtime.config.dataDir, "diagnostics");
155
+ await ensureDir(root);
156
+ const stamp = createdAt.replace(/[^0-9]/g, "").slice(0, 14);
157
+ let destination = join(root, stamp);
158
+ for (let suffix = 2; await pathExists(destination); suffix += 1) {
159
+ destination = join(root, `${stamp}-${suffix}`);
160
+ }
161
+ return destination;
162
+ }
163
+ async function exportWorkerLog(staging, taskId, worker, context, maxBytes, maxLines) {
164
+ const workerSegment = safePathSegment(worker.id);
165
+ const relativeLogPath = join("logs", safePathSegment(taskId), `${workerSegment}.log`);
166
+ const tail = await readBoundedTextTail(worker.logPath, maxBytes, maxLines);
167
+ if (tail) {
168
+ await writeText(join(staging, relativeLogPath), ensureTrailingNewline(sanitizeDiagnosticsText(tail.text, context)));
169
+ }
170
+ return {
171
+ taskId,
172
+ id: worker.id,
173
+ ...(worker.featureId ? { featureId: worker.featureId } : {}),
174
+ role: worker.role,
175
+ engine: worker.engine,
176
+ label: sanitizeDiagnosticsText(worker.label, context),
177
+ status: worker.runtimeStatus
178
+ ? sanitizeDiagnosticsValue(worker.runtimeStatus, context)
179
+ : null,
180
+ nativeSession: await readNativeSession(worker),
181
+ log: tail
182
+ ? {
183
+ path: relativeLogPath,
184
+ sourceBytes: tail.sourceBytes,
185
+ exportedBytes: Buffer.byteLength(sanitizeDiagnosticsText(tail.text, context), "utf8"),
186
+ exportedLines: tail.text.split("\n").length,
187
+ truncated: tail.truncated
188
+ }
189
+ : null
190
+ };
191
+ }
192
+ async function readNativeSession(worker) {
193
+ try {
194
+ return await readJson(join(dirname(worker.statusPath), "native-session.json"), NativeSessionSchema);
195
+ }
196
+ catch (error) {
197
+ if (error.code === "ENOENT") {
198
+ return null;
199
+ }
200
+ return null;
201
+ }
202
+ }
203
+ async function readBoundedTextTail(path, maxBytes, maxLines) {
204
+ let handle;
205
+ try {
206
+ handle = await open(path, "r");
207
+ }
208
+ catch (error) {
209
+ if (error.code === "ENOENT") {
210
+ return null;
211
+ }
212
+ throw error;
213
+ }
214
+ try {
215
+ const sourceBytes = (await handle.stat()).size;
216
+ const requested = Math.min(sourceBytes, maxBytes);
217
+ const start = Math.max(0, sourceBytes - requested);
218
+ const buffer = Buffer.alloc(requested);
219
+ const { bytesRead } = await handle.read(buffer, 0, requested, start);
220
+ let text = buffer.subarray(0, bytesRead).toString("utf8");
221
+ let truncated = start > 0;
222
+ if (start > 0) {
223
+ const firstNewline = text.indexOf("\n");
224
+ if (firstNewline >= 0) {
225
+ text = text.slice(firstNewline + 1);
226
+ }
227
+ }
228
+ const lines = text.split("\n");
229
+ if (lines.length > maxLines) {
230
+ text = lines.slice(-maxLines).join("\n");
231
+ truncated = true;
232
+ }
233
+ if (truncated) {
234
+ text = `[earlier log content omitted]\n${text}`;
235
+ }
236
+ return { text, sourceBytes, truncated };
237
+ }
238
+ finally {
239
+ await handle.close();
240
+ }
241
+ }
242
+ function diagnosticsConfigSummary(config, env, context) {
243
+ return {
244
+ dataDir: sanitizeDiagnosticsText(config.dataDir, context),
245
+ router: {
246
+ mode: config.router.defaultMode,
247
+ command: commandName(config.router.codex.command),
248
+ argumentCount: config.router.codex.args.length,
249
+ timeoutMs: config.router.codex.timeoutMs,
250
+ firstOutputTimeoutMs: config.router.codex.firstOutputTimeoutMs,
251
+ idleTimeoutMs: config.router.codex.idleTimeoutMs,
252
+ followUpTimeoutMs: config.router.codex.followUpTimeoutMs,
253
+ maxOutputBytes: config.router.codex.maxOutputBytes,
254
+ maxAttempts: config.router.codex.maxAttempts,
255
+ retryDelayMs: config.router.codex.retryDelayMs,
256
+ fallback: config.router.codex.fallback,
257
+ environment: environmentSummary(config.router.codex.env, env),
258
+ proxyVariables: proxyVariableNames(config.router.codex.env)
259
+ },
260
+ orchestration: config.orchestration,
261
+ pairing: config.pairing,
262
+ providers: Object.entries(config.workers).map(([id, worker]) => ({
263
+ id,
264
+ command: commandName(worker.command),
265
+ interactiveCommand: commandName(worker.interactive.command),
266
+ argumentCount: worker.args.length,
267
+ assignable: worker.assignable,
268
+ model: sanitizeDiagnosticsText(worker.model.name || "default", context),
269
+ modelProvider: sanitizeDiagnosticsText(worker.model.provider || "default", context),
270
+ modelArgumentCount: worker.model.args.length,
271
+ environment: environmentSummary(worker.model.env, env),
272
+ proxyVariables: proxyVariableNames(worker.model.env),
273
+ capabilities: {
274
+ profile: worker.capabilities.profile,
275
+ writableDirectoryArguments: worker.capabilities.writableDirArgs.length > 0,
276
+ freshSessionArguments: worker.capabilities.freshSessionArgs.length > 0
277
+ },
278
+ nativeSession: {
279
+ enabled: worker.nativeSession.enabled,
280
+ detectSessionId: worker.nativeSession.detectSessionId,
281
+ fallback: worker.nativeSession.fallback,
282
+ resumeArgumentCount: worker.nativeSession.resumeArgs.length,
283
+ forkSupported: worker.interactive.forkArgs.length > 0
284
+ },
285
+ timeouts: {
286
+ totalMs: worker.timeoutMs ?? null,
287
+ firstOutputMs: worker.firstOutputTimeoutMs ?? null,
288
+ idleMs: worker.idleTimeoutMs ?? null
289
+ }
290
+ })),
291
+ ui: {
292
+ theme: config.ui.theme,
293
+ showStatusBar: config.ui.showStatusBar,
294
+ autoOpenFailedWorker: config.ui.autoOpenFailedWorker,
295
+ colorOverrides: Object.keys(config.ui.colors).sort()
296
+ }
297
+ };
298
+ }
299
+ function diagnosticsMarkdownReport(report, config) {
300
+ const providers = config.providers
301
+ .map((provider) => `- ${provider.id}: ${provider.command} · ${provider.modelProvider}/${provider.model}`)
302
+ .join("\n");
303
+ return `# parallel-codex-tui diagnostics
304
+
305
+ - Created: ${report.createdAt}
306
+ - Version: ${report.app.version}
307
+ - Runtime: Node ${report.app.node} · ${report.app.platform}/${report.app.arch}
308
+ - Workspace: ${report.workspace}
309
+ - Active task: ${report.sessionIndex.activeTaskId ?? "none"}
310
+ - Doctor: ${report.doctor.ok ? "ok" : "needs attention"}
311
+ - Tasks: ${report.tasks.exported}
312
+ - Workers: ${report.workers.exported}${report.workers.omitted ? ` (${report.workers.omitted} omitted by limit)` : ""}
313
+ - Logs: ${report.logs.exported} bounded tails
314
+ - Router audit rows: ${report.routerAudit.exported}
315
+
316
+ ## Providers
317
+
318
+ ${providers || "- none"}
319
+
320
+ ## Privacy
321
+
322
+ This bundle is sanitized. It excludes environment values, prompts, role instructions, command arguments, source files, and complete lifetime logs. Review the bundle before sharing it.
323
+ `;
324
+ }
325
+ function diagnosticsTextContext(appRoot, workspaceRoot) {
326
+ return {
327
+ aliases: [
328
+ { path: workspaceRoot, alias: "$WORKSPACE" },
329
+ { path: appRoot, alias: "$APP_ROOT" },
330
+ { path: homedir(), alias: "~" }
331
+ ]
332
+ .filter(({ path }) => path.length > 1)
333
+ .sort((left, right) => right.path.length - left.path.length)
334
+ };
335
+ }
336
+ export function sanitizeDiagnosticsText(value, context) {
337
+ let sanitized = sanitizeRouterText(value);
338
+ for (const entry of context.aliases) {
339
+ sanitized = sanitized.split(entry.path).join(entry.alias);
340
+ }
341
+ return sanitized;
342
+ }
343
+ function sanitizeDiagnosticsValue(value, context) {
344
+ if (typeof value === "string") {
345
+ return sanitizeDiagnosticsText(value, context);
346
+ }
347
+ if (Array.isArray(value)) {
348
+ return value.map((entry) => sanitizeDiagnosticsValue(entry, context));
349
+ }
350
+ if (value && typeof value === "object") {
351
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
352
+ key,
353
+ sanitizeDiagnosticsValue(entry, context)
354
+ ]));
355
+ }
356
+ return value;
357
+ }
358
+ function environmentSummary(values, env) {
359
+ return Object.entries(values).map(([name, value]) => ({
360
+ name,
361
+ configured: true,
362
+ referencedVariables: [...value.matchAll(/\$\{([A-Z_][A-Z0-9_]*)\}/gi)].map((match) => ({
363
+ name: match[1] ?? "",
364
+ present: Boolean(env[match[1] ?? ""])
365
+ }))
366
+ }));
367
+ }
368
+ function proxyVariableNames(values) {
369
+ return Object.keys(values).filter((name) => /^(?:HTTP|HTTPS|ALL)_PROXY$/i.test(name)).sort();
370
+ }
371
+ function commandName(command) {
372
+ return basename(command) || command;
373
+ }
374
+ function latestWorkers(workers, limit) {
375
+ if (limit <= 0) {
376
+ return [];
377
+ }
378
+ return [...workers]
379
+ .sort((left, right) => ((right.runtimeStatus?.last_event_at ?? "").localeCompare(left.runtimeStatus?.last_event_at ?? "")
380
+ || right.id.localeCompare(left.id)))
381
+ .slice(0, limit);
382
+ }
383
+ function safePathSegment(value) {
384
+ return value.replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 180) || "unknown";
385
+ }
386
+ function boundedLimit(value, fallback, maximum) {
387
+ if (!Number.isFinite(value)) {
388
+ return fallback;
389
+ }
390
+ return Math.min(maximum, Math.max(1, Math.trunc(value ?? fallback)));
391
+ }
392
+ function ensureTrailingNewline(value) {
393
+ return value.endsWith("\n") ? value : `${value}\n`;
394
+ }
@@ -6,7 +6,7 @@ const TASK_STATE_TRANSITIONS = {
6
6
  actor_running: new Set(["critic_running", "verifying", "paused", "failed", "cancelled"]),
7
7
  critic_running: new Set(["revision_needed", "integrating", "paused", "failed", "cancelled"]),
8
8
  revision_needed: new Set(["actor_running", "paused", "failed", "cancelled"]),
9
- integrating: new Set(["actor_running", "verifying", "paused", "done", "failed", "cancelled"]),
9
+ integrating: new Set(["judging", "actor_running", "verifying", "paused", "done", "failed", "cancelled"]),
10
10
  verifying: new Set(["revision_needed", "integrating", "paused", "done", "failed", "cancelled"]),
11
11
  paused: new Set(["routed", "judging", "ready_for_pair", "failed", "cancelled"]),
12
12
  done: new Set(["routed", "cancelled"]),
@@ -58,7 +58,7 @@ export async function createFeatureChannel(input) {
58
58
  await ensureDir(dir);
59
59
  await ensureDir(join(input.task.dir, "dialogue"));
60
60
  if (input.resume) {
61
- await ensureFeatureChannelFiles(input, channel);
61
+ await ensureFeatureChannelFiles(input, channel, input.refreshDefinition ?? false);
62
62
  const repairedState = await repairFeatureStatus(channel);
63
63
  if (repairedState) {
64
64
  await appendFeatureDialogue(channel, "feature.status_recovered", "actor", `Recovered Feature status as ${repairedState} while preserving collaboration evidence.`);
@@ -76,7 +76,7 @@ export async function createFeatureChannel(input) {
76
76
  await appendFeatureDialogue(channel, "feature.created", "actor", "Feature mailbox created for the current turn.");
77
77
  return channel;
78
78
  }
79
- async function ensureFeatureChannelFiles(input, channel) {
79
+ async function ensureFeatureChannelFiles(input, channel, refreshDefinition) {
80
80
  const files = [
81
81
  [channel.specPath, buildFeatureSpec(input, channel)],
82
82
  [channel.actorWorklogPath, ""],
@@ -84,7 +84,7 @@ async function ensureFeatureChannelFiles(input, channel) {
84
84
  [channel.criticFindingsPath, ""]
85
85
  ];
86
86
  for (const [path, initialContent] of files) {
87
- if (!(await pathExists(path))) {
87
+ if ((refreshDefinition && path === channel.specPath) || !(await pathExists(path))) {
88
88
  await writeText(path, initialContent);
89
89
  }
90
90
  }
@@ -1,4 +1,4 @@
1
- import { readdir } from "node:fs/promises";
1
+ import { cp, readdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { appendJsonLine, ensureDir, pathExists, readJson, readTextIfExists, removeIfExists, writeJson, writeText } from "../core/file-store.js";
4
4
  import { runWithLeaseFinalization } from "../core/lease-finalization.js";
@@ -16,7 +16,7 @@ import { validateFinalJudgeAcceptance } from "./final-acceptance.js";
16
16
  import { featureExecutionWaves, parseFeaturePlan } from "./feature-plan.js";
17
17
  import { JUDGE_REQUIRED_ARTIFACTS, JUDGE_VALIDATION_FILE, validateJudgeArtifacts } from "./judge-artifacts.js";
18
18
  import { buildSupervisorSummary } from "./supervisor-summary.js";
19
- import { ParallelWorkspaceManager } from "./workspace-sandbox.js";
19
+ import { ParallelWorkspaceManager, WorkspaceMergeConflictError } from "./workspace-sandbox.js";
20
20
  const PREVIOUS_TURN_SUMMARY_LIMIT = 5;
21
21
  const PREVIOUS_TURN_SUMMARY_LENGTH = 600;
22
22
  const JUDGE_ARTIFACTS = [
@@ -431,7 +431,7 @@ export class Orchestrator {
431
431
  criticEngine: route.critic_engine,
432
432
  resume: input.retry
433
433
  })));
434
- return await this.runFeaturePlan(input, task, route, turn, workers, judge, featurePlan, features);
434
+ return await this.runFeaturePlanWithConflictReplan(input, task, route, turn, workers, judge, featurePlan, features);
435
435
  }
436
436
  const feature = await createFeatureChannel({
437
437
  task,
@@ -477,7 +477,7 @@ export class Orchestrator {
477
477
  criticEngine: route.critic_engine,
478
478
  resume: input.retry
479
479
  })));
480
- return await this.runFeaturePlan(input, task, route, turn, workers, judge, featurePlan, features);
480
+ return await this.runFeaturePlanWithConflictReplan(input, task, route, turn, workers, judge, featurePlan, features);
481
481
  }
482
482
  const feature = await createFeatureChannel({
483
483
  task,
@@ -558,6 +558,127 @@ export class Orchestrator {
558
558
  throw new Error(`Invalid feature plan at ${sourcePath}: ${errorMessage(error)}`);
559
559
  }
560
560
  }
561
+ async runFeaturePlanWithConflictReplan(input, task, route, turn, workers, judge, plan, features) {
562
+ let currentInput = input;
563
+ let currentJudge = judge;
564
+ let currentPlan = plan;
565
+ let currentFeatures = features;
566
+ let replanRound = 0;
567
+ const workspaceManager = new ParallelWorkspaceManager({
568
+ workspaceRoot: input.cwd,
569
+ taskDir: task.dir,
570
+ dataDir: this.config.dataDir
571
+ });
572
+ while (true) {
573
+ try {
574
+ return await this.runFeaturePlan(currentInput, task, route, turn, workers, currentJudge, currentPlan, currentFeatures);
575
+ }
576
+ catch (error) {
577
+ if (!(error instanceof WorkspaceMergeConflictError)
578
+ || error.wave === undefined
579
+ || !error.featureId
580
+ || error.waveFeatureIds.length < 2) {
581
+ throw error;
582
+ }
583
+ if (replanRound >= this.config.orchestration.maxConflictReplans) {
584
+ throw new Error(`${error.message} Automatic Judge replan limit exhausted after ${replanRound} `
585
+ + `${replanRound === 1 ? "round" : "rounds"}.`, { cause: error });
586
+ }
587
+ replanRound += 1;
588
+ const definitionIds = conflictDefinitionIds(currentPlan, currentFeatures, error.waveFeatureIds);
589
+ if (definitionIds.length < 2) {
590
+ throw error;
591
+ }
592
+ const reportPath = join(turn.dir, `feature-replan-${String(replanRound).padStart(2, "0")}.json`);
593
+ const conflictArchiveDir = join(turn.dir, "conflicts", `replan-${String(replanRound).padStart(2, "0")}`);
594
+ await ensureDir(join(turn.dir, "conflicts"));
595
+ await cp(error.conflictDir, conflictArchiveDir, { recursive: true, force: true });
596
+ const requestedAt = new Date().toISOString();
597
+ const report = {
598
+ version: 1,
599
+ state: "requested",
600
+ round: replanRound,
601
+ requested_at: requestedAt,
602
+ reason: "workspace-merge-conflict",
603
+ wave: error.wave,
604
+ conflict_paths: error.paths,
605
+ conflict_evidence: conflictArchiveDir,
606
+ conflicting_feature_id: error.featureId,
607
+ wave_feature_ids: error.waveFeatureIds,
608
+ definition_ids: definitionIds,
609
+ previous_plan: currentPlan
610
+ };
611
+ await writeJson(reportPath, report);
612
+ await this.sessions.appendEvent(task, "feature.conflict_replan_requested", `Judge replan ${replanRound}/${this.config.orchestration.maxConflictReplans} requested for wave ${error.wave}: ${definitionIds.join(", ")}`);
613
+ await Promise.all(currentFeatures
614
+ .filter((feature) => error.waveFeatureIds.includes(feature.id))
615
+ .map((feature) => appendFeatureDialogue(feature, "judge.conflict_replan_requested", "judge", `Workspace conflict in ${error.paths.join(", ")}; Judge is replanning the Feature DAG.`, { report: reportPath, conflicts: conflictArchiveDir })));
616
+ await this.sessions.updateTaskStatus(task, "judging");
617
+ currentInput.onStatus?.({
618
+ taskId: task.id,
619
+ judge: `replanning ${replanRound}/${this.config.orchestration.maxConflictReplans}`,
620
+ actor: "waiting",
621
+ critic: "waiting"
622
+ });
623
+ await this.clearTurnJudgeArtifacts(turn);
624
+ const replanContext = {
625
+ round: replanRound,
626
+ reportPath,
627
+ conflictPaths: error.paths,
628
+ waveFeatureIds: definitionIds,
629
+ previousFeatureIds: currentPlan.features.map((feature) => feature.id)
630
+ };
631
+ const judgeWorker = await this.runJudge({ ...currentInput, retry: true }, task, route.judge_engine, workers, turn, replanContext);
632
+ const revisedJudge = await this.snapshotJudgeArtifacts(judgeWorker, turn);
633
+ let judgePlan = null;
634
+ let judgePlanError = "";
635
+ try {
636
+ judgePlan = await this.loadFeaturePlan(revisedJudge, turn);
637
+ }
638
+ catch (planError) {
639
+ judgePlanError = errorMessage(planError);
640
+ }
641
+ const judgePlanAccepted = Boolean(judgePlan
642
+ && sameFeatureIds(currentPlan, judgePlan)
643
+ && conflictFeaturesAreSerialized(judgePlan, definitionIds));
644
+ const revisedPlan = judgePlanAccepted
645
+ ? judgePlan
646
+ : serializeConflictWave(currentPlan, definitionIds);
647
+ await writeJson(join(turn.dir, "feature-plan.json"), revisedPlan);
648
+ await workspaceManager.discardWavesFrom(turn.turnId, error.wave);
649
+ const revisedFeatures = await Promise.all(revisedPlan.features.map((feature) => createFeatureChannel({
650
+ task,
651
+ turn,
652
+ request: input.request,
653
+ judgeDir: revisedJudge.dir,
654
+ feature,
655
+ actorEngine: route.actor_engine,
656
+ criticEngine: route.critic_engine,
657
+ resume: true,
658
+ refreshDefinition: true
659
+ })));
660
+ await this.sessions.updateTaskStatus(task, "ready_for_pair");
661
+ await writeJson(reportPath, {
662
+ ...report,
663
+ state: "applied",
664
+ applied_at: new Date().toISOString(),
665
+ source: judgePlanAccepted ? "judge" : "supervisor-serialization-fallback",
666
+ ...(judgePlanError ? { judge_plan_error: judgePlanError } : {}),
667
+ ...(judgePlan && !judgePlanAccepted ? { rejected_judge_plan: judgePlan } : {}),
668
+ revised_plan: revisedPlan
669
+ });
670
+ await this.sessions.appendEvent(task, "feature.conflict_replan_applied", `Applied ${judgePlanAccepted ? "Judge" : "supervisor fallback"} DAG replan ${replanRound}; wave ${error.wave} will resume from isolated workspaces`);
671
+ await Promise.all(revisedFeatures
672
+ .filter((feature) => error.waveFeatureIds.includes(feature.id))
673
+ .map((feature) => appendFeatureDialogue(feature, "judge.conflict_replan_applied", "judge", `Replanned dependencies: ${feature.dependsOn.length > 0 ? feature.dependsOn.join(", ") : "none"}.`, { report: reportPath, plan: join(turn.dir, "feature-plan.json") })));
674
+ currentInput = { ...currentInput, retry: true };
675
+ currentJudge = revisedJudge;
676
+ currentPlan = revisedPlan;
677
+ features.splice(0, features.length, ...revisedFeatures);
678
+ currentFeatures = features;
679
+ }
680
+ }
681
+ }
561
682
  async runFeaturePlan(input, task, route, turn, workers, judge, plan, features) {
562
683
  const channels = new Map(plan.features.map((definition, index) => [definition.id, features[index]]));
563
684
  const assignments = new Map(await Promise.all(features.map(async (feature) => [
@@ -1294,21 +1415,22 @@ export class Orchestrator {
1294
1415
  throwIfCancelled(input.signal);
1295
1416
  return readTextIfExists(outputLogPath);
1296
1417
  }
1297
- async runJudge(input, task, engine, workers, turn) {
1418
+ async runJudge(input, task, engine, workers, turn, replan) {
1298
1419
  const workerId = taskWorkerId("judge", engine, turn.turnId);
1299
1420
  const judgeFiles = this.workerFiles(task, workerId);
1300
1421
  const judge = await this.sessions.initializeWorker(task, {
1301
1422
  workerId,
1302
1423
  role: "judge",
1303
1424
  engine,
1304
- preserveOutput: input.retry,
1425
+ preserveOutput: input.retry || Boolean(replan),
1305
1426
  prompt: buildJudgePrompt({
1306
1427
  request: input.request,
1307
1428
  taskDir: task.dir,
1308
1429
  workerDir: judgeFiles.dir,
1309
1430
  workspaceDir: input.cwd,
1310
1431
  turn: await this.promptTurnContext(task, turn),
1311
- role: this.config.roles.judge
1432
+ role: this.config.roles.judge,
1433
+ replan
1312
1434
  })
1313
1435
  });
1314
1436
  this.recordWorker(input, workers, {
@@ -2196,6 +2318,47 @@ function requiredChannel(channels, definition) {
2196
2318
  }
2197
2319
  return channel;
2198
2320
  }
2321
+ function conflictDefinitionIds(plan, channels, channelIds) {
2322
+ const definitionByChannel = new Map(channels.map((channel, index) => [
2323
+ channel.id,
2324
+ plan.features[index]?.id
2325
+ ]));
2326
+ return [...new Set(channelIds
2327
+ .map((channelId) => definitionByChannel.get(channelId))
2328
+ .filter((id) => Boolean(id)))];
2329
+ }
2330
+ function sameFeatureIds(previous, revised) {
2331
+ const previousIds = previous.features.map((feature) => feature.id).sort();
2332
+ const revisedIds = revised.features.map((feature) => feature.id).sort();
2333
+ return previousIds.length === revisedIds.length
2334
+ && previousIds.every((id, index) => id === revisedIds[index]);
2335
+ }
2336
+ function conflictFeaturesAreSerialized(plan, featureIds) {
2337
+ const waveByFeature = new Map();
2338
+ for (const [waveIndex, wave] of featureExecutionWaves(plan).entries()) {
2339
+ for (const feature of wave) {
2340
+ waveByFeature.set(feature.id, waveIndex);
2341
+ }
2342
+ }
2343
+ const waveIndexes = featureIds.map((id) => waveByFeature.get(id));
2344
+ return waveIndexes.every((index) => index !== undefined)
2345
+ && new Set(waveIndexes).size === featureIds.length;
2346
+ }
2347
+ function serializeConflictWave(plan, featureIds) {
2348
+ const serializedIds = new Set(featureIds);
2349
+ let previousId = null;
2350
+ const features = plan.features.map((feature) => {
2351
+ if (!serializedIds.has(feature.id)) {
2352
+ return feature;
2353
+ }
2354
+ const dependsOn = previousId
2355
+ ? [...new Set([...feature.depends_on, previousId])]
2356
+ : feature.depends_on;
2357
+ previousId = feature.id;
2358
+ return { ...feature, depends_on: dependsOn };
2359
+ });
2360
+ return parseFeaturePlan({ version: 1, features });
2361
+ }
2199
2362
  function requiredFeatureAssignment(assignments, channel) {
2200
2363
  const assignment = assignments.get(channel.id);
2201
2364
  if (!assignment) {
@@ -53,6 +53,20 @@ export function buildJudgePrompt(input) {
53
53
  "Use safe lowercase ids made from letters, numbers, and hyphens.",
54
54
  "List dependencies in \"depends_on\"; independent features can run in parallel.",
55
55
  "Example: {\"version\":1,\"features\":[{\"id\":\"ui\",\"title\":\"UI\",\"description\":\"Build the interface\",\"depends_on\":[]}]}",
56
+ ...(input.replan ? [
57
+ "",
58
+ `# Conflict replan ${input.replan.round}`,
59
+ "",
60
+ `Conflict report: ${input.replan.reportPath}`,
61
+ `Conflicting paths: ${input.replan.conflictPaths.join(", ")}`,
62
+ `Features from the conflicting wave: ${input.replan.waveFeatureIds.join(", ")}`,
63
+ "The previous parallel DAG produced an isolated merge conflict; the live workspace was not changed by that wave.",
64
+ "Read the conflict report and rewrite the Judge artifacts for a safe retry.",
65
+ `Preserve exactly these feature ids: ${input.replan.previousFeatureIds.join(", ")}.`,
66
+ "Do not add, remove, or rename Features during this replan.",
67
+ "Use depends_on to serialize the Features from the conflicting wave so they no longer execute together.",
68
+ "Keep unrelated safe Features parallel when their dependencies allow it."
69
+ ] : []),
56
70
  "",
57
71
  "User request:",
58
72
  input.request,
@@ -10,13 +10,21 @@ const LIVE_COMMIT_PROTOCOL = "atomic-claim-v1";
10
10
  export class WorkspaceMergeConflictError extends Error {
11
11
  paths;
12
12
  conflictDir;
13
- constructor(paths, conflictDir) {
13
+ wave;
14
+ featureId;
15
+ waveFeatureIds;
16
+ integratedFeatureIds;
17
+ constructor(paths, conflictDir, context = {}) {
14
18
  const count = paths.length;
15
19
  super(`Workspace integration conflict in ${count} ${count === 1 ? "path" : "paths"}: ${paths.join(", ")}. `
16
20
  + `The live workspace was not changed. Conflict evidence: ${conflictDir}`);
17
21
  this.name = "WorkspaceMergeConflictError";
18
22
  this.paths = paths;
19
23
  this.conflictDir = conflictDir;
24
+ this.wave = context.wave;
25
+ this.featureId = context.featureId;
26
+ this.waveFeatureIds = [...(context.waveFeatureIds ?? [])];
27
+ this.integratedFeatureIds = [...(context.integratedFeatureIds ?? [])];
20
28
  }
21
29
  }
22
30
  export class WorkspaceLiveMutationError extends Error {
@@ -163,7 +171,7 @@ export class ParallelWorkspaceManager {
163
171
  await rm(wave.integrationDir, { recursive: true, force: true });
164
172
  await rm(wave.verificationDir, { recursive: true, force: true });
165
173
  await cloneTree(wave.baselineDir, wave.stagingDir);
166
- for (const featureId of wave.featureIds) {
174
+ for (const [featureIndex, featureId] of wave.featureIds.entries()) {
167
175
  const featureDir = wave.featureDirs.get(featureId);
168
176
  if (!featureDir) {
169
177
  throw new Error(`Feature workspace missing for ${featureId}`);
@@ -171,7 +179,12 @@ export class ParallelWorkspaceManager {
171
179
  const featureConflictDir = join(wave.conflictDir, featureId);
172
180
  const plan = await planWorkspaceMerge(wave.baselineDir, featureDir, wave.stagingDir, featureConflictDir, (path) => this.excludeRelativePath(path));
173
181
  if (plan.conflicts.length > 0) {
174
- throw new WorkspaceMergeConflictError(plan.conflicts, featureConflictDir);
182
+ throw new WorkspaceMergeConflictError(plan.conflicts, featureConflictDir, {
183
+ wave: wave.wave,
184
+ featureId,
185
+ waveFeatureIds: wave.featureIds,
186
+ integratedFeatureIds: wave.featureIds.slice(0, featureIndex)
187
+ });
175
188
  }
176
189
  await applyMergePlan(wave.stagingDir, plan);
177
190
  }
@@ -187,6 +200,29 @@ export class ParallelWorkspaceManager {
187
200
  });
188
201
  return { changedPaths };
189
202
  }
203
+ async discardWavesFrom(turnId, firstWave) {
204
+ if (!/^\d{4,}$/.test(turnId)) {
205
+ throw new Error(`Unsafe workspace turn id: ${turnId}`);
206
+ }
207
+ if (!Number.isInteger(firstWave) || firstWave < 1) {
208
+ throw new Error(`Workspace wave must be a positive integer: ${firstWave}`);
209
+ }
210
+ const turnRoot = join(this.taskDir, "workspaces", `turn-${turnId}`);
211
+ if (!(await pathIsDirectory(turnRoot))) {
212
+ return;
213
+ }
214
+ for (const entry of await readdir(turnRoot, { withFileTypes: true })) {
215
+ if (!entry.isDirectory()) {
216
+ continue;
217
+ }
218
+ const match = entry.name.match(/^wave-(\d+)$/);
219
+ if (match && Number(match[1] ?? 0) >= firstWave) {
220
+ await rm(join(turnRoot, entry.name), { recursive: true, force: true });
221
+ }
222
+ }
223
+ await rm(join(turnRoot, "final-verification"), { recursive: true, force: true });
224
+ await rm(join(turnRoot, "final-verification.json"), { force: true });
225
+ }
190
226
  async prepareVerificationWorkspace(wave) {
191
227
  await rm(wave.verificationDir, { recursive: true, force: true });
192
228
  await cloneTree(wave.integrationDir, wave.verificationDir);
package/dist/tui/App.js CHANGED
@@ -19,7 +19,7 @@ import { TerminalOutput } from "./TerminalOutput.js";
19
19
  import { NativeTerminalScreen } from "./terminal-screen.js";
20
20
  import { WorkerOutputView } from "./WorkerOutputView.js";
21
21
  import { compactEndByDisplayWidth, displayWidth, wrapByDisplayWidth } from "./display-width.js";
22
- import { isAttachShortcut, isCopyShortcut, isExitShortcut, isLogsShortcut, isNewTaskShortcut, isRouterDiagnosticsShortcut, isStatusDetailsShortcut, isTaskResultShortcut, isTaskSessionsShortcut, isWorkerOverviewShortcut, isWorkerSearchShortcut, isWorkspaceShortcut, mouseScrollDelta, rawHistoryDelta, rawPageScrollDelta, rawPlainArrowDelta, scrollDelta, workerLogJumpKind } from "./keyboard.js";
22
+ import { isAttachShortcut, isCopyShortcut, isDiagnosticsShortcut, isExitShortcut, isLogsShortcut, isNewTaskShortcut, isRouterDiagnosticsShortcut, isStatusDetailsShortcut, isTaskResultShortcut, isTaskSessionsShortcut, isWorkerOverviewShortcut, isWorkerSearchShortcut, isWorkspaceShortcut, mouseScrollDelta, rawHistoryDelta, rawPageScrollDelta, rawPlainArrowDelta, scrollDelta, workerLogJumpKind } from "./keyboard.js";
23
23
  import { createRawInputDecoder, tokenizeRawInput } from "./raw-input-decoder.js";
24
24
  import { decodeHtmlEntities } from "./markdown-text.js";
25
25
  import { configureTuiTheme, TUI_THEME } from "./theme.js";
@@ -48,7 +48,7 @@ const EMPTY_WORKER_NAVIGATION_TARGETS = {
48
48
  errorOffsets: [],
49
49
  diffOffsets: []
50
50
  };
51
- export function App({ config, orchestrator, cwd, initialTaskId = null, initialRoute = null, initialWorkers, initialCanRetryTask = false, initialMessages = [], persistChatMessage, workspaceChoices = [], switchWorkspace, loadRouterDiagnostics, loadTaskSessions, loadTaskSessionDetails, renameTaskSession, setTaskSessionArchived, deleteTaskSession, exportTaskSession, loadCollaborationTimeline, activateTaskSession, prepareNativeAttach, prepareNativeFork, startNativeAttach, copyToClipboard = copyTextToClipboard, shutdownSignal }) {
51
+ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRoute = null, initialWorkers, initialCanRetryTask = false, initialMessages = [], persistChatMessage, workspaceChoices = [], switchWorkspace, loadRouterDiagnostics, loadTaskSessions, loadTaskSessionDetails, renameTaskSession, setTaskSessionArchived, deleteTaskSession, exportTaskSession, exportDiagnostics, loadCollaborationTimeline, activateTaskSession, prepareNativeAttach, prepareNativeFork, startNativeAttach, copyToClipboard = copyTextToClipboard, shutdownSignal }) {
52
52
  configureTuiTheme({
53
53
  theme: config.ui.theme,
54
54
  colors: config.ui.colors
@@ -123,6 +123,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
123
123
  const nativeAttachRef = useRef(nativeAttach);
124
124
  const nativeAttachRequestSequenceRef = useRef(0);
125
125
  const clipboardNoticeTimerRef = useRef(null);
126
+ const diagnosticsExportingRef = useRef(false);
126
127
  const copyTextByViewRef = useRef({ chat: "", worker: "" });
127
128
  const messagesRef = useRef([...initialMessages]);
128
129
  const activeRunControllerRef = useRef(null);
@@ -185,6 +186,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
185
186
  const refreshTaskSessionDetailsRef = useRef(refreshTaskSessionDetails);
186
187
  const openTaskSessionDetailWorkerRef = useRef(openTaskSessionDetailWorker);
187
188
  const copyVisibleViewRef = useRef(copyVisibleView);
189
+ const exportDiagnosticsRef = useRef(exportDiagnosticsBundle);
188
190
  const workspaceReturnViewRef = useRef("chat");
189
191
  const routerReturnViewRef = useRef("chat");
190
192
  const workerOverviewReturnViewRef = useRef("chat");
@@ -372,6 +374,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
372
374
  refreshTaskSessionDetailsRef.current = refreshTaskSessionDetails;
373
375
  openTaskSessionDetailWorkerRef.current = openTaskSessionDetailWorker;
374
376
  copyVisibleViewRef.current = copyVisibleView;
377
+ exportDiagnosticsRef.current = exportDiagnosticsBundle;
375
378
  });
376
379
  useEffect(() => {
377
380
  if ((view !== "collaboration" && view !== "features") || !activeTaskId || !loadCollaborationTimeline) {
@@ -403,6 +406,10 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
403
406
  const resizeNativeAttach = () => {
404
407
  const cols = nativeAttachTerminalColumns(process.stdout.columns || 120);
405
408
  const rows = nativeAttachTerminalRows(process.stdout.rows || 30, Boolean(attachError), config.ui.showStatusBar);
409
+ const dimensions = screen.dimensions();
410
+ if (dimensions.cols === cols && dimensions.rows === rows) {
411
+ return;
412
+ }
406
413
  screen.resize(cols, rows);
407
414
  nativeProcess.resize(cols, rows);
408
415
  setNativeAttach((current) => current && current.screen === screen
@@ -692,6 +699,12 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
692
699
  void copyVisibleViewRef.current();
693
700
  return;
694
701
  }
702
+ if (currentView !== "workspace"
703
+ && currentView !== "native"
704
+ && tokenizeRawInput(chunk).some((inputChunk) => isDiagnosticsShortcut(inputChunk, {}))) {
705
+ void exportDiagnosticsRef.current();
706
+ return;
707
+ }
695
708
  if (currentView === "chat" && routeFallbackPromptRef.current) {
696
709
  const fallbackChunks = tokenizeRawInput(chunk);
697
710
  if (fallbackChunks.some((fallbackChunk) => isExitShortcut(fallbackChunk, {}))) {
@@ -1551,6 +1564,46 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1551
1564
  setAttachError(`Copy failed · ${error instanceof Error ? error.message : String(error)}`);
1552
1565
  }
1553
1566
  }
1567
+ async function exportDiagnosticsBundle() {
1568
+ if (diagnosticsExportingRef.current) {
1569
+ return;
1570
+ }
1571
+ if (!exportDiagnostics) {
1572
+ setAttachError("Diagnostics export is unavailable");
1573
+ return;
1574
+ }
1575
+ diagnosticsExportingRef.current = true;
1576
+ if (clipboardNoticeTimerRef.current) {
1577
+ clearTimeout(clipboardNoticeTimerRef.current);
1578
+ clipboardNoticeTimerRef.current = null;
1579
+ }
1580
+ setAttachError(null);
1581
+ setClipboardNotice({ state: "copying", text: "exporting sanitized diagnostics" });
1582
+ try {
1583
+ const path = await exportDiagnostics();
1584
+ if (!mountedRef.current) {
1585
+ return;
1586
+ }
1587
+ setClipboardNotice({ state: "copied", text: `diagnostics exported · ${path}` });
1588
+ clipboardNoticeTimerRef.current = setTimeout(() => {
1589
+ if (!mountedRef.current) {
1590
+ return;
1591
+ }
1592
+ clipboardNoticeTimerRef.current = null;
1593
+ setClipboardNotice(null);
1594
+ }, 2600);
1595
+ }
1596
+ catch (error) {
1597
+ if (!mountedRef.current) {
1598
+ return;
1599
+ }
1600
+ setClipboardNotice(null);
1601
+ setAttachError(`Diagnostics export failed · ${error instanceof Error ? error.message : String(error)}`);
1602
+ }
1603
+ finally {
1604
+ diagnosticsExportingRef.current = false;
1605
+ }
1606
+ }
1554
1607
  function visibleClipboardPayload(currentView) {
1555
1608
  if (currentView === "chat") {
1556
1609
  return { label: "chat", text: copyTextByViewRef.current.chat };
@@ -415,7 +415,8 @@ function workerInputHints(width) {
415
415
  }
416
416
  function statusDetailInputHints(width) {
417
417
  return selectInputHints(width, [
418
- { label: "status", detail: " · ^S/Esc back · ^C exit" },
418
+ { label: "status", detail: " · ^X diagnostics · ^S/Esc back · ^C exit" },
419
+ { label: "status", detail: " · ^X diag · ^S/Esc back · ^C exit" },
419
420
  { label: "status", detail: " · ^S back · ^C exit" },
420
421
  { label: "status", detail: " · ^S back" },
421
422
  { label: "status", detail: "" },
@@ -34,6 +34,9 @@ export function isWorkerSearchShortcut(input, key) {
34
34
  export function isCopyShortcut(input, key) {
35
35
  return (key.ctrl === true && input.toLowerCase() === "y") || input === "\u0019";
36
36
  }
37
+ export function isDiagnosticsShortcut(input, key) {
38
+ return (key.ctrl === true && input.toLowerCase() === "x") || input === "\u0018";
39
+ }
37
40
  export function workerLogJumpKind(input) {
38
41
  if (input === "e" || input === "E") {
39
42
  return "error";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "0.1.7";
1
+ export const version = "0.2.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parallel-codex-tui",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "description": "A TypeScript TUI wrapper for routed parallel coding with Codex and Claude workers.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,8 +41,14 @@
41
41
  "postbuild": "node -e \"require('node:fs').chmodSync('dist/cli.js', 0o755)\"",
42
42
  "prepack": "npm run build",
43
43
  "pretest": "node scripts/fix-node-pty-permissions.mjs",
44
+ "pretest:stability": "node scripts/fix-node-pty-permissions.mjs",
45
+ "pretest:terminal-hosts": "node scripts/fix-node-pty-permissions.mjs",
46
+ "pretest:real-agents": "node scripts/fix-node-pty-permissions.mjs",
44
47
  "verify:package": "node scripts/verify-package.mjs",
45
48
  "test": "vitest run",
49
+ "test:stability": "vitest run tests/cli-worker-history-smoke.test.ts tests/cli-workspace-switch-smoke.test.ts tests/cli-orphan-process-recovery-smoke.test.ts tests/cli-startup-recovery-smoke.test.ts",
50
+ "test:terminal-hosts": "vitest run tests/cli-terminal-host-compat-smoke.test.ts",
51
+ "test:real-agents": "PCT_REAL_AGENT_TESTS=1 vitest run tests/cli-real-multi-workspace.test.ts",
46
52
  "test:watch": "vitest",
47
53
  "typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
48
54
  "lint": "tsc --noEmit -p tsconfig.typecheck.json"