parallel-codex-tui 0.1.0 → 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 (74) hide show
  1. package/.parallel-codex/config.example.toml +90 -3
  2. package/README.md +269 -12
  3. package/dist/bootstrap.js +50 -18
  4. package/dist/cli-args.js +96 -14
  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 +40 -0
  10. package/dist/cli.js +291 -35
  11. package/dist/core/app-root.js +8 -0
  12. package/dist/core/collaboration-timeline.js +261 -0
  13. package/dist/core/config-errors.js +14 -0
  14. package/dist/core/config.js +191 -23
  15. package/dist/core/file-store.js +130 -6
  16. package/dist/core/lease-finalization.js +22 -0
  17. package/dist/core/paths.js +10 -0
  18. package/dist/core/process-identity.js +48 -0
  19. package/dist/core/process-mutation-turn.js +128 -0
  20. package/dist/core/process-ownership.js +276 -0
  21. package/dist/core/process-tree.js +90 -0
  22. package/dist/core/router-audit.js +155 -0
  23. package/dist/core/router-redaction.js +31 -0
  24. package/dist/core/router.js +473 -42
  25. package/dist/core/session-index.js +225 -30
  26. package/dist/core/session-manager.js +1182 -44
  27. package/dist/core/task-state-machine.js +17 -0
  28. package/dist/core/workspace-commit-recovery.js +118 -0
  29. package/dist/core/workspace.js +126 -0
  30. package/dist/doctor.js +384 -30
  31. package/dist/domain/schemas.js +127 -6
  32. package/dist/orchestrator/collaboration-channel.js +255 -4
  33. package/dist/orchestrator/feature-plan.js +70 -0
  34. package/dist/orchestrator/judge-artifacts.js +236 -0
  35. package/dist/orchestrator/orchestrator.js +1777 -212
  36. package/dist/orchestrator/prompts.js +126 -2
  37. package/dist/orchestrator/supervisor-summary.js +56 -2
  38. package/dist/orchestrator/workspace-sandbox.js +911 -0
  39. package/dist/tui/App.js +2838 -159
  40. package/dist/tui/AppShell.js +188 -23
  41. package/dist/tui/CollaborationTimelineView.js +327 -0
  42. package/dist/tui/FeatureBoardView.js +227 -0
  43. package/dist/tui/InputBar.js +514 -57
  44. package/dist/tui/RouterDiagnosticsView.js +469 -0
  45. package/dist/tui/StatusBar.js +610 -57
  46. package/dist/tui/TaskSessionsView.js +207 -0
  47. package/dist/tui/TerminalOutput.js +53 -9
  48. package/dist/tui/WorkerOutputView.js +1403 -161
  49. package/dist/tui/WorkerOverviewView.js +250 -0
  50. package/dist/tui/chat-history.js +25 -0
  51. package/dist/tui/chat-input.js +67 -19
  52. package/dist/tui/chat-paste.js +76 -0
  53. package/dist/tui/display-width.js +41 -3
  54. package/dist/tui/incremental-text-file.js +101 -0
  55. package/dist/tui/keyboard.js +46 -0
  56. package/dist/tui/markdown-text.js +14 -0
  57. package/dist/tui/raw-input-decoder.js +3 -0
  58. package/dist/tui/scrolling.js +2 -1
  59. package/dist/tui/status-line.js +318 -11
  60. package/dist/tui/task-memory.js +15 -0
  61. package/dist/tui/task-result.js +105 -0
  62. package/dist/tui/terminal-screen.js +13 -1
  63. package/dist/tui/theme-contrast.js +144 -0
  64. package/dist/tui/theme-preview.js +109 -0
  65. package/dist/tui/theme.js +158 -0
  66. package/dist/version.js +1 -1
  67. package/dist/workers/capabilities.js +212 -0
  68. package/dist/workers/live-probe.js +176 -0
  69. package/dist/workers/mock-adapter.js +39 -6
  70. package/dist/workers/native-attach.js +147 -8
  71. package/dist/workers/native-session-detection.js +17 -0
  72. package/dist/workers/process-adapter.js +580 -81
  73. package/dist/workers/registry.js +4 -2
  74. package/package.json +17 -2
