parallel-codex-tui 0.2.4 → 0.2.5

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.
@@ -77,12 +77,13 @@ fallback = "new"
77
77
 
78
78
  [workers.claude]
79
79
  command = "claude"
80
- # Main uses Claude auto mode for non-interactive tools. Isolated roles are still clamped to acceptEdits.
80
+ # Claude auto mode lets safe non-interactive tools run while retaining permission checks.
81
81
  args = ["--print", "--permission-mode", "auto", "--output-format", "text"]
82
- # timeoutMs is the hard ceiling; first output owns silent startup and idle starts after actual output.
82
+ # Claude text output is buffered until completion, so timeoutMs is the effective startup ceiling.
83
+ # A shorter firstOutputTimeoutMs is used only with --output-format stream-json.
83
84
  # timeoutMs = 2700000
84
85
  # idleTimeoutMs = 300000
85
- firstOutputTimeoutMs = 120000
86
+ # firstOutputTimeoutMs = 2700000
86
87
 
87
88
  # [workers.claude.model]
88
89
  # name = "claude-sonnet"
@@ -157,10 +158,10 @@ freshSessionArgs = []
157
158
  # args = ["run", "--stdin"]
158
159
 
159
160
  [pairing]
160
- main = "claude"
161
+ main = "codex"
161
162
  judge = "codex"
162
163
  actor = "codex"
163
- critic = "codex"
164
+ critic = "claude"
164
165
 
165
166
  [roles.main]
166
167
  title = "Main"
package/README.md CHANGED
@@ -6,21 +6,22 @@ Built with Codex-assisted development.
6
6
 
7
7
  ## Current Release
8
8
 
9
- `v0.2.4` is available from [npm](https://www.npmjs.com/package/parallel-codex-tui/v/0.2.4) and as a [GitHub Release](https://github.com/allendred/parallel-codex-tui/releases/tag/v0.2.4). Codex Router, Main, and automated Workers now place the non-interactive approval policy at the required root position as `-a never ... exec`, while retaining `read-only` routing and `workspace-write` coding sandboxes. Isolated Codex roles also replace unsafe or interactive approval arguments; native attach remains interactive. The Claude Main permission fix from `v0.2.3` remains included.
9
+ `v0.2.5` is available from [npm](https://www.npmjs.com/package/parallel-codex-tui/v/0.2.5) and as a [GitHub Release](https://github.com/allendred/parallel-codex-tui/releases/tag/v0.2.5). Main chat now extracts only the final Codex answer while retaining the complete CLI transcript in `output.log`; legacy transcript-shaped chat records are cleaned when displayed without rewriting their file-backed evidence. Workspace integration ignores known host metadata such as `.DS_Store` and AppleDouble files while continuing to reject real concurrent project edits.
10
10
 
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.
11
+ The default role map is Main/Judge/Actor on Codex and Critic on Claude. Status details show the complete active role map separately from each historical Worker's persisted Provider/model. Config monitoring labels Router-only edits as active on the next request and marks role, Worker, model, permission, or UI changes as requiring restart. Claude text/JSON print runs are recognized as buffered work, use the total deadline instead of a false first-output deadline, and run safe isolated tools through non-interactive `auto` permissions. The `v0.2.4` non-interactive Codex permission placement remains included. The release keeps terminal scrolling and copying available at the same time without requiring Shift and preserves the embedded native Agent scrollback across status-detail round trips and real PTY resizes.
12
12
 
13
13
  Highlights:
14
14
 
15
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
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
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
+ - Main responses, raw process evidence, active role configuration, and historical Worker identity now have separate display and persistence paths.
18
19
  - `--diagnostics` and `Ctrl+X` export bounded, redacted support evidence without prompts, role instructions, command arguments, source files, or environment-variable values.
19
20
  - Runtime preflight checks Codex, Claude, named third-party Providers, proxy reachability, workspace access, CLI capabilities, and native attach policy before work starts.
20
21
  - 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.
21
22
  - 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.
22
23
 
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; Codex fresh and resume runs executed workspace writes with root-level `-a never`, and a Claude Main session executed safe Bash tools before and after resume 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 and preserving the native output tail across status-detail round trips. The deterministic repository suite passed 1,265 tests across 123 files; one quota-consuming real-Agent test is skipped by default and passes through `npm run test:real-agents`.
24
+ 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 and preserving the native output tail across status-detail round trips. The deterministic repository suite contains 1,281 tests across 127 files: 1,280 pass by default, while one quota-consuming real-Agent test is skipped and passes through `npm run test:real-agents`.
24
25
 
25
26
  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.
26
27
 
@@ -204,7 +205,7 @@ The doctor output includes `preview:` and `semantic:` ANSI swatch rows so you ca
204
205
  - Router classification only receives the user request; workspace selection and session files are kept out of the router prompt.
205
206
  - Simple requests stay in the main TUI flow and do not create Judge, Actor, or Critic workers.
206
207
  - Consecutive simple requests reuse the main worker's native session across app restarts when the CLI exposes a session id.
207
- - The workspace chat transcript is appended to `.parallel-codex/sessions/main/chat.jsonl`; startup restores the latest 200 valid messages and skips isolated corrupt rows. The shared tail reader reads JSONL backward in bounded chunks, so a long-running chat does not require loading its lifetime transcript and a partial or oversized final row cannot hide earlier valid messages.
208
+ - The workspace chat transcript is appended to `.parallel-codex/sessions/main/chat.jsonl`; startup restores the latest 200 valid messages and skips isolated corrupt rows. Main stores only the extracted final answer in new chat records while retaining the raw Codex/Claude process transcript in the Main Worker's `output.log`. Legacy Codex transcript records are reduced to their final answer at read time without changing the original JSONL. The shared tail reader reads JSONL backward in bounded chunks, so a long-running chat does not require loading its lifetime transcript and a partial or oversized final row cannot hide earlier valid messages.
208
209
  - Chat drafts support Unicode-safe Left/Right, Home/End, Backspace, and Delete editing. A single Up/Down recalls persisted user requests and returns to the exact unsent draft; Ctrl+Up/Down remains available, while repeated alternate-scroll arrow bursts continue to scroll chat. Long input stays on one row with the visible window centered around the logical cursor.
209
210
  - Bracketed multiline paste stays in one draft instead of submitting the first line; logical line breaks and tabs appear as `↵` and `⇥` in the single-row input until the complete request is submitted.
210
211
  - Complex requests create a session under `.parallel-codex/sessions/`.
@@ -223,7 +224,7 @@ The doctor output includes `preview:` and `semantic:` ANSI swatch rows so you ca
223
224
  - Parallel Actor, Critic, and revision batches honor `[orchestration].maxParallelFeatures` (default `3`).
224
225
  - Each planned feature gets an isolated Actor implementation workspace, a disposable Critic review clone, worker directories, logs, status, native session ids, and a mailbox under `features/<turn>-<feature>`; the shared dialogue remains in `dialogue/actor-critic.jsonl`. Concurrent text and JSONL appends inside one TUI are serialized per resolved file path, preserving call order without blocking independent feature files; one failed append does not poison later writes.
225
226
  - Feature revision mailboxes are a checked protocol rather than prompt-only convention. A `REVISION_REQUIRED` Critic must write one `{"id":"C-001","severity":"blocker","summary":"..."}` row per blocker to `critic-findings.jsonl`; the revision Actor must write matching `{"finding_id":"C-001","status":"fixed","notes":"..."}` rows to `actor-replies.jsonl`. Missing, malformed, unacknowledged, or contradictory findings stop the task before live integration. The Supervisor snapshots pending, inconsistent, and approved fixed/open state in `finding-resolution.json`; the collaboration timeline retains the original findings and replies as history and uses resolution evidence instead of counting every reply as a fix.
226
- - Parallel workspace isolation works for both Git and non-Git projects. A wave records `baseline`, per-feature `features/` workspaces, refreshable `reviews/` copies, `staging`, and conflict evidence under `sessions/<task>/workspaces/turn-<turn>/wave-<wave>/` while excluding `.git` and the configured runtime data directory.
227
+ - Parallel workspace isolation works for both Git and non-Git projects. A wave records `baseline`, per-feature `features/` workspaces, refreshable `reviews/` copies, `staging`, and conflict evidence under `sessions/<task>/workspaces/turn-<turn>/wave-<wave>/` while excluding `.git`, the configured runtime data directory, and known host metadata (`.DS_Store`, AppleDouble, Spotlight/Trash indexes, `Thumbs.db`, and `desktop.ini`). Source files outside that narrow list still trigger the live-mutation guard.
227
228
  - Feature Critic implementation writes are discarded with the review clone. After an Actor revision, the same Critic native session rechecks a freshly cloned review path; only Actor workspace changes can enter staging.
228
229
  - Approved feature changes are three-way merged into a task-owned integration workspace first; independent edits to the same text file are combined automatically. The live workspace remains unchanged during this stage.
229
230
  - 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.
@@ -236,7 +237,7 @@ The doctor output includes `preview:` and `semantic:` ANSI swatch rows so you ca
236
237
  - Every numbered turn keeps its own immutable Judge, Actor, and Critic worker directories. Later turns use suffixed ids such as `judge-codex-0002`; the log viewer retains earlier workers, cycles them in turn order, and restores the same order after restart. The new worker record may reuse the prior native session id without overwriting the prior turn's prompt, status, log, or native-session metadata.
237
238
  - `Ctrl+N` leaves the active complex task intact on disk while clearing its live context, worker selection, retry state, and status so the next complex request creates an independent task from turn `0001`.
238
239
  - Single-feature follow-ups reuse the same Actor/Critic native sessions when available while moving them to the new turn's isolated workspace; prompts include up to five prior turn summaries as file-backed memory. A failed follow-up retry reuses a complete saved Judge plan, or restores Judge first when the earlier Judge run never produced one.
239
- - 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 `acceptEdits`, even when private command arguments request interactive approval or contain a broader bypass mode. Native attach remains an explicit interactive path.
240
+ - 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.
240
241
  - 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.
241
242
  - 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.
242
243
  - 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.
@@ -363,7 +364,7 @@ args = ["--resume", "{sessionId}"]
363
364
  forkArgs = ["--resume", "{sessionId}", "--fork-session"]
364
365
  ```
365
366
 
366
- `model.args` and `model.env` apply to both automated worker runs and embedded native attach sessions. Native attach appends the rendered model arguments after `interactive.args`, so third-party `{model}` and `{provider}` selections remain active when you press `Ctrl+O`. Codex Router and non-interactive Codex Workers use the root option `-a never` before `exec`, so they cannot stall on an approval UI the outer TUI cannot deliver; Router remains `read-only`, while coding Workers stay inside `workspace-write`. Isolated Codex roles remove broader bypass flags and replace any configured approval mode with this bounded policy. The dangerous `--dangerously-bypass-approvals-and-sandbox` mode is never required. Native Codex attach remains interactive. Claude Main uses `auto` so safe tool calls can complete in print mode; isolated Claude coding roles are still clamped to `acceptEdits`. When a command genuinely needs interactive approval, Main points to `Ctrl+O` instead of claiming that a chat reply can grant permission.
367
+ `model.args` and `model.env` apply to both automated worker runs and embedded native attach sessions. Native attach appends the rendered model arguments after `interactive.args`, so third-party `{model}` and `{provider}` selections remain active when you press `Ctrl+O`. Codex Router and non-interactive Codex Workers use the root option `-a never` before `exec`, so they cannot stall on an approval UI the outer TUI cannot deliver; Router remains `read-only`, while coding Workers stay inside `workspace-write`. Isolated Codex roles remove broader bypass flags and replace any configured approval mode with this bounded policy. The dangerous `--dangerously-bypass-approvals-and-sandbox` mode is never required. Native Codex attach remains interactive. Claude automated roles use `auto`, allowing safe test and edit commands while retaining Claude's permission checks; commands that still require interactive approval must be continued through `Ctrl+O` instead of being falsely approved in chat.
367
368
 
368
369
  `model.provider` names the remote model service; `capabilities.profile` describes the local CLI protocol. Worker Provider IDs are lowercase names using letters, digits, or `_`; excluding `-` keeps generated Worker directory names unambiguous. Existing `codex`, `claude`, and `mock` IDs remain compatible, while additional profiles can inherit `codex`, `claude`, another named profile, or conservative `generic` defaults.
369
370
 
@@ -450,9 +451,9 @@ instructions = ["Implement small verified changes.", "Record decisions in worklo
450
451
 
451
452
  Keep `[router.codex]` on `read-only`; routing only classifies requests and does not need project writes. Automated Judge/Actor/Critic runs also clamp unsafe Codex or Claude permission flags to their isolated workspace policy, so a private `danger-full-access` or bypass flag cannot silently defeat live-workspace isolation. Use native attach for deliberate interactive host-level work such as Docker or OrbStack access.
452
453
 
453
- 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.
454
+ 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.
454
455
 
455
- 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 the current session and start a new task, `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, selected Provider/model, phase, activity timestamp, and native session. 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 new task, and `Esc` to return to chat.
456
+ 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 the current session and start a new task, `Ctrl+W` to open worker logs, and `Tab` to cycle the selected worker when workers exist. `Ctrl+S` opens the same status detail view from chat, logs, Worker overview, or an embedded native session; it keeps the footer compact while exposing the complete route, reason, active Main/Judge/Actor/Critic Provider map, selected historical Provider/model, phase, activity timestamp, native session, and config effective/restart state. Press `Ctrl+S` or `Esc` to return without interrupting the underlying Worker. While a request is running, press `Esc` to stop it. After a failed or cancelled task, press `Ctrl+R` to retry the same turn or `Ctrl+N` to start independently. In worker-log views, scroll with the mouse wheel, Up/Down, or PageUp/PageDown, press `Tab` to cycle workers, `Ctrl+N` to start a new task, and `Esc` to return to chat.
456
457
 
457
458
  `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.
458
459
 
@@ -516,12 +517,12 @@ The release job installs npm `^11.5.1`, runs on Node `24.15.x`, publishes the pr
516
517
  To publish a release, update `package.json` and `src/version.ts` to the same version, then push a matching tag:
517
518
 
518
519
  ```bash
519
- VERSION=0.2.4
520
+ VERSION=0.2.5
520
521
  git tag "v$VERSION"
521
522
  git push origin "v$VERSION"
522
523
  ```
523
524
 
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.4` requires tag `v0.2.4`. Published tags such as `v0.2.3` are immutable and must not be moved or reused.
525
+ 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.5` requires tag `v0.2.5`. Published tags such as `v0.2.4` are immutable and must not be moved or reused.
525
526
 
526
527
  ## Publishing Hygiene
527
528
 
package/dist/cli.js CHANGED
@@ -179,7 +179,7 @@ async function main() {
179
179
  }, persistChatMessage: (message, taskId) => state.runtime.sessions.appendChatMessage({
180
180
  ...message,
181
181
  taskId
182
- }) }, state.runtime.workspaceRoot));
182
+ }), reloadConfig: async () => withUiThemeOverride(await loadConfig(cliArgs.appRoot), cliArgs.theme) }, state.runtime.workspaceRoot));
183
183
  const removeSigintHandler = installInteractiveSigintExitHandler(() => shutdownController.abort());
184
184
  enterInteractiveTerminal();
185
185
  try {
@@ -4,6 +4,7 @@ import { join } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { EngineNameSchema } from "../domain/schemas.js";
6
6
  import { pathExists, readTextIfExists, writeText } from "./file-store.js";
7
+ import { workerBuffersOutputUntilCompletion } from "./worker-output-policy.js";
7
8
  import { normalizeTuiThemeColorValue, normalizeTuiThemeName, TUI_THEME_FIELDS, TUI_THEME_NAMES } from "../tui/theme.js";
8
9
  const NativeSessionConfigSchema = z.object({
9
10
  enabled: z.boolean().default(true),
@@ -220,7 +221,7 @@ export function defaultConfig(projectRoot) {
220
221
  assignable: true,
221
222
  timeoutMs: 45 * 60 * 1000,
222
223
  idleTimeoutMs: 5 * 60 * 1000,
223
- firstOutputTimeoutMs: 2 * 60 * 1000,
224
+ firstOutputTimeoutMs: 45 * 60 * 1000,
224
225
  model: {
225
226
  name: "",
226
227
  provider: "",
@@ -273,10 +274,10 @@ export function defaultConfig(projectRoot) {
273
274
  }
274
275
  },
275
276
  pairing: {
276
- main: "claude",
277
+ main: "codex",
277
278
  judge: "codex",
278
279
  actor: "codex",
279
- critic: "codex"
280
+ critic: "claude"
280
281
  },
281
282
  roles: {
282
283
  main: {
@@ -404,7 +405,7 @@ function resolveWorkerConfigs(builtins, configured) {
404
405
  parent = resolve(parentId);
405
406
  }
406
407
  }
407
- const worker = mergeWorkerConfig(parent, override);
408
+ const worker = normalizeBufferedWorkerTimeout(mergeWorkerConfig(parent, override));
408
409
  resolved.set(id, worker);
409
410
  return worker;
410
411
  }
@@ -476,6 +477,20 @@ function mergeWorkerConfig(base, override) {
476
477
  }
477
478
  };
478
479
  }
480
+ function normalizeBufferedWorkerTimeout(worker) {
481
+ const launchArgs = [
482
+ worker.args,
483
+ ...(worker.nativeSession.enabled ? [worker.nativeSession.resumeArgs] : [])
484
+ ];
485
+ const buffersOutput = launchArgs.some((args) => (workerBuffersOutputUntilCompletion(worker.capabilities.profile, args)));
486
+ if (!buffersOutput || !worker.timeoutMs || worker.firstOutputTimeoutMs === worker.timeoutMs) {
487
+ return worker;
488
+ }
489
+ return {
490
+ ...worker,
491
+ firstOutputTimeoutMs: worker.timeoutMs
492
+ };
493
+ }
479
494
  export function withUiThemeOverride(config, theme) {
480
495
  if (!theme) {
481
496
  return config;
@@ -0,0 +1,78 @@
1
+ const ANSI_CSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
2
+ const ANSI_OSC_PATTERN = /\x1b\][^\x07]*(?:\x07|\x1b\\)/g;
3
+ const CODEX_HEADER_PATTERN = /^OpenAI Codex v\S+/;
4
+ const CODEX_COMMAND_PATTERN = /^\$\s+.*\bcodex(?:\s|$)/i;
5
+ const TOKEN_COUNT_PATTERN = /^[\d][\d,._ ]*$/;
6
+ /**
7
+ * Keeps process transcripts in output.log while returning only the response that
8
+ * belongs in chat. Codex writes its UI transcript to stderr and repeats the final
9
+ * response on stdout, so the cleanest answer normally follows "tokens used".
10
+ */
11
+ export function extractMainResponse(outputLog) {
12
+ const normalized = stripTerminalControls(outputLog).replace(/\r\n?/g, "\n");
13
+ const lines = normalized.split("\n");
14
+ const isCodexTranscript = linesAreCodexTranscript(lines);
15
+ if (isCodexTranscript) {
16
+ const extracted = extractCodexFinalResponse(lines);
17
+ if (extracted) {
18
+ return extracted;
19
+ }
20
+ }
21
+ return lines
22
+ .filter((line) => !line.startsWith("$ "))
23
+ .filter((line) => !line.startsWith("[mock:main]"))
24
+ .join("\n")
25
+ .trim();
26
+ }
27
+ export function sanitizePersistedMainMessage(from, text) {
28
+ if (from !== "system" || !linesAreCodexTranscript(stripTerminalControls(text).split(/\r?\n/))) {
29
+ return text;
30
+ }
31
+ return extractMainResponse(text);
32
+ }
33
+ function linesAreCodexTranscript(lines) {
34
+ return lines.some((line) => CODEX_HEADER_PATTERN.test(line.trim()))
35
+ || lines.some((line) => CODEX_COMMAND_PATTERN.test(line.trim()));
36
+ }
37
+ function extractCodexFinalResponse(lines) {
38
+ const tokensUsedIndex = findLastLine(lines, (line) => line.trim().toLowerCase() === "tokens used");
39
+ if (tokensUsedIndex >= 0) {
40
+ const trailing = trimBlankLines(lines.slice(tokensUsedIndex + 1));
41
+ if (trailing[0] && TOKEN_COUNT_PATTERN.test(trailing[0].trim())) {
42
+ trailing.shift();
43
+ }
44
+ const stdoutResponse = trimBlankLines(trailing).join("\n").trim();
45
+ if (stdoutResponse) {
46
+ return stdoutResponse;
47
+ }
48
+ const assistantIndex = findLastLine(lines.slice(0, tokensUsedIndex), (line) => line.trim().toLowerCase() === "codex");
49
+ if (assistantIndex >= 0) {
50
+ return trimBlankLines(lines.slice(assistantIndex + 1, tokensUsedIndex)).join("\n").trim();
51
+ }
52
+ }
53
+ return "";
54
+ }
55
+ function findLastLine(lines, predicate) {
56
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
57
+ if (predicate(lines[index] ?? "")) {
58
+ return index;
59
+ }
60
+ }
61
+ return -1;
62
+ }
63
+ function trimBlankLines(lines) {
64
+ let start = 0;
65
+ let end = lines.length;
66
+ while (start < end && !(lines[start] ?? "").trim()) {
67
+ start += 1;
68
+ }
69
+ while (end > start && !(lines[end - 1] ?? "").trim()) {
70
+ end -= 1;
71
+ }
72
+ return lines.slice(start, end);
73
+ }
74
+ function stripTerminalControls(text) {
75
+ return text
76
+ .replace(ANSI_CSI_PATTERN, "")
77
+ .replace(ANSI_OSC_PATTERN, "");
78
+ }
@@ -4,6 +4,7 @@ import { basename, dirname, join } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { appendJsonLine, appendText, ensureDir, pathExists, readJson, readRecentJsonLines, readTextIfExists, removeIfExists, writeJson, writeText } from "./file-store.js";
6
6
  import { runWithLeaseFinalization } from "./lease-finalization.js";
7
+ import { sanitizePersistedMainMessage } from "./main-response.js";
7
8
  import { formatTaskTimestamp, taskDir, taskSessionIdIsValid } from "./paths.js";
8
9
  import { sessionsRoot } from "./paths.js";
9
10
  import { loadCollaborationTimeline } from "./collaboration-timeline.js";
@@ -212,7 +213,11 @@ export class SessionManager {
212
213
  const boundedLimit = Number.isFinite(limit)
213
214
  ? Math.min(1000, Math.max(0, Math.trunc(limit)))
214
215
  : 200;
215
- return readRecentJsonLines(join(this.mainSessionDir(), "chat.jsonl"), ChatRecordSchema, boundedLimit);
216
+ const records = await readRecentJsonLines(join(this.mainSessionDir(), "chat.jsonl"), ChatRecordSchema, boundedLimit);
217
+ return records.map((record) => ({
218
+ ...record,
219
+ text: sanitizePersistedMainMessage(record.from, record.text)
220
+ }));
216
221
  }
217
222
  taskFromId(taskId) {
218
223
  const dir = taskDir(this.projectRoot, this.dataDir, taskId);
@@ -0,0 +1,19 @@
1
+ export function workerBuffersOutputUntilCompletion(profile, args) {
2
+ if (profile !== "claude" || !args.some((arg) => arg === "--print" || arg === "-p")) {
3
+ return false;
4
+ }
5
+ const outputFormat = readOptionValue(args, "--output-format");
6
+ return outputFormat === undefined || outputFormat === "text" || outputFormat === "json";
7
+ }
8
+ function readOptionValue(args, option) {
9
+ for (let index = 0; index < args.length; index += 1) {
10
+ const arg = args[index];
11
+ if (arg === option) {
12
+ return args[index + 1]?.trim().toLowerCase();
13
+ }
14
+ if (arg?.startsWith(`${option}=`)) {
15
+ return arg.slice(option.length + 1).trim().toLowerCase();
16
+ }
17
+ }
18
+ return undefined;
19
+ }
@@ -2,6 +2,7 @@ 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";
5
+ import { extractMainResponse } from "../core/main-response.js";
5
6
  import { claimTaskRunLease, TaskRunLeaseConflictError } from "../core/process-ownership.js";
6
7
  import { routerRuntimeDir } from "../core/paths.js";
7
8
  import { classifyRouterFailure, routerFallbackIsTransient } from "../core/router-audit.js";
@@ -2235,14 +2236,6 @@ async function recoverWorkerFileFromWorkspace(sourcePath, targetPath) {
2235
2236
  }
2236
2237
  await mirrorWorkerFileToFeature(sourcePath, targetPath);
2237
2238
  }
2238
- function extractMainResponse(outputLog) {
2239
- return outputLog
2240
- .split("\n")
2241
- .filter((line) => !line.startsWith("$ "))
2242
- .filter((line) => !line.startsWith("[mock:main]"))
2243
- .join("\n")
2244
- .trim();
2245
- }
2246
2239
  function emptyMainResponseSummary() {
2247
2240
  return "简单对话通道没有收到可显示回复。";
2248
2241
  }
@@ -7,6 +7,8 @@ const MAX_TEXT_MERGE_BYTES = 10 * 1024 * 1024;
7
7
  const FEATURE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
8
8
  const COMMIT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/;
9
9
  const LIVE_COMMIT_PROTOCOL = "atomic-claim-v1";
10
+ const HOST_METADATA_FILES = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
11
+ const HOST_METADATA_DIRECTORIES = new Set([".Spotlight-V100", ".Trashes", ".fseventsd"]);
10
12
  export class WorkspaceMergeConflictError extends Error {
11
13
  paths;
12
14
  conflictDir;
@@ -405,10 +407,18 @@ export class ParallelWorkspaceManager {
405
407
  if (segments.includes(".git")) {
406
408
  return true;
407
409
  }
410
+ if (isHostMetadataPath(segments)) {
411
+ return true;
412
+ }
408
413
  return Boolean(this.dataRelativePath
409
414
  && (path === this.dataRelativePath || path.startsWith(`${this.dataRelativePath}${sep}`)));
410
415
  }
411
416
  }
417
+ function isHostMetadataPath(segments) {
418
+ return segments.some((segment) => (HOST_METADATA_FILES.has(segment)
419
+ || HOST_METADATA_DIRECTORIES.has(segment)
420
+ || segment.startsWith("._")));
421
+ }
412
422
  async function readJsonRecord(path) {
413
423
  try {
414
424
  const value = JSON.parse(await readFile(path, "utf8"));
package/dist/tui/App.js CHANGED
@@ -34,6 +34,7 @@ import { latestTaskResultMessageIndex, parseTaskResultSummary } from "./task-res
34
34
  import { buildNativeAttachLaunch, buildNativeForkLaunch, startNativeAttachProcess } from "../workers/native-attach.js";
35
35
  import { assignableWorkerProviderIds, workerProviderLabel, workerProviders } from "../workers/provider.js";
36
36
  import { StatusDetailView } from "./StatusDetailView.js";
37
+ import { runtimeConfigChange } from "./runtime-config-state.js";
37
38
  export function routeDecisionChatMessage(route) {
38
39
  return `route · ${route.mode} · ${route.source ?? "router"}\n${route.reason.trim()}`;
39
40
  }
@@ -48,7 +49,7 @@ const EMPTY_WORKER_NAVIGATION_TARGETS = {
48
49
  errorOffsets: [],
49
50
  diffOffsets: []
50
51
  };
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
+ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRoute = null, initialWorkers, initialCanRetryTask = false, initialMessages = [], persistChatMessage, reloadConfig, workspaceChoices = [], switchWorkspace, loadRouterDiagnostics, loadTaskSessions, loadTaskSessionDetails, renameTaskSession, setTaskSessionArchived, deleteTaskSession, exportTaskSession, exportDiagnostics, loadCollaborationTimeline, activateTaskSession, prepareNativeAttach, prepareNativeFork, startNativeAttach, copyToClipboard = copyTextToClipboard, shutdownSignal }) {
52
53
  configureTuiTheme({
53
54
  theme: config.ui.theme,
54
55
  colors: config.ui.colors
@@ -74,6 +75,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
74
75
  const [activeMode, setActiveMode] = useState(initialTaskId ? "complex" : null);
75
76
  const [canRetryTask, setCanRetryTask] = useState(initialCanRetryTask);
76
77
  const [attachError, setAttachError] = useState(null);
78
+ const [configChange, setConfigChange] = useState(null);
77
79
  const [clipboardNotice, setClipboardNotice] = useState(null);
78
80
  const [nativeInput, setNativeInput] = useState("");
79
81
  const [workerScrollOffset, setWorkerScrollOffset] = useState(0);
@@ -250,6 +252,43 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
250
252
  process.stdout.off("resize", updateTerminalSize);
251
253
  };
252
254
  }, []);
255
+ useEffect(() => {
256
+ if (!reloadConfig) {
257
+ return;
258
+ }
259
+ let active = true;
260
+ let loading = false;
261
+ const refresh = async () => {
262
+ if (loading) {
263
+ return;
264
+ }
265
+ loading = true;
266
+ try {
267
+ const latest = await reloadConfig();
268
+ if (active) {
269
+ setConfigChange(runtimeConfigChange(config, latest));
270
+ }
271
+ }
272
+ catch (error) {
273
+ if (active) {
274
+ setConfigChange({
275
+ kind: "restart",
276
+ detail: `config · invalid · ${error instanceof Error ? error.message : String(error)}`,
277
+ compact: "config invalid"
278
+ });
279
+ }
280
+ }
281
+ finally {
282
+ loading = false;
283
+ }
284
+ };
285
+ void refresh();
286
+ const interval = setInterval(() => void refresh(), 1500);
287
+ return () => {
288
+ active = false;
289
+ clearInterval(interval);
290
+ };
291
+ }, [config, reloadConfig]);
253
292
  useEffect(() => {
254
293
  inputCursorRef.current = inputCursor;
255
294
  }, [inputCursor]);
@@ -2689,7 +2728,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2689
2728
  if (view === "workspace") {
2690
2729
  return (_jsx(Box, { flexDirection: "column", height: terminalSize.rows, children: _jsx(WorkspacePicker, { cwd: cwd, choices: workspaceChoices, terminalHeight: terminalSize.rows, terminalWidth: terminalWidth, onCancel: closeWorkspacePicker, onSelect: (workspace) => void selectWorkspace(workspace) }) }));
2691
2730
  }
2692
- return (_jsx(AppShell, { view: view, cwd: cwd, taskId: activeTaskId, statusText: [visibleTaskStatus, visibleRouteSummary].filter(Boolean).join(" | "), contentHeight: contentHeight, showStatusBar: config.ui.showStatusBar, input: _jsx(InputBar, { mode: view === "worker" && workerSearch.open ? "worker-search" : view, ready: inputReady, busy: busy, routeFallback: Boolean(routeFallbackPrompt), collaborationDetail: collaborationDetailOpen, collaborationUnresolved: collaborationUnresolvedOnly, collaborationBack: collaborationReturnViewRef.current, featureCanCancel: featureCanCancel, featureCanPause: featureCanCancel, featureCanReassign: featureCanReassign, featureCancelConfirm: featureCancelPrompt?.action === "cancel", featurePauseConfirm: featureCancelPrompt?.action === "pause", featureAssignment: Boolean(featureAssignmentPrompt), taskSessionAction: taskSessionAction?.type === "rename"
2731
+ return (_jsx(AppShell, { view: view, cwd: cwd, taskId: activeTaskId, statusText: [visibleTaskStatus, visibleRouteSummary, configChange?.compact].filter(Boolean).join(" | "), contentHeight: contentHeight, showStatusBar: config.ui.showStatusBar, input: _jsx(InputBar, { mode: view === "worker" && workerSearch.open ? "worker-search" : view, ready: inputReady, busy: busy, routeFallback: Boolean(routeFallbackPrompt), collaborationDetail: collaborationDetailOpen, collaborationUnresolved: collaborationUnresolvedOnly, collaborationBack: collaborationReturnViewRef.current, featureCanCancel: featureCanCancel, featureCanPause: featureCanCancel, featureCanReassign: featureCanReassign, featureCancelConfirm: featureCancelPrompt?.action === "cancel", featurePauseConfirm: featureCancelPrompt?.action === "pause", featureAssignment: Boolean(featureAssignmentPrompt), taskSessionAction: taskSessionAction?.type === "rename"
2693
2732
  ? { type: "rename", value: taskSessionAction.value, cursor: taskSessionAction.cursor }
2694
2733
  : taskSessionAction?.type === "delete"
2695
2734
  ? { type: "delete", title: taskSessionAction.title }
@@ -2697,7 +2736,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2697
2736
  ? workerSearch.query
2698
2737
  : view === "native" || view === "router" || view === "workers" || view === "features" || view === "sessions" || view === "collaboration" || view === "status" ? "" : input, cursor: workerSearch.open && view === "worker"
2699
2738
  ? workerSearch.cursor
2700
- : view === "chat" ? inputCursor : undefined, terminalWidth: terminalWidth, onChange: view === "native" ? setNativeInput : setInput, onSubmit: view === "native" ? undefined : submit }), error: attachError, children: view === "status" ? (_jsx(StatusDetailView, { cwd: cwd, taskId: activeTaskId, mode: activeMode, busy: busy, canRetry: canRetryTask, taskStatus: visibleTaskStatus, routeStatus: visibleRouteStatus, routeReason: routePending ? undefined : lastRoute?.reason, workers: workers, selectedWorkerIndex: selectedWorkerIndex, height: contentHeight, terminalWidth: terminalWidth })) : view === "native" ? (_jsx(NativeAttachView, { attach: nativeAttach, viewportHeight: contentHeight })) : view === "sessions" ? (taskSessionDetails ? (_jsx(TaskSessionDetailView, { details: taskSessionDetails, selectedWorkerIndex: taskSessionDetailSelectedWorkerIndex, loading: taskSessionsLoading, error: taskSessionsError, notice: taskSessionDetailNotice, height: contentHeight, terminalWidth: terminalWidth })) : (_jsx(TaskSessionsView, { tasks: taskSessions, activeTaskId: activeTaskId, selectedIndex: selectedTaskSessionIndex, includeArchived: taskSessionsIncludeArchived, notice: taskSessionsNotice, action: taskSessionAction?.type === "rename"
2739
+ : view === "chat" ? inputCursor : undefined, terminalWidth: terminalWidth, onChange: view === "native" ? setNativeInput : setInput, onSubmit: view === "native" ? undefined : submit }), error: attachError, children: view === "status" ? (_jsx(StatusDetailView, { cwd: cwd, taskId: activeTaskId, mode: activeMode, busy: busy, canRetry: canRetryTask, taskStatus: visibleTaskStatus, routeStatus: visibleRouteStatus, routeReason: routePending ? undefined : lastRoute?.reason, pairing: config.pairing, configStatus: configChange?.detail, configRestartRequired: configChange?.kind === "restart", workers: workers, selectedWorkerIndex: selectedWorkerIndex, height: contentHeight, terminalWidth: terminalWidth })) : view === "native" ? (_jsx(NativeAttachView, { attach: nativeAttach, viewportHeight: contentHeight })) : view === "sessions" ? (taskSessionDetails ? (_jsx(TaskSessionDetailView, { details: taskSessionDetails, selectedWorkerIndex: taskSessionDetailSelectedWorkerIndex, loading: taskSessionsLoading, error: taskSessionsError, notice: taskSessionDetailNotice, height: contentHeight, terminalWidth: terminalWidth })) : (_jsx(TaskSessionsView, { tasks: taskSessions, activeTaskId: activeTaskId, selectedIndex: selectedTaskSessionIndex, includeArchived: taskSessionsIncludeArchived, notice: taskSessionsNotice, action: taskSessionAction?.type === "rename"
2701
2740
  ? { type: "rename", title: taskSessionAction.title }
2702
2741
  : taskSessionAction?.type === "delete"
2703
2742
  ? { type: "delete", title: taskSessionAction.title }
@@ -275,18 +275,24 @@ function currentStatusValueCandidates(text) {
275
275
  ].filter(Boolean)));
276
276
  }
277
277
  function compactCurrentStatusDetail(detail) {
278
+ if (/^working\s+·\s+buffered$/i.test(detail)) {
279
+ return "run buffered";
280
+ }
278
281
  const progress = detail.match(/^(waiting output|responding)\s+·\s+(\d+(?:\.\d+)?s)\s*\/\s*(\d+(?:\.\d+)?(?:ms|s|m))\s+(?:first|idle)$/i);
279
282
  if (progress) {
280
283
  const state = progress[1]?.toLowerCase() === "responding" ? "reply" : "wait";
281
284
  return `${state} ${progress[2]}/${progress[3]}`;
282
285
  }
283
- const state = detail.match(/^(starting|running|run|done|failed|fail|waiting|wait|queued|stopping|stop|idle|cancelled|canceled)\b/i)?.[1]?.toLowerCase();
286
+ const state = detail.match(/^(starting|working|running|run|done|failed|fail|waiting|wait|queued|stopping|stop|idle|cancelled|canceled)\b/i)?.[1]?.toLowerCase();
284
287
  if (state === "starting") {
285
288
  return "start";
286
289
  }
287
290
  if (state === "running") {
288
291
  return "run";
289
292
  }
293
+ if (state === "working") {
294
+ return "run";
295
+ }
290
296
  if (state === "failed") {
291
297
  return "fail";
292
298
  }
@@ -763,7 +769,7 @@ function parseMainEngineStatus(part) {
763
769
  };
764
770
  }
765
771
  function runtimeStatusTone(status) {
766
- if (status === "run" || status === "running" || status === "starting" || status === "responding") {
772
+ if (status === "run" || status === "running" || status === "working" || status === "starting" || status === "responding") {
767
773
  return "run";
768
774
  }
769
775
  if (status === "done") {
@@ -23,8 +23,18 @@ export function statusDetailDisplayLines(input, width, height) {
23
23
  {
24
24
  text: fitStatusDetailText(`workspace · ${basename(input.cwd) || input.cwd} · ${input.cwd}`, safeWidth),
25
25
  tone: "muted"
26
+ },
27
+ {
28
+ text: fitStatusDetailText(rolePairingDetail(input.pairing), safeWidth),
29
+ tone: "text"
26
30
  }
27
31
  ];
32
+ if (input.configStatus?.trim()) {
33
+ lines.push({
34
+ text: fitStatusDetailText(input.configStatus, safeWidth),
35
+ tone: input.configRestartRequired ? "warning" : "success"
36
+ });
37
+ }
28
38
  if (input.routeStatus.trim()) {
29
39
  lines.push({
30
40
  text: fitStatusDetailText(input.routeStatus.trim(), safeWidth),
@@ -49,6 +59,15 @@ export function statusDetailDisplayLines(input, width, height) {
49
59
  }
50
60
  return lines.slice(0, maxLines);
51
61
  }
62
+ function rolePairingDetail(pairing) {
63
+ return [
64
+ "active roles",
65
+ `main/${pairing.main}`,
66
+ `judge/${pairing.judge}`,
67
+ `actor/${pairing.actor}`,
68
+ `critic/${pairing.critic}`
69
+ ].join(" · ");
70
+ }
52
71
  function taskDetail(input) {
53
72
  if (!input.taskId) {
54
73
  return "task · none";
@@ -80,6 +80,12 @@ export function workerOverviewActivityLine(worker, nowMs, policy) {
80
80
  tone: "warning"
81
81
  };
82
82
  }
83
+ if (status.phase === "process-buffered") {
84
+ return {
85
+ text: `activity · working ${formatWorkerActivityDuration(ageMs)} · output buffered until completion`,
86
+ tone: "muted"
87
+ };
88
+ }
83
89
  const starting = status.state === "starting";
84
90
  const limitMs = effectiveWorkerWatchdog(starting ? policy.firstOutputTimeoutMs : policy.idleTimeoutMs, policy.timeoutMs);
85
91
  const activity = starting ? "started" : "output";
@@ -0,0 +1,21 @@
1
+ export function runtimeConfigChange(active, latest) {
2
+ if (runtimeConfigFingerprint(active) !== runtimeConfigFingerprint(latest)) {
3
+ return {
4
+ kind: "restart",
5
+ detail: "config · roles/workers changed · restart required",
6
+ compact: "config restart"
7
+ };
8
+ }
9
+ if (JSON.stringify(active.router) !== JSON.stringify(latest.router)) {
10
+ return {
11
+ kind: "router",
12
+ detail: "config · router changed · active on next request",
13
+ compact: "config router live"
14
+ };
15
+ }
16
+ return null;
17
+ }
18
+ function runtimeConfigFingerprint(config) {
19
+ const { router: _router, ...runtime } = config;
20
+ return JSON.stringify(runtime);
21
+ }
@@ -45,6 +45,9 @@ function formatMainStatus(status, progress) {
45
45
  if (progress.phase === "initialized") {
46
46
  return "starting";
47
47
  }
48
+ if (progress.phase === "process-buffered") {
49
+ return "working · buffered";
50
+ }
48
51
  if (progress.phase === "process-starting" || progress.phase === "native-resume-fallback") {
49
52
  return formatMainWaitProgress("waiting output", progress.elapsedMs, progress.firstOutputTimeoutMs, "first");
50
53
  }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "0.2.4";
1
+ export const version = "0.2.5";
@@ -4,6 +4,7 @@ import { StringDecoder } from "node:string_decoder";
4
4
  import { appendText, writeJson } from "../core/file-store.js";
5
5
  import { clearWorkerProcessRecord, writeWorkerProcessRecord } from "../core/process-ownership.js";
6
6
  import { terminateProcessTree } from "../core/process-tree.js";
7
+ import { workerBuffersOutputUntilCompletion } from "../core/worker-output-policy.js";
7
8
  import { detectNativeSessionId } from "./native-session-detection.js";
8
9
  const WORKER_DIAGNOSTIC_TAIL_CHARS = 64 * 1024;
9
10
  const RESUME_DETECTION_OVERLAP_CHARS = 512;
@@ -32,7 +33,8 @@ export class ProcessWorkerAdapter {
32
33
  modelConfig: model
33
34
  };
34
35
  const first = await this.runAttempt(runSpec, launch, {
35
- initialNativeSessionId: launch.initialNativeSessionId
36
+ initialNativeSessionId: launch.initialNativeSessionId,
37
+ outputBufferedUntilCompletion: workerBuffersOutputUntilCompletion(capabilities.profile, launch.args)
36
38
  });
37
39
  if (!shouldFallbackToNewNativeSession(first, runSpec.nativeSessionConfig)) {
38
40
  return first.result;
@@ -49,7 +51,8 @@ export class ProcessWorkerAdapter {
49
51
  }, freshLaunch, {
50
52
  initialNativeSessionId: freshLaunch.initialNativeSessionId,
51
53
  startPhase: "native-resume-fallback",
52
- startSummary: `${this.command} starting fresh session after unrecoverable native resume`
54
+ startSummary: `${this.command} starting fresh session after unrecoverable native resume`,
55
+ outputBufferedUntilCompletion: workerBuffersOutputUntilCompletion(capabilities.profile, freshLaunch.args)
53
56
  })).result;
54
57
  }
55
58
  async runAttempt(runSpec, launch, options = {}) {
@@ -64,7 +67,13 @@ export class ProcessWorkerAdapter {
64
67
  await appendText(runSpec.outputLogPath, "Process cancelled by user before start\n");
65
68
  return { result, output: "", launch };
66
69
  }
67
- await setStatus(runSpec, "starting", options.startPhase ?? "process-starting", options.startSummary ?? `Starting ${this.command}`);
70
+ const startPhase = options.outputBufferedUntilCompletion
71
+ ? "process-buffered"
72
+ : options.startPhase ?? "process-starting";
73
+ const startSummary = options.startSummary ?? `Starting ${this.command}`;
74
+ await setStatus(runSpec, "starting", startPhase, options.outputBufferedUntilCompletion
75
+ ? `${startSummary}; text output is buffered until completion`
76
+ : startSummary);
68
77
  await appendText(runSpec.outputLogPath, `$ ${formatShellCommand(this.command, launch.args)}\n`);
69
78
  return new Promise((resolve, reject) => {
70
79
  const detached = process.platform !== "win32";
@@ -456,7 +465,9 @@ export class ProcessWorkerAdapter {
456
465
  failAndTerminate("process-timeout", `${this.command} exceeded ${runSpec.timeoutMs}ms`, `\nProcess timed out after ${runSpec.timeoutMs}ms\n`);
457
466
  }, runSpec.timeoutMs);
458
467
  }
459
- if (runSpec.firstOutputTimeoutMs
468
+ if (!options.outputBufferedUntilCompletion
469
+ &&
470
+ runSpec.firstOutputTimeoutMs
460
471
  && runSpec.firstOutputTimeoutMs > 0
461
472
  && (!runSpec.timeoutMs || runSpec.timeoutMs <= 0 || runSpec.firstOutputTimeoutMs < runSpec.timeoutMs)) {
462
473
  firstOutputTimeout = setTimeout(() => {
@@ -540,7 +551,7 @@ function enforceWorkerIsolationArgs(args, profile, enforce) {
540
551
  return enforceCodexWorkspaceSandbox(args);
541
552
  }
542
553
  if (profile === "claude") {
543
- return enforceClaudeEditPermissions(args);
554
+ return enforceClaudeAutoPermissions(args);
544
555
  }
545
556
  return args;
546
557
  }
@@ -586,7 +597,7 @@ function enforceCodexWorkspaceSandbox(args) {
586
597
  }
587
598
  return result;
588
599
  }
589
- function enforceClaudeEditPermissions(args) {
600
+ function enforceClaudeAutoPermissions(args) {
590
601
  const result = [];
591
602
  let hasPermissionMode = false;
592
603
  for (let index = 0; index < args.length; index += 1) {
@@ -595,20 +606,20 @@ function enforceClaudeEditPermissions(args) {
595
606
  continue;
596
607
  }
597
608
  if (arg === "--permission-mode") {
598
- result.push(arg, "acceptEdits");
609
+ result.push(arg, "auto");
599
610
  hasPermissionMode = true;
600
611
  index += 1;
601
612
  continue;
602
613
  }
603
614
  if (arg.startsWith("--permission-mode=")) {
604
- result.push("--permission-mode=acceptEdits");
615
+ result.push("--permission-mode=auto");
605
616
  hasPermissionMode = true;
606
617
  continue;
607
618
  }
608
619
  result.push(arg);
609
620
  }
610
621
  if (!hasPermissionMode) {
611
- result.push("--permission-mode", "acceptEdits");
622
+ result.push("--permission-mode", "auto");
612
623
  }
613
624
  return result;
614
625
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parallel-codex-tui",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "A TypeScript TUI wrapper for routed parallel coding with Codex and Claude workers.",
5
5
  "license": "MIT",
6
6
  "type": "module",