parallel-codex-tui 0.1.3 → 0.1.4

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.
Files changed (72) hide show
  1. package/.parallel-codex/config.example.toml +90 -3
  2. package/README.md +240 -21
  3. package/dist/bootstrap.js +37 -17
  4. package/dist/cli-args.js +34 -3
  5. package/dist/cli-help.js +20 -0
  6. package/dist/cli-startup-recovery.js +70 -0
  7. package/dist/cli-workspace-picker.js +330 -0
  8. package/dist/cli-workspace-transition.js +33 -0
  9. package/dist/cli-workspace.js +7 -71
  10. package/dist/cli.js +221 -23
  11. package/dist/core/collaboration-timeline.js +261 -0
  12. package/dist/core/config-errors.js +14 -0
  13. package/dist/core/config.js +154 -24
  14. package/dist/core/file-store.js +119 -6
  15. package/dist/core/lease-finalization.js +22 -0
  16. package/dist/core/paths.js +7 -0
  17. package/dist/core/process-identity.js +48 -0
  18. package/dist/core/process-mutation-turn.js +128 -0
  19. package/dist/core/process-ownership.js +276 -0
  20. package/dist/core/process-tree.js +90 -0
  21. package/dist/core/router-audit.js +155 -0
  22. package/dist/core/router-redaction.js +31 -0
  23. package/dist/core/router.js +462 -35
  24. package/dist/core/session-index.js +188 -37
  25. package/dist/core/session-manager.js +1086 -40
  26. package/dist/core/task-state-machine.js +17 -0
  27. package/dist/core/workspace-commit-recovery.js +118 -0
  28. package/dist/core/workspace.js +19 -11
  29. package/dist/doctor.js +343 -23
  30. package/dist/domain/schemas.js +127 -6
  31. package/dist/orchestrator/collaboration-channel.js +255 -4
  32. package/dist/orchestrator/feature-plan.js +70 -0
  33. package/dist/orchestrator/judge-artifacts.js +236 -0
  34. package/dist/orchestrator/orchestrator.js +1749 -202
  35. package/dist/orchestrator/prompts.js +126 -2
  36. package/dist/orchestrator/supervisor-summary.js +56 -2
  37. package/dist/orchestrator/workspace-sandbox.js +911 -0
  38. package/dist/tui/App.js +2830 -153
  39. package/dist/tui/AppShell.js +188 -23
  40. package/dist/tui/CollaborationTimelineView.js +327 -0
  41. package/dist/tui/FeatureBoardView.js +227 -0
  42. package/dist/tui/InputBar.js +514 -57
  43. package/dist/tui/RouterDiagnosticsView.js +469 -0
  44. package/dist/tui/StatusBar.js +610 -57
  45. package/dist/tui/TaskSessionsView.js +207 -0
  46. package/dist/tui/TerminalOutput.js +53 -9
  47. package/dist/tui/WorkerOutputView.js +1403 -161
  48. package/dist/tui/WorkerOverviewView.js +250 -0
  49. package/dist/tui/chat-history.js +25 -0
  50. package/dist/tui/chat-input.js +67 -19
  51. package/dist/tui/chat-paste.js +76 -0
  52. package/dist/tui/display-width.js +41 -3
  53. package/dist/tui/incremental-text-file.js +101 -0
  54. package/dist/tui/keyboard.js +46 -0
  55. package/dist/tui/markdown-text.js +14 -0
  56. package/dist/tui/raw-input-decoder.js +3 -0
  57. package/dist/tui/scrolling.js +2 -1
  58. package/dist/tui/status-line.js +318 -11
  59. package/dist/tui/task-memory.js +13 -1
  60. package/dist/tui/task-result.js +105 -0
  61. package/dist/tui/terminal-screen.js +13 -1
  62. package/dist/tui/theme-contrast.js +144 -0
  63. package/dist/tui/theme-preview.js +109 -0
  64. package/dist/tui/theme.js +158 -0
  65. package/dist/version.js +1 -1
  66. package/dist/workers/capabilities.js +212 -0
  67. package/dist/workers/live-probe.js +176 -0
  68. package/dist/workers/mock-adapter.js +39 -6
  69. package/dist/workers/native-attach.js +78 -3
  70. package/dist/workers/process-adapter.js +570 -77
  71. package/dist/workers/registry.js +4 -2
  72. package/package.json +5 -2