@@ -0,0 +1,128 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { ensureDir, readJson, removeIfExists, writeJson } from "./file-store.js";
6
+ import { processIsAlive, readProcessStartToken } from "./process-identity.js";
7
+ const ProcessMutationIntentSchema = z.object({
8
+ version: z.literal(1),
9
+ intent_id: z.string().min(1),
10
+ pid: z.number().int().positive(),
11
+ created_at: z.string().datetime(),
12
+ choosing: z.boolean(),
13
+ ticket: z.number().int().nonnegative(),
14
+ process_start_token: z.string().min(1).optional()
15
+ });
16
+ let currentProcessStartToken = null;
17
+ export async function acquireProcessMutationTurn(directory, options) {
18
+ validateIntentPrefix(options.intentPrefix);
19
+ await ensureDir(directory);
20
+ const identity = await currentMutationIdentity();
21
+ const intentId = randomUUID();
22
+ const path = join(directory, `${options.intentPrefix}${intentId}.json`);
23
+ let intent = ProcessMutationIntentSchema.parse({
24
+ version: 1,
25
+ intent_id: intentId,
26
+ pid: identity.pid,
27
+ created_at: new Date().toISOString(),
28
+ choosing: true,
29
+ ticket: 0,
30
+ ...(identity.process_start_token ? { process_start_token: identity.process_start_token } : {})
31
+ });
32
+ await writeJson(path, intent);
33
+ try {
34
+ const existing = await readActiveIntents(directory, options.intentPrefix);
35
+ intent = {
36
+ ...intent,
37
+ choosing: false,
38
+ ticket: Math.max(0, ...existing.map((candidate) => candidate.ticket)) + 1
39
+ };
40
+ await writeJson(path, intent);
41
+ const deadline = Date.now() + (options.timeoutMs ?? 5000);
42
+ while (true) {
43
+ const candidates = await readActiveIntents(directory, options.intentPrefix);
44
+ const blocked = candidates.some((candidate) => (candidate.intent_id !== intent.intent_id
45
+ && (candidate.choosing || intentPrecedes(candidate, intent))));
46
+ if (!blocked) {
47
+ let released = false;
48
+ return {
49
+ release: async () => {
50
+ if (released) {
51
+ return;
52
+ }
53
+ released = true;
54
+ await removeIfExists(path);
55
+ }
56
+ };
57
+ }
58
+ if (Date.now() >= deadline) {
59
+ throw new Error(options.timeoutMessage);
60
+ }
61
+ await delay(options.pollMs ?? 5);
62
+ }
63
+ }
64
+ catch (error) {
65
+ await removeIfExists(path);
66
+ throw error;
67
+ }
68
+ }
69
+ async function currentMutationIdentity() {
70
+ currentProcessStartToken ??= readProcessStartToken(process.pid);
71
+ const processStartToken = await currentProcessStartToken;
72
+ return {
73
+ pid: process.pid,
74
+ ...(processStartToken ? { process_start_token: processStartToken } : {})
75
+ };
76
+ }
77
+ async function readActiveIntents(directory, prefix) {
78
+ const names = await readdir(directory);
79
+ const tokenReads = new Map();
80
+ const active = [];
81
+ for (const name of names) {
82
+ if (!name.startsWith(prefix) || !name.endsWith(".json")) {
83
+ continue;
84
+ }
85
+ const path = join(directory, name);
86
+ const intent = await readValidIntent(path);
87
+ if (!intent || !processIsAlive(intent.pid)) {
88
+ await removeIfExists(path);
89
+ continue;
90
+ }
91
+ if (intent.process_start_token) {
92
+ let tokenRead = tokenReads.get(intent.pid);
93
+ if (!tokenRead) {
94
+ tokenRead = readProcessStartToken(intent.pid);
95
+ tokenReads.set(intent.pid, tokenRead);
96
+ }
97
+ const currentToken = await tokenRead;
98
+ if (!currentToken || currentToken !== intent.process_start_token) {
99
+ await removeIfExists(path);
100
+ continue;
101
+ }
102
+ }
103
+ active.push(intent);
104
+ }
105
+ return active;
106
+ }
107
+ async function readValidIntent(path) {
108
+ try {
109
+ return await readJson(path, ProcessMutationIntentSchema);
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ }
115
+ function intentPrecedes(candidate, current) {
116
+ if (candidate.ticket !== current.ticket) {
117
+ return candidate.ticket < current.ticket;
118
+ }
119
+ return candidate.intent_id < current.intent_id;
120
+ }
121
+ function validateIntentPrefix(prefix) {
122
+ if (!prefix.startsWith(".") || prefix.includes("/") || prefix.includes("\\")) {
123
+ throw new Error(`Invalid process mutation intent prefix: ${prefix}`);
124
+ }
125
+ }
126
+ async function delay(milliseconds) {
127
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
128
+ }
@@ -0,0 +1,276 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { ensureDir, pathExists, readJson, removeIfExists, writeJson } from "./file-store.js";
6
+ import { processIsAlive, readProcessStartToken } from "./process-identity.js";
7
+ import { acquireProcessMutationTurn } from "./process-mutation-turn.js";
8
+ export { processIsAlive, readProcessStartToken } from "./process-identity.js";
9
+ const TaskRunOwnerSchema = z.object({
10
+ version: z.literal(1),
11
+ owner_id: z.string().min(1),
12
+ pid: z.number().int().positive(),
13
+ acquired_at: z.string().datetime(),
14
+ process_start_token: z.string().min(1).optional()
15
+ });
16
+ const CLAIM_INTENT_PREFIX = ".run-owner-claim-";
17
+ const CLAIM_INTENT_TIMEOUT_MS = 5000;
18
+ const CLAIM_INTENT_POLL_MS = 5;
19
+ const WorkerProcessRecordSchema = z.object({
20
+ version: z.literal(1),
21
+ worker_id: z.string().min(1),
22
+ pid: z.number().int().positive(),
23
+ process_group_id: z.number().int().positive().optional(),
24
+ process_start_token: z.string().min(1).optional(),
25
+ owner_pid: z.number().int().positive(),
26
+ command: z.string().min(1),
27
+ started_at: z.string().datetime()
28
+ });
29
+ export class TaskRunLeaseConflictError extends Error {
30
+ owner;
31
+ constructor(owner) {
32
+ super(`Task is already running in another parallel-codex-tui process (pid ${owner?.pid ?? "unknown"}).`);
33
+ this.owner = owner;
34
+ this.name = "TaskRunLeaseConflictError";
35
+ }
36
+ }
37
+ export function taskRunOwnerPath(taskDir) {
38
+ return join(taskDir, "run-owner.json");
39
+ }
40
+ export function workerProcessRecordPath(workerDir) {
41
+ return join(workerDir, "process.json");
42
+ }
43
+ export async function claimTaskRunLease(taskDir, options = {}) {
44
+ const pid = options.pid ?? process.pid;
45
+ const owner = TaskRunOwnerSchema.parse({
46
+ version: 1,
47
+ owner_id: options.ownerId ?? randomUUID(),
48
+ pid,
49
+ acquired_at: (options.now ?? (() => new Date()))().toISOString(),
50
+ ...await optionalProcessStartToken(pid)
51
+ });
52
+ const path = taskRunOwnerPath(taskDir);
53
+ await ensureDir(taskDir);
54
+ const mutationTurn = await acquireTaskRunMutationTurn(taskDir);
55
+ try {
56
+ if (await writeJsonExclusive(path, owner)) {
57
+ return {
58
+ owner,
59
+ release: () => releaseTaskRunLease(taskDir, path, owner.owner_id)
60
+ };
61
+ }
62
+ const inspection = await inspectTaskRunLease(taskDir);
63
+ if (inspection.state === "active") {
64
+ throw new TaskRunLeaseConflictError(inspection.owner);
65
+ }
66
+ await removeOwnedLease(path, inspection.owner?.owner_id);
67
+ if (await writeJsonExclusive(path, owner)) {
68
+ return {
69
+ owner,
70
+ release: () => releaseTaskRunLease(taskDir, path, owner.owner_id)
71
+ };
72
+ }
73
+ const current = await inspectTaskRunLease(taskDir);
74
+ throw new TaskRunLeaseConflictError(current.owner);
75
+ }
76
+ finally {
77
+ await mutationTurn.release();
78
+ }
79
+ }
80
+ export async function inspectTaskRunLease(taskDir) {
81
+ const path = taskRunOwnerPath(taskDir);
82
+ if (!(await pathExists(path))) {
83
+ return { state: "missing", owner: null };
84
+ }
85
+ const owner = await readValidJson(path, TaskRunOwnerSchema);
86
+ if (!owner) {
87
+ return { state: "stale", owner: null };
88
+ }
89
+ if (!processIsAlive(owner.pid)) {
90
+ return { state: "stale", owner };
91
+ }
92
+ if (owner.process_start_token) {
93
+ const currentToken = await readProcessStartToken(owner.pid);
94
+ if (!currentToken || currentToken !== owner.process_start_token) {
95
+ return { state: "stale", owner };
96
+ }
97
+ }
98
+ return { state: "active", owner };
99
+ }
100
+ export async function clearStaleTaskRunLease(taskDir, owner) {
101
+ const mutationTurn = await acquireTaskRunMutationTurn(taskDir);
102
+ try {
103
+ const inspection = await inspectTaskRunLease(taskDir);
104
+ if (inspection.state !== "stale") {
105
+ return;
106
+ }
107
+ await removeOwnedLease(taskRunOwnerPath(taskDir), owner?.owner_id ?? inspection.owner?.owner_id);
108
+ }
109
+ finally {
110
+ await mutationTurn.release();
111
+ }
112
+ }
113
+ export async function writeWorkerProcessRecord(workerDir, input) {
114
+ const record = WorkerProcessRecordSchema.parse({
115
+ version: 1,
116
+ worker_id: input.workerId,
117
+ pid: input.pid,
118
+ ...(input.processGroupId ? { process_group_id: input.processGroupId } : {}),
119
+ owner_pid: process.pid,
120
+ command: input.command,
121
+ started_at: (input.now ?? (() => new Date()))().toISOString(),
122
+ ...await optionalProcessStartToken(input.pid)
123
+ });
124
+ await writeJson(workerProcessRecordPath(workerDir), record);
125
+ return record;
126
+ }
127
+ export async function clearWorkerProcessRecord(workerDir) {
128
+ await removeIfExists(workerProcessRecordPath(workerDir));
129
+ }
130
+ export async function terminateOwnedWorkerProcess(workerDir) {
131
+ const path = workerProcessRecordPath(workerDir);
132
+ if (!(await pathExists(path))) {
133
+ return "missing";
134
+ }
135
+ const record = await readValidJson(path, WorkerProcessRecordSchema);
136
+ if (!record) {
137
+ return "unverifiable";
138
+ }
139
+ const leaderIsAlive = processIsAlive(record.pid);
140
+ if (!ownedProcessIsAlive(record)) {
141
+ await removeIfExists(path);
142
+ return "not-running";
143
+ }
144
+ if (!record.process_start_token) {
145
+ return "unverifiable";
146
+ }
147
+ if (leaderIsAlive) {
148
+ const currentToken = await readProcessStartToken(record.pid);
149
+ if (!currentToken) {
150
+ return "unverifiable";
151
+ }
152
+ if (currentToken !== record.process_start_token) {
153
+ return "identity-mismatch";
154
+ }
155
+ }
156
+ signalOwnedProcess(record, "SIGTERM");
157
+ if (!(await waitForOwnedProcessExit(record, 1500))) {
158
+ if (!targetsProcessGroup(record)) {
159
+ const tokenBeforeKill = await readProcessStartToken(record.pid);
160
+ if (tokenBeforeKill !== record.process_start_token) {
161
+ return "identity-mismatch";
162
+ }
163
+ }
164
+ signalOwnedProcess(record, "SIGKILL");
165
+ await waitForOwnedProcessExit(record, 500);
166
+ }
167
+ if (!ownedProcessIsAlive(record)) {
168
+ await removeIfExists(path);
169
+ return "terminated";
170
+ }
171
+ return "still-running";
172
+ }
173
+ async function optionalProcessStartToken(pid) {
174
+ const processStartToken = await readProcessStartToken(pid);
175
+ return processStartToken ? { process_start_token: processStartToken } : {};
176
+ }
177
+ async function writeJsonExclusive(path, value) {
178
+ try {
179
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
180
+ return true;
181
+ }
182
+ catch (error) {
183
+ if (error.code === "EEXIST") {
184
+ return false;
185
+ }
186
+ throw error;
187
+ }
188
+ }
189
+ async function acquireTaskRunMutationTurn(taskDir) {
190
+ return acquireProcessMutationTurn(taskDir, {
191
+ intentPrefix: CLAIM_INTENT_PREFIX,
192
+ timeoutMs: CLAIM_INTENT_TIMEOUT_MS,
193
+ pollMs: CLAIM_INTENT_POLL_MS,
194
+ timeoutMessage: "Timed out waiting to update task run ownership."
195
+ });
196
+ }
197
+ async function releaseTaskRunLease(taskDir, path, ownerId) {
198
+ if (!(await pathExists(taskDir))) {
199
+ return;
200
+ }
201
+ const mutationTurn = await acquireTaskRunMutationTurn(taskDir);
202
+ try {
203
+ await removeOwnedLease(path, ownerId);
204
+ }
205
+ finally {
206
+ await mutationTurn.release();
207
+ }
208
+ }
209
+ async function removeOwnedLease(path, ownerId) {
210
+ if (ownerId) {
211
+ const current = await readValidJson(path, TaskRunOwnerSchema);
212
+ if (!current || current.owner_id !== ownerId) {
213
+ return;
214
+ }
215
+ }
216
+ await removeIfExists(path);
217
+ }
218
+ async function readValidJson(path, schema) {
219
+ try {
220
+ return await readJson(path, schema);
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ }
226
+ function signalProcess(pid, signal) {
227
+ try {
228
+ process.kill(pid, signal);
229
+ }
230
+ catch (error) {
231
+ if (error.code !== "ESRCH") {
232
+ throw error;
233
+ }
234
+ }
235
+ }
236
+ function signalOwnedProcess(record, signal) {
237
+ if (targetsProcessGroup(record)) {
238
+ try {
239
+ process.kill(-record.process_group_id, signal);
240
+ return;
241
+ }
242
+ catch (error) {
243
+ if (error.code !== "ESRCH") {
244
+ throw error;
245
+ }
246
+ }
247
+ }
248
+ signalProcess(record.pid, signal);
249
+ }
250
+ function targetsProcessGroup(record) {
251
+ return Boolean(record.process_group_id && process.platform !== "win32");
252
+ }
253
+ function ownedProcessIsAlive(record) {
254
+ return targetsProcessGroup(record)
255
+ ? processGroupIsAlive(record.process_group_id)
256
+ : processIsAlive(record.pid);
257
+ }
258
+ function processGroupIsAlive(processGroupId) {
259
+ try {
260
+ process.kill(-processGroupId, 0);
261
+ return true;
262
+ }
263
+ catch (error) {
264
+ return error.code === "EPERM";
265
+ }
266
+ }
267
+ async function waitForOwnedProcessExit(record, timeoutMs) {
268
+ const deadline = Date.now() + timeoutMs;
269
+ while (Date.now() < deadline) {
270
+ if (!ownedProcessIsAlive(record)) {
271
+ return true;
272
+ }
273
+ await new Promise((resolve) => setTimeout(resolve, 40));
274
+ }
275
+ return !ownedProcessIsAlive(record);
276
+ }
@@ -0,0 +1,90 @@
1
+ export class ProcessTreeCleanupError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = "ProcessTreeCleanupError";
5
+ }
6
+ }
7
+ export async function terminateProcessTree(child, options) {
8
+ const pid = child.pid;
9
+ if (!pid || !processTreeIsAlive(child, options.processGroup)) {
10
+ return;
11
+ }
12
+ const termGraceMs = options.termGraceMs ?? 250;
13
+ const killWaitMs = options.killWaitMs ?? 500;
14
+ const pollMs = options.pollMs ?? 20;
15
+ try {
16
+ if (processGroupLeaderWasReused(child, options.processGroup)) {
17
+ return;
18
+ }
19
+ signalProcessTree(pid, "SIGTERM", options.processGroup);
20
+ if (await waitForProcessTreeExit(child, options.processGroup, termGraceMs, pollMs)) {
21
+ return;
22
+ }
23
+ signalProcessTree(pid, "SIGKILL", options.processGroup);
24
+ if (await waitForProcessTreeExit(child, options.processGroup, killWaitMs, pollMs)) {
25
+ return;
26
+ }
27
+ }
28
+ catch (error) {
29
+ throw new ProcessTreeCleanupError(`Could not terminate ${options.label} ${pid}: ${errorMessage(error)}`);
30
+ }
31
+ throw new ProcessTreeCleanupError(`${options.label} group ${pid} remained alive after SIGKILL.`);
32
+ }
33
+ export function processTreeIsAlive(child, processGroup) {
34
+ const pid = child.pid;
35
+ if (!pid) {
36
+ return false;
37
+ }
38
+ if (child.exitCode !== null || child.signalCode !== null) {
39
+ if (processGroupLeaderWasReused(child, processGroup)) {
40
+ return false;
41
+ }
42
+ return processGroup && signalTargetIsAlive(-pid);
43
+ }
44
+ if (processGroup && signalTargetIsAlive(-pid)) {
45
+ return true;
46
+ }
47
+ return signalTargetIsAlive(pid);
48
+ }
49
+ function processGroupLeaderWasReused(child, processGroup) {
50
+ // POSIX keeps the numeric PID reserved while the original process group still exists.
51
+ return processGroup
52
+ && (child.exitCode !== null || child.signalCode !== null)
53
+ && typeof child.pid === "number"
54
+ && signalTargetIsAlive(child.pid);
55
+ }
56
+ async function waitForProcessTreeExit(child, processGroup, timeoutMs, pollMs) {
57
+ const deadline = Date.now() + timeoutMs;
58
+ while (Date.now() < deadline) {
59
+ if (!processTreeIsAlive(child, processGroup)) {
60
+ return true;
61
+ }
62
+ await delay(pollMs);
63
+ }
64
+ return !processTreeIsAlive(child, processGroup);
65
+ }
66
+ function signalProcessTree(pid, signal, processGroup) {
67
+ try {
68
+ process.kill(processGroup ? -pid : pid, signal);
69
+ }
70
+ catch (error) {
71
+ if (error.code !== "ESRCH") {
72
+ throw error;
73
+ }
74
+ }
75
+ }
76
+ function signalTargetIsAlive(pid) {
77
+ try {
78
+ process.kill(pid, 0);
79
+ return true;
80
+ }
81
+ catch (error) {
82
+ return error.code === "EPERM";
83
+ }
84
+ }
85
+ async function delay(milliseconds) {
86
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
87
+ }
88
+ function errorMessage(error) {
89
+ return error instanceof Error ? error.message : String(error);
90
+ }
@@ -0,0 +1,155 @@
1
+ import { z } from "zod";
2
+ import { RouteDecisionSchema, RouterFailureKindSchema } from "../domain/schemas.js";
3
+ import { readRecentJsonLines } from "./file-store.js";
4
+ import { sanitizeRouterText } from "./router-redaction.js";
5
+ export { RouterFailureKindSchema };
6
+ export const RouterAuditRecordSchema = RouteDecisionSchema.extend({
7
+ time: z.string().datetime(),
8
+ request: z.string(),
9
+ workspace: z.string().min(1),
10
+ scope: z.enum(["initial", "follow-up"]).default("initial"),
11
+ router_timeout_ms: z.number().int().positive().optional(),
12
+ router_first_output_timeout_ms: z.number().int().positive().optional(),
13
+ router_idle_timeout_ms: z.number().int().positive().optional(),
14
+ router_max_output_bytes: z.number().int().min(1024).max(16 * 1024 * 1024).optional(),
15
+ router_max_attempts: z.number().int().min(1).max(3).optional(),
16
+ router_retry_delay_ms: z.number().int().nonnegative().max(10000).optional(),
17
+ proxy_configured: z.boolean().optional(),
18
+ failure_kind: RouterFailureKindSchema.optional()
19
+ });
20
+ export function classifyRouterFailure(reason) {
21
+ const timedOut = /\b(?:timed out|timeout|ETIMEDOUT)\b/i.test(reason);
22
+ const proxyMentioned = /\bproxy\b|代理/i.test(reason);
23
+ const proxyFailure = (/\bproxy(?:\s+(?:connection|connect|handshake|authentication|request|server|transport|tunnel))?\s+(?:authentication|required|failed|failure|error|refused|unreachable|invalid|closed|reset)\b/i.test(reason)
24
+ || /\b(?:failed|unable|cannot|could not)\s+(?:to\s+)?(?:connect|reach|use|negotiate).{0,30}\bproxy\b/i.test(reason)
25
+ || /代理.{0,20}(?:认证|失败|错误|拒绝|无法|不可达)/i.test(reason));
26
+ if (proxyMentioned && timedOut) {
27
+ return "timeout";
28
+ }
29
+ if (proxyFailure) {
30
+ return "proxy";
31
+ }
32
+ if (/\b(?:401|403)\b|\b(?:unauthori[sz]ed|forbidden|authentication|api[-_\s]?key|login required|not logged in|sign in)\b/i.test(reason)) {
33
+ return "auth";
34
+ }
35
+ if (/\b429\b|\b(?:rate[ -]?limit|too many requests|quota (?:exceeded|exhausted)|usage limit)\b/i.test(reason)) {
36
+ return "rate-limit";
37
+ }
38
+ if (/\b(?:ECONNREFUSED|ECONNRESET|ENETUNREACH|EHOSTUNREACH|ENOTFOUND|EAI_AGAIN)\b|\b(?:network|websocket|https transport|fetch failed|certificate|tls|ssl)\b/i.test(reason)) {
39
+ return "network";
40
+ }
41
+ if (timedOut) {
42
+ return "timeout";
43
+ }
44
+ if (/\b(?:ENOENT|command not found|spawn error)\b/i.test(reason)) {
45
+ return "unavailable";
46
+ }
47
+ if (/\b(?:no json object|invalid json|invalid codex router (?:mode|response object)|failed to parse json|unexpected token)\b/i.test(reason)) {
48
+ return "invalid-output";
49
+ }
50
+ if (/\b(?:exited with (?:code|signal)|process exited)\b/i.test(reason)) {
51
+ return "exit";
52
+ }
53
+ if (/\b(?:router input failed|stdin|EPIPE)\b/i.test(reason)) {
54
+ return "input";
55
+ }
56
+ return null;
57
+ }
58
+ export function routerFallbackIsTransient(route) {
59
+ if (route.source !== "fallback") {
60
+ return false;
61
+ }
62
+ if (route.router_timeout_kind === "total") {
63
+ return false;
64
+ }
65
+ if (route.router_timeout_kind === "first-output" || route.router_timeout_kind === "idle") {
66
+ return true;
67
+ }
68
+ const kind = route.router_failure_kind ?? classifyRouterFailure(route.reason);
69
+ if (kind === "network" || kind === "proxy") {
70
+ return true;
71
+ }
72
+ if (kind === "timeout") {
73
+ return route.router_failure_stage !== "response";
74
+ }
75
+ if (kind === "input") {
76
+ return /\b(?:EPIPE|ECONNRESET|temporar(?:y|ily))\b/i.test(route.reason);
77
+ }
78
+ if (kind === "exit") {
79
+ return /\b(?:ECONNRESET|ETIMEDOUT|EAI_AGAIN|temporar(?:y|ily)|try again|connection reset)\b/i.test(route.reason);
80
+ }
81
+ return false;
82
+ }
83
+ export function diagnoseRouterFailure(evidence) {
84
+ const kind = evidence.failure_kind ?? classifyRouterFailure(evidence.reason) ?? "unknown";
85
+ if (kind === "auth") {
86
+ return diagnosis(kind, "Codex authentication failed", "run codex login, then retry Router");
87
+ }
88
+ if (kind === "rate-limit") {
89
+ return diagnosis(kind, "The provider rate limit blocked routing", "wait for quota or change the Router model/provider");
90
+ }
91
+ if (kind === "proxy") {
92
+ return diagnosis(kind, "Router reported a proxy-path failure", "run parallel-codex-tui --doctor --probe-router and verify the proxy upstream");
93
+ }
94
+ if (kind === "network") {
95
+ return diagnosis(kind, "Router reported a network-path failure", "run parallel-codex-tui --doctor --probe-router and verify DNS, TLS, and API reachability");
96
+ }
97
+ if (kind === "unavailable") {
98
+ return diagnosis(kind, evidence.router_failure_stage === "spawn" ? "Router process could not start" : "Router command is unavailable", "run parallel-codex-tui --doctor and fix router.codex.command");
99
+ }
100
+ if (kind === "invalid-output") {
101
+ return diagnosis(kind, "Router returned output that was not valid route JSON", "retry Router; if it repeats, inspect the Router model/provider output");
102
+ }
103
+ if (kind === "input") {
104
+ return diagnosis(kind, "Router process rejected the request input", "check that router.codex.args accepts a prompt on stdin");
105
+ }
106
+ if (kind === "timeout") {
107
+ return diagnoseRouterTimeout(evidence);
108
+ }
109
+ if (kind === "exit") {
110
+ return diagnosis(kind, "Router process exited before a valid route response", "run the configured Codex command directly and inspect its exit output");
111
+ }
112
+ return diagnosis("unknown", "Router failed before a valid route response", "run parallel-codex-tui --doctor --probe-router and inspect the recorded reason");
113
+ }
114
+ function diagnoseRouterTimeout(evidence) {
115
+ if (evidence.router_timeout_kind === "first-output") {
116
+ return diagnosis("timeout", "Router produced no output before the first-output deadline", evidence.proxy_configured
117
+ ? "run parallel-codex-tui --doctor --probe-router; verify Codex login and proxy upstream, or raise router.codex.firstOutputTimeoutMs"
118
+ : "run parallel-codex-tui --doctor --probe-router; verify Codex login and API network path, or raise router.codex.firstOutputTimeoutMs");
119
+ }
120
+ if (evidence.router_timeout_kind === "idle") {
121
+ return diagnosis("timeout", (evidence.router_stdout_bytes ?? 0) > 0
122
+ ? "Router response stopped before valid route JSON completed"
123
+ : "Router diagnostics stopped before a route response", "inspect the reason; retry Router or raise router.codex.idleTimeoutMs");
124
+ }
125
+ if ((evidence.router_stdout_bytes ?? 0) > 0) {
126
+ return diagnosis("timeout", "Router began a route response but did not finish", "retry Router or raise router.codex.timeoutMs");
127
+ }
128
+ if ((evidence.router_stderr_bytes ?? 0) > 0) {
129
+ return diagnosis("timeout", "Router emitted diagnostics but no route response", "inspect the reason, then run parallel-codex-tui --doctor --probe-router");
130
+ }
131
+ if (evidence.router_failure_stage === "waiting-output") {
132
+ return diagnosis("timeout", "Router produced no output before the timeout", evidence.proxy_configured
133
+ ? "run parallel-codex-tui --doctor --probe-router; verify Codex login and proxy upstream"
134
+ : "run parallel-codex-tui --doctor --probe-router; verify Codex login and API network path");
135
+ }
136
+ return diagnosis("timeout", "Router timed out before a valid route response", evidence.proxy_configured
137
+ ? "run parallel-codex-tui --doctor --probe-router; verify Codex login and proxy upstream"
138
+ : "run parallel-codex-tui --doctor --probe-router; verify Codex login and API network path");
139
+ }
140
+ function diagnosis(kind, summary, action) {
141
+ return { kind, summary, action };
142
+ }
143
+ export async function readRouterAudit(path, limit = 100) {
144
+ const boundedLimit = Number.isFinite(limit)
145
+ ? Math.min(500, Math.max(0, Math.trunc(limit)))
146
+ : 100;
147
+ if (boundedLimit === 0) {
148
+ return [];
149
+ }
150
+ return (await readRecentJsonLines(path, RouterAuditRecordSchema, boundedLimit)).map((record) => ({
151
+ ...record,
152
+ request: sanitizeRouterText(record.request),
153
+ reason: sanitizeRouterText(record.reason)
154
+ }));
155
+ }
@@ -0,0 +1,31 @@
1
+ const ROUTER_URL_PATTERN = /\b[a-z][a-z0-9+.-]*:\/\/[^\s<>"'`]+/giu;
2
+ const ROUTER_SECRET_ASSIGNMENT_PATTERN = /\b((?:(?:[a-z][a-z0-9]*_)*(?:api_?key|access_?token|auth_?token|token|password|passwd|secret|client_?secret))\s*(?:=|:)\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)/giu;
3
+ const ROUTER_AUTHORIZATION_PATTERN = /\b((?:authorization\s*:\s*)?(?:bearer|basic)\s+)[^\s,;]+/giu;
4
+ const ROUTER_RAW_TOKEN_PATTERN = /\b(?:sk-[a-z0-9_-]{8,}|npm_[a-z0-9]{20,}|gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,})\b/giu;
5
+ const ROUTER_URL_TRAILING_PUNCTUATION = /[),.;!?,。;!?、]+$/u;
6
+ export function sanitizeRouterText(value) {
7
+ return value
8
+ .replace(ROUTER_URL_PATTERN, sanitizeRouterUrl)
9
+ .replace(ROUTER_AUTHORIZATION_PATTERN, "$1***")
10
+ .replace(ROUTER_SECRET_ASSIGNMENT_PATTERN, "$1***")
11
+ .replace(ROUTER_RAW_TOKEN_PATTERN, "***");
12
+ }
13
+ function sanitizeRouterUrl(value) {
14
+ const trailing = value.match(ROUTER_URL_TRAILING_PUNCTUATION)?.[0] ?? "";
15
+ const candidate = trailing ? value.slice(0, -trailing.length) : value;
16
+ try {
17
+ const parsed = new URL(candidate);
18
+ const credentials = parsed.username || parsed.password ? "***@" : "";
19
+ return `${parsed.protocol}//${credentials}${parsed.host}${trailing}`;
20
+ }
21
+ catch {
22
+ const authority = candidate.match(/^([a-z][a-z0-9+.-]*:\/\/)([^/?#]*)/iu);
23
+ if (!authority) {
24
+ return value.replace(/\b([a-z][a-z0-9+.-]*:\/\/)([^@\s/]+)@/iu, "$1***@");
25
+ }
26
+ const safeAuthority = authority[2]?.includes("@")
27
+ ? `***@${authority[2].slice(authority[2].lastIndexOf("@") + 1)}`
28
+ : authority[2] ?? "";
29
+ return `${authority[1]}${safeAuthority}${trailing}`;
30
+ }
31
+ }