@@ -0,0 +1,17 @@
1
+ const TASK_STATE_TRANSITIONS = {
2
+ created: new Set(["routed", "failed", "cancelled"]),
3
+ routed: new Set(["judging", "failed", "cancelled"]),
4
+ judging: new Set(["ready_for_pair", "failed", "cancelled"]),
5
+ ready_for_pair: new Set(["actor_running", "done", "failed", "cancelled"]),
6
+ actor_running: new Set(["critic_running", "verifying", "failed", "cancelled"]),
7
+ critic_running: new Set(["revision_needed", "integrating", "failed", "cancelled"]),
8
+ revision_needed: new Set(["actor_running", "failed", "cancelled"]),
9
+ integrating: new Set(["actor_running", "verifying", "done", "failed", "cancelled"]),
10
+ verifying: new Set(["revision_needed", "integrating", "failed", "cancelled"]),
11
+ done: new Set(["routed", "cancelled"]),
12
+ failed: new Set(["routed", "judging", "ready_for_pair"]),
13
+ cancelled: new Set(["routed", "judging", "ready_for_pair"])
14
+ };
15
+ export function taskStateTransitionAllowed(from, to) {
16
+ return from === to || TASK_STATE_TRANSITIONS[from].has(to);
17
+ }
@@ -0,0 +1,118 @@
1
+ import { readFile, readdir, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { sessionsRoot } from "./paths.js";
4
+ const TASK_DIRECTORY_PATTERN = /^task-/;
5
+ const TURN_DIRECTORY_PATTERN = /^turn-(\d{4,})$/;
6
+ const WAVE_DIRECTORY_PATTERN = /^wave-(\d{4,})$/;
7
+ const COMMIT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/;
8
+ const COMMIT_PROTOCOL = "atomic-claim-v1";
9
+ export async function reconcileWorkspaceCommitIntents(projectRoot, dataDir) {
10
+ const recovery = { cleaned: 0, preserved: 0 };
11
+ const taskEntries = await directoryEntries(sessionsRoot(projectRoot, dataDir));
12
+ for (const taskEntry of taskEntries) {
13
+ if (!taskEntry.isDirectory() || !TASK_DIRECTORY_PATTERN.test(taskEntry.name)) {
14
+ continue;
15
+ }
16
+ const workspacesDir = join(sessionsRoot(projectRoot, dataDir), taskEntry.name, "workspaces");
17
+ for (const turnEntry of await directoryEntries(workspacesDir)) {
18
+ const turnMatch = turnEntry.isDirectory() ? TURN_DIRECTORY_PATTERN.exec(turnEntry.name) : null;
19
+ if (!turnMatch) {
20
+ continue;
21
+ }
22
+ for (const waveEntry of await directoryEntries(join(workspacesDir, turnEntry.name))) {
23
+ const waveMatch = waveEntry.isDirectory() ? WAVE_DIRECTORY_PATTERN.exec(waveEntry.name) : null;
24
+ if (!waveMatch) {
25
+ continue;
26
+ }
27
+ const waveDir = join(workspacesDir, turnEntry.name, waveEntry.name);
28
+ const pendingPath = join(waveDir, "integration.pending.json");
29
+ const pendingRecord = await readJsonRecord(pendingPath);
30
+ if (pendingRecord === undefined) {
31
+ continue;
32
+ }
33
+ const pending = parseCommitEvidence(pendingRecord, "committing");
34
+ const integrated = parseCommitEvidence(await readJsonRecord(join(waveDir, "integration.json")), "integrated");
35
+ const directoryTurnId = turnMatch[1];
36
+ const directoryWave = Number(waveMatch[1]);
37
+ if (!pending
38
+ || !integrated
39
+ || pending.turnId !== directoryTurnId
40
+ || pending.wave !== directoryWave
41
+ || !sameCommit(pending, integrated)) {
42
+ recovery.preserved += 1;
43
+ continue;
44
+ }
45
+ try {
46
+ await rm(pendingPath, { force: true });
47
+ recovery.cleaned += 1;
48
+ }
49
+ catch {
50
+ recovery.preserved += 1;
51
+ }
52
+ }
53
+ }
54
+ }
55
+ return recovery;
56
+ }
57
+ async function directoryEntries(path) {
58
+ try {
59
+ return await readdir(path, { withFileTypes: true });
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ }
65
+ async function readJsonRecord(path) {
66
+ try {
67
+ const value = JSON.parse(await readFile(path, "utf8"));
68
+ return value !== null && typeof value === "object" && !Array.isArray(value)
69
+ ? value
70
+ : null;
71
+ }
72
+ catch (error) {
73
+ return error.code === "ENOENT" ? undefined : null;
74
+ }
75
+ }
76
+ function parseCommitEvidence(record, state) {
77
+ const featureIds = stringArray(record?.feature_ids);
78
+ const changedPaths = stringArray(record?.changed_paths);
79
+ const commitId = record?.commit_id;
80
+ const commitProtocol = record?.commit_protocol;
81
+ if (record?.version !== 1
82
+ || record.state !== state
83
+ || typeof record.turn_id !== "string"
84
+ || !Number.isInteger(record.wave)
85
+ || record.wave < 1
86
+ || !featureIds
87
+ || !changedPaths
88
+ || (commitId !== undefined && (typeof commitId !== "string" || !COMMIT_ID_PATTERN.test(commitId)))
89
+ || (commitProtocol !== undefined && commitProtocol !== COMMIT_PROTOCOL)) {
90
+ return null;
91
+ }
92
+ return {
93
+ version: 1,
94
+ state,
95
+ turnId: record.turn_id,
96
+ wave: record.wave,
97
+ featureIds,
98
+ ...(typeof commitId === "string" ? { commitId } : {}),
99
+ ...(commitProtocol === COMMIT_PROTOCOL ? { commitProtocol } : {}),
100
+ changedPaths
101
+ };
102
+ }
103
+ function stringArray(value) {
104
+ return Array.isArray(value) && value.every((item) => typeof item === "string")
105
+ ? value
106
+ : null;
107
+ }
108
+ function sameCommit(pending, integrated) {
109
+ return pending.turnId === integrated.turnId
110
+ && pending.wave === integrated.wave
111
+ && sameStrings(pending.featureIds, integrated.featureIds)
112
+ && sameStrings(pending.changedPaths, integrated.changedPaths)
113
+ && (pending.commitId === undefined || pending.commitId === integrated.commitId)
114
+ && (pending.commitProtocol === undefined || pending.commitProtocol === integrated.commitProtocol);
115
+ }
116
+ function sameStrings(left, right) {
117
+ return left.length === right.length && left.every((value, index) => value === right[index]);
118
+ }
@@ -2,9 +2,12 @@ import { join, resolve } from "node:path";
2
2
  import { homedir } from "node:os";
3
3
  import { z } from "zod";
4
4
  import { ensureDir, pathExists, pathIsDirectory, readJson, readTextIfExists, writeJson, writeText } from "./file-store.js";
5
+ import { runWithLeaseFinalization } from "./lease-finalization.js";
6
+ import { acquireProcessMutationTurn } from "./process-mutation-turn.js";
5
7
  const lastWorkspaceFile = "last-workspace";
6
8
  const workspacesFile = "workspaces.json";
7
9
  const maxRememberedWorkspaces = 20;
10
+ const workspaceRegistryIntentPrefix = ".workspace-registry-claim-";
8
11
  const WorkspaceRegistrySchema = z.object({
9
12
  version: z.literal(1).default(1),
10
13
  workspaces: z
@@ -73,18 +76,23 @@ export async function listWorkspaceChoices(appRoot) {
73
76
  .map(({ selectable: _selectable, ...choice }) => choice);
74
77
  }
75
78
  async function rememberWorkspace(appRoot, workspaceRoot) {
76
- const now = new Date().toISOString();
77
79
  const resolved = resolveWorkspacePath(process.cwd(), workspaceRoot);
78
- const current = await readWorkspaceEntries(appRoot);
79
- const next = {
80
- version: 1,
81
- workspaces: [
82
- { path: resolved, last_used_at: now },
83
- ...current.filter((entry) => resolveStoredWorkspacePath(appRoot, entry.path) !== resolved)
84
- ].slice(0, maxRememberedWorkspaces)
85
- };
86
- await writeText(lastWorkspacePath(appRoot), `${resolved}\n`);
87
- await writeJson(workspacesPath(appRoot), WorkspaceRegistrySchema.parse(next));
80
+ const mutationTurn = await acquireProcessMutationTurn(join(appRoot, ".parallel-codex"), {
81
+ intentPrefix: workspaceRegistryIntentPrefix,
82
+ timeoutMessage: "Timed out waiting to update the workspace registry."
83
+ });
84
+ await runWithLeaseFinalization("Workspace registry update", mutationTurn, async () => {
85
+ const current = await readWorkspaceEntries(appRoot);
86
+ const next = {
87
+ version: 1,
88
+ workspaces: [
89
+ { path: resolved, last_used_at: new Date().toISOString() },
90
+ ...current.filter((entry) => resolveStoredWorkspacePath(appRoot, entry.path) !== resolved)
91
+ ].slice(0, maxRememberedWorkspaces)
92
+ };
93
+ await writeJson(workspacesPath(appRoot), WorkspaceRegistrySchema.parse(next));
94
+ await writeText(lastWorkspacePath(appRoot), `${resolved}\n`);
95
+ });
88
96
  }
89
97
  async function readWorkspaceEntries(appRoot) {
90
98
  const file = workspacesPath(appRoot);
package/dist/doctor.js CHANGED
@@ -1,11 +1,22 @@
1
+ import { execFile } from "node:child_process";
1
2
  import { constants } from "node:fs";
2
3
  import { access } from "node:fs/promises";
4
+ import { createConnection } from "node:net";
3
5
  import { delimiter, join } from "node:path";
4
6
  import { prepareAppRoot } from "./core/app-root.js";
5
- import { configPath, loadConfig } from "./core/config.js";
6
- import { pathExists } from "./core/file-store.js";
7
+ import { formatConfigErrorMessage } from "./core/config-errors.js";
8
+ import { configPath, loadConfig, withUiThemeOverride } from "./core/config.js";
9
+ import { ensureDir, pathExists } from "./core/file-store.js";
10
+ import { routerRuntimeDir } from "./core/paths.js";
11
+ import { diagnoseRouterFailure } from "./core/router-audit.js";
12
+ import { routeRequestWithCodex, routerProxyConfigured } from "./core/router.js";
7
13
  import { prepareWorkspace } from "./core/workspace.js";
8
- export async function runDoctor(appRoot, workspaceRoot, env = process.env) {
14
+ import { auditTuiThemeContrast, TUI_THEME_MIN_CONTRAST_RATIO } from "./tui/theme-contrast.js";
15
+ import { formatTuiThemePreview } from "./tui/theme-preview.js";
16
+ import { resolveTuiTheme } from "./tui/theme.js";
17
+ import { diagnoseAgentCapabilities } from "./workers/capabilities.js";
18
+ import { runLiveAgentProbes } from "./workers/live-probe.js";
19
+ export async function runDoctor(appRoot, workspaceRoot, env = process.env, options = {}) {
9
20
  const lines = ["parallel-codex-tui doctor"];
10
21
  let ok = true;
11
22
  await prepareAppRoot(appRoot);
@@ -15,7 +26,7 @@ export async function runDoctor(appRoot, workspaceRoot, env = process.env) {
15
26
  }
16
27
  else {
17
28
  ok = false;
18
- lines.push(`Node.js: unsupported (${process.versions.node}; need 26+)`);
29
+ lines.push(`Node.js: unsupported (${process.versions.node}; need 24.15+)`);
19
30
  }
20
31
  lines.push(`workspace: ok (${preparedWorkspace})`);
21
32
  const localConfigPath = configPath(appRoot);
@@ -29,19 +40,33 @@ export async function runDoctor(appRoot, workspaceRoot, env = process.env) {
29
40
  }
30
41
  let config;
31
42
  try {
32
- config = await loadConfig(appRoot);
43
+ const loadedConfig = await loadConfig(appRoot);
44
+ config = withUiThemeOverride(loadedConfig, options.theme ?? null);
33
45
  lines.push(`config: ok (${localConfigPath})`);
46
+ lines.push(`theme: ok (${themeSummary(config.ui.theme, loadedConfig.ui.theme, options.theme ?? null)}; ${themeOverrideSummary(config.ui.colors)})`);
47
+ const theme = resolveTuiTheme({ theme: config.ui.theme, colors: config.ui.colors });
48
+ lines.push(`palette: ${themePaletteSummary(theme)}`);
49
+ lines.push(...formatTuiThemePreview(theme));
50
+ lines.push(...formatThemeContrastAudit(auditTuiThemeContrast(theme)));
34
51
  }
35
52
  catch (error) {
36
53
  ok = false;
37
- lines.push(`config: invalid (${localConfigPath}; ${error.message})`);
54
+ lines.push(`config: invalid (${localConfigPath})`);
55
+ lines.push(...formatConfigErrorMessage(error).split("\n").map((line) => `config error: ${line}`));
38
56
  return {
39
57
  ok,
40
58
  text: `${lines.join("\n")}\n`
41
59
  };
42
60
  }
43
- for (const command of configuredCommands(config)) {
61
+ const includeRouter = config.router.defaultMode === "auto" || options.probeRouter === true;
62
+ if (includeRouter) {
63
+ lines.push(`router retry: ${config.router.codex.maxAttempts} attempts; transient only; ${config.router.codex.retryDelayMs}ms backoff (TUI routing; live probe runs once)`);
64
+ lines.push(`router budget: total ${config.router.codex.timeoutMs}ms; follow-up ${config.router.codex.followUpTimeoutMs}ms; first output ${config.router.codex.firstOutputTimeoutMs}ms; idle ${config.router.codex.idleTimeoutMs}ms`);
65
+ }
66
+ const availableCommands = new Set();
67
+ for (const command of configuredCommands(config, includeRouter)) {
44
68
  if (await commandExists(command, env)) {
69
+ availableCommands.add(command);
45
70
  lines.push(`${command}: ok`);
46
71
  }
47
72
  else {
@@ -49,7 +74,7 @@ export async function runDoctor(appRoot, workspaceRoot, env = process.env) {
49
74
  lines.push(`${command}: missing`);
50
75
  }
51
76
  }
52
- for (const check of configuredWorkerModelEnvChecks(config, env)) {
77
+ for (const check of configuredEnvironmentChecks(config, env, includeRouter)) {
53
78
  if (check.ok) {
54
79
  lines.push(`${check.label}: ok`);
55
80
  }
@@ -58,37 +83,226 @@ export async function runDoctor(appRoot, workspaceRoot, env = process.env) {
58
83
  lines.push(`${check.label}: missing env ${check.envName}`);
59
84
  }
60
85
  }
86
+ const capabilityDiagnostics = await diagnoseAgentCapabilities(config, env, {
87
+ includeRouter,
88
+ workerEngines: configuredWorkerEngines(config),
89
+ availableCommands,
90
+ ...(options.capabilityRunner ? { runner: options.capabilityRunner } : {}),
91
+ ...(options.capabilityTimeoutMs ? { timeoutMs: options.capabilityTimeoutMs } : {})
92
+ });
93
+ lines.push(...capabilityDiagnostics.lines);
94
+ ok = ok && capabilityDiagnostics.ok;
95
+ const proxyDiagnostics = await diagnoseProxyEnvironment(config, env, await detectMacSystemProxy(), canConnectProxy, { includeRouter });
96
+ lines.push(...proxyDiagnostics.lines);
97
+ ok = ok && proxyDiagnostics.ok;
98
+ if (options.probeAgents) {
99
+ if (ok) {
100
+ const liveAgents = await runLiveAgentProbes(config, preparedWorkspace, configuredWorkerEngines(config), options.liveAgentProbeOptions);
101
+ lines.push(...liveAgents.lines);
102
+ ok = ok && liveAgents.ok;
103
+ }
104
+ else {
105
+ lines.push("agent live probe: skipped (preflight failed)");
106
+ }
107
+ }
108
+ else {
109
+ lines.push("agent live probe: not run (add --probe-agents; may use model quota)");
110
+ }
111
+ if (options.probeRouter) {
112
+ const probe = await runRouterProbe(config, appRoot, options.routeRunner);
113
+ lines.push(probe.line);
114
+ ok = ok && probe.ok;
115
+ }
116
+ else if (config.router.defaultMode === "auto") {
117
+ lines.push("router live probe: not run (add --probe-router)");
118
+ }
61
119
  return {
62
120
  ok,
63
121
  text: `${lines.join("\n")}\n`
64
122
  };
65
123
  }
124
+ export async function diagnoseProxyEnvironment(config, env, systemProxy, connect = canConnectProxy, options = {}) {
125
+ const contexts = [];
126
+ if (options.includeRouter ?? config.router.defaultMode === "auto") {
127
+ contexts.push({ label: "router proxy", env: config.router.codex.env, table: "router.codex.env" });
128
+ }
129
+ if (configuredWorkerEngines(config).includes("codex")) {
130
+ contexts.push({
131
+ label: "workers.codex proxy",
132
+ env: config.workers.codex.model.env,
133
+ table: "workers.codex.model.env"
134
+ });
135
+ }
136
+ const connectionCache = new Map();
137
+ const lines = [];
138
+ let ok = true;
139
+ for (const context of contexts) {
140
+ const endpoint = configuredProxyEndpoint(context.env, env);
141
+ if (!endpoint) {
142
+ if (systemProxy) {
143
+ lines.push(`${context.label}: warning (macOS system proxy ${formatProxyEndpoint(systemProxy)} is not inherited; configure [${context.table}])`);
144
+ }
145
+ else {
146
+ lines.push(`${context.label}: direct (no proxy configured)`);
147
+ }
148
+ continue;
149
+ }
150
+ if (endpoint === "invalid") {
151
+ ok = false;
152
+ lines.push(`${context.label}: invalid proxy URL`);
153
+ continue;
154
+ }
155
+ const key = formatProxyEndpoint(endpoint);
156
+ const reachable = await cachedProxyConnection(connectionCache, key, () => connect(endpoint.host, endpoint.port));
157
+ if (reachable) {
158
+ lines.push(`${context.label}: reachable (${key}; local endpoint only)`);
159
+ }
160
+ else {
161
+ ok = false;
162
+ lines.push(`${context.label}: unreachable (${key})`);
163
+ }
164
+ }
165
+ return { ok, lines };
166
+ }
167
+ async function runRouterProbe(config, appRoot, runner) {
168
+ const cwd = routerRuntimeDir(appRoot, config.dataDir);
169
+ await ensureDir(cwd);
170
+ const probeConfig = {
171
+ ...config,
172
+ router: {
173
+ ...config.router,
174
+ defaultMode: "auto"
175
+ }
176
+ };
177
+ try {
178
+ const route = await routeRequestWithCodex("hello", probeConfig, runner, cwd);
179
+ if (route.source === "codex") {
180
+ const trace = formatRouterProbeTrace(route, false);
181
+ return {
182
+ ok: true,
183
+ line: `router live probe: ok (${route.mode} in ${Math.round(route.duration_ms ?? 0)}ms${trace ? `; ${trace}` : ""})`
184
+ };
185
+ }
186
+ const trace = formatRouterProbeTrace(route, true);
187
+ const diagnosis = diagnoseRouterFailure({
188
+ ...route,
189
+ reason: route.reason,
190
+ proxy_configured: routerProxyConfigured(config.router.codex.env)
191
+ });
192
+ return {
193
+ ok: false,
194
+ line: `router live probe: failed (${sanitizeDiagnosticText(route.reason)}${trace ? `; ${trace}` : ""}; diagnosis ${diagnosis.summary}; next ${diagnosis.action})`
195
+ };
196
+ }
197
+ catch (error) {
198
+ const message = error instanceof Error ? error.message : String(error);
199
+ return {
200
+ ok: false,
201
+ line: `router live probe: failed (${sanitizeDiagnosticText(message)})`
202
+ };
203
+ }
204
+ }
205
+ function formatRouterProbeTrace(route, includeStage) {
206
+ return [
207
+ ...(includeStage && route.router_failure_stage ? [`stage ${route.router_failure_stage}`] : []),
208
+ ...(includeStage && route.router_timeout_kind ? [`timeout ${route.router_timeout_kind}`] : []),
209
+ ...(typeof route.router_dispatch_ms === "number" ? [`dispatch ${Math.round(route.router_dispatch_ms)}ms`] : []),
210
+ ...(typeof route.router_spawn_ms === "number" ? [`spawn ${Math.round(route.router_spawn_ms)}ms`] : []),
211
+ ...formatRouterProbeFirstOutput(route),
212
+ ...(typeof route.router_process_ms === "number" ? [`process ${Math.round(route.router_process_ms)}ms`] : []),
213
+ ...(typeof route.router_parse_ms === "number" ? [`parse ${Math.round(route.router_parse_ms)}ms`] : []),
214
+ ...(typeof route.duration_ms === "number" ? [`total ${Math.round(route.duration_ms)}ms`] : []),
215
+ ...(typeof route.router_stdout_bytes === "number" ? [`stdout ${formatRouterProbeBytes(route.router_stdout_bytes)}`] : []),
216
+ ...(typeof route.router_stderr_bytes === "number" ? [`stderr ${formatRouterProbeBytes(route.router_stderr_bytes)}`] : [])
217
+ ].join("; ");
218
+ }
219
+ function formatRouterProbeFirstOutput(route) {
220
+ const streams = [
221
+ ...(typeof route.router_first_stdout_ms === "number"
222
+ ? [{ at: route.router_first_stdout_ms, text: `first stdout ${Math.round(route.router_first_stdout_ms)}ms` }]
223
+ : []),
224
+ ...(typeof route.router_first_stderr_ms === "number"
225
+ ? [{ at: route.router_first_stderr_ms, text: `first stderr ${Math.round(route.router_first_stderr_ms)}ms` }]
226
+ : [])
227
+ ];
228
+ if (streams.length > 0) {
229
+ return streams.sort((left, right) => left.at - right.at).map((stream) => stream.text);
230
+ }
231
+ if (typeof route.router_first_output_ms === "number") {
232
+ return [`first output ${Math.round(route.router_first_output_ms)}ms`];
233
+ }
234
+ return route.router_failure_stage === "waiting-output" ? ["first output none"] : [];
235
+ }
236
+ function formatRouterProbeBytes(bytes) {
237
+ if (bytes < 1024) {
238
+ return `${Math.round(bytes)}B`;
239
+ }
240
+ return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)}KB`;
241
+ }
242
+ function sanitizeDiagnosticText(value) {
243
+ return value
244
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
245
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
246
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ")
247
+ .replace(/\b([a-z][a-z0-9+.-]*:\/\/)([^@\s/]+)@/gi, "$1***@")
248
+ .replace(/\b(api[-_\s]?key|token|authorization)(\s*[:=]\s*)(\S+)/gi, "$1$2***")
249
+ .replace(/\s+/g, " ")
250
+ .trim()
251
+ .slice(0, 400) || "unknown error";
252
+ }
66
253
  export function isSupportedNodeVersion(version) {
67
254
  const [majorRaw = "0", minorRaw = "0"] = version.split(".");
68
255
  const major = Number.parseInt(majorRaw, 10);
69
256
  const minor = Number.parseInt(minorRaw, 10);
70
- return major >= 26;
257
+ return major > 24 || (major === 24 && minor >= 15);
71
258
  }
72
- function configuredCommands(config) {
73
- return Array.from(new Set(configuredEngines(config, { includeRouter: true })
74
- .map((engine) => commandForEngine(config, engine))
75
- .filter((command) => Boolean(command) && command !== "mock")));
259
+ function configuredCommands(config, includeRouter) {
260
+ const commands = includeRouter ? [config.router.codex.command] : [];
261
+ for (const engine of configuredWorkerEngines(config)) {
262
+ const worker = config.workers[engine];
263
+ commands.push(worker.command);
264
+ if (worker.nativeSession.enabled) {
265
+ commands.push(worker.interactive.command);
266
+ }
267
+ }
268
+ return [...new Set(commands.filter((command) => command && command !== "mock"))];
76
269
  }
77
- function commandForEngine(config, engine) {
78
- if (engine === "router-codex") {
79
- return config.router.codex.command;
270
+ function themeOverrideSummary(colors) {
271
+ const entries = Object.entries(colors).sort(([left], [right]) => left.localeCompare(right));
272
+ if (entries.length === 0) {
273
+ return "no color overrides";
80
274
  }
81
- if (engine === "codex") {
82
- return config.workers.codex.command;
275
+ return `colors: ${entries.map(([key, value]) => `${key}=${value}`).join(", ")}`;
276
+ }
277
+ function themePaletteSummary(theme) {
278
+ return [
279
+ `chrome=${theme.chrome}`,
280
+ `surface=${theme.surface}`,
281
+ `rail=${theme.rail}`,
282
+ `accent=${theme.accent}`
283
+ ].join(", ");
284
+ }
285
+ function formatThemeContrastAudit(audit) {
286
+ if (audit.issues.length === 0) {
287
+ return [`theme contrast: ok (minimum ${formatContrastRatio(audit.minimumRatio)}:1 across ${audit.measurements.length} rendered pairs)`];
83
288
  }
84
- if (engine === "claude") {
85
- return config.workers.claude.command;
289
+ return [
290
+ `theme contrast: warning (${audit.issues.length} of ${audit.measurements.length} rendered pairs below ${TUI_THEME_MIN_CONTRAST_RATIO}:1)`,
291
+ ...audit.issues.map(({ foreground, background, ratio }) => `theme contrast issue: ${foreground}/${background} ${formatContrastRatio(ratio)}:1`)
292
+ ];
293
+ }
294
+ function formatContrastRatio(ratio) {
295
+ return ratio.toFixed(2);
296
+ }
297
+ function themeSummary(effectiveTheme, configTheme, cliTheme) {
298
+ if (cliTheme && cliTheme !== configTheme) {
299
+ return `${effectiveTheme} via --theme; config ${configTheme}`;
86
300
  }
87
- return null;
301
+ return effectiveTheme;
88
302
  }
89
303
  function configuredEngines(config, options) {
90
304
  const engines = new Set();
91
- if (config.router.defaultMode === "auto" && options.includeRouter) {
305
+ if (options.includeRouter) {
92
306
  engines.add("router-codex");
93
307
  }
94
308
  if (config.router.defaultMode === "auto") {
@@ -110,8 +324,19 @@ function configuredEngines(config, options) {
110
324
  function configuredWorkerEngines(config) {
111
325
  return configuredEngines(config, { includeRouter: false }).filter((engine) => engine === "codex" || engine === "claude");
112
326
  }
113
- function configuredWorkerModelEnvChecks(config, env) {
327
+ function configuredEnvironmentChecks(config, env, includeRouter) {
114
328
  const checks = [];
329
+ if (includeRouter) {
330
+ for (const [key, value] of Object.entries(config.router.codex.env)) {
331
+ for (const envName of referencedEnvNames(value)) {
332
+ checks.push({
333
+ label: `router.codex.env.${key}`,
334
+ envName,
335
+ ok: Boolean(env[envName])
336
+ });
337
+ }
338
+ }
339
+ }
115
340
  for (const engine of configuredWorkerEngines(config)) {
116
341
  const worker = engine === "codex" ? config.workers.codex : config.workers.claude;
117
342
  for (const [key, value] of Object.entries(worker.model.env)) {
@@ -153,3 +378,98 @@ async function canExecute(path) {
153
378
  return false;
154
379
  }
155
380
  }
381
+ function configuredProxyEndpoint(configured, processEnvironment) {
382
+ const effective = {
383
+ ...processEnvironment,
384
+ ...Object.fromEntries(Object.entries(configured).map(([name, value]) => [name, renderEnvironmentValue(value, processEnvironment)]))
385
+ };
386
+ const value = [
387
+ effective.HTTPS_PROXY,
388
+ effective.https_proxy,
389
+ effective.ALL_PROXY,
390
+ effective.all_proxy,
391
+ effective.HTTP_PROXY,
392
+ effective.http_proxy
393
+ ].find((candidate) => candidate?.trim())?.trim();
394
+ if (!value) {
395
+ return null;
396
+ }
397
+ try {
398
+ const url = new URL(value.includes("://") ? value : `http://${value}`);
399
+ const port = url.port ? Number(url.port) : defaultProxyPort(url.protocol);
400
+ if (!url.hostname || !Number.isInteger(port) || port <= 0 || port > 65535) {
401
+ return "invalid";
402
+ }
403
+ return { host: url.hostname, port };
404
+ }
405
+ catch {
406
+ return "invalid";
407
+ }
408
+ }
409
+ function renderEnvironmentValue(value, env) {
410
+ return value.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => env[name] ?? "");
411
+ }
412
+ function defaultProxyPort(protocol) {
413
+ if (protocol === "https:") {
414
+ return 443;
415
+ }
416
+ if (protocol.startsWith("socks")) {
417
+ return 1080;
418
+ }
419
+ return 80;
420
+ }
421
+ function formatProxyEndpoint(endpoint) {
422
+ const host = endpoint.host.includes(":") ? `[${endpoint.host}]` : endpoint.host;
423
+ return `${host}:${endpoint.port}`;
424
+ }
425
+ async function cachedProxyConnection(cache, key, connect) {
426
+ const existing = cache.get(key);
427
+ if (existing) {
428
+ return existing;
429
+ }
430
+ const pending = connect();
431
+ cache.set(key, pending);
432
+ return pending;
433
+ }
434
+ async function detectMacSystemProxy() {
435
+ if (process.platform !== "darwin") {
436
+ return null;
437
+ }
438
+ const output = await execFileText("scutil", ["--proxy"]);
439
+ if (!output) {
440
+ return null;
441
+ }
442
+ const https = parseScutilProxy(output, "HTTPS");
443
+ return https ?? parseScutilProxy(output, "HTTP");
444
+ }
445
+ function parseScutilProxy(output, prefix) {
446
+ const enabled = output.match(new RegExp(`${prefix}Enable\\s*:\\s*(\\d+)`))?.[1] === "1";
447
+ const host = output.match(new RegExp(`${prefix}Proxy\\s*:\\s*(\\S+)`))?.[1];
448
+ const port = Number(output.match(new RegExp(`${prefix}Port\\s*:\\s*(\\d+)`))?.[1]);
449
+ return enabled && host && Number.isInteger(port) && port > 0 ? { host, port } : null;
450
+ }
451
+ function execFileText(command, args) {
452
+ return new Promise((resolve) => {
453
+ execFile(command, args, { timeout: 1000 }, (error, stdout) => {
454
+ resolve(error ? "" : stdout);
455
+ });
456
+ });
457
+ }
458
+ function canConnectProxy(host, port) {
459
+ return new Promise((resolve) => {
460
+ const socket = createConnection({ host, port });
461
+ let settled = false;
462
+ const finish = (reachable) => {
463
+ if (settled) {
464
+ return;
465
+ }
466
+ settled = true;
467
+ socket.destroy();
468
+ resolve(reachable);
469
+ };
470
+ socket.setTimeout(750);
471
+ socket.once("connect", () => finish(true));
472
+ socket.once("error", () => finish(false));
473
+ socket.once("timeout", () => finish(false));
474
+ });
475
+ }