parallel-codex-tui 0.1.4 → 0.1.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.
Files changed (40) hide show
  1. package/.parallel-codex/config.example.toml +46 -0
  2. package/README.md +76 -17
  3. package/dist/cli-startup-preflight.js +18 -0
  4. package/dist/cli-startup-recovery.js +13 -1
  5. package/dist/cli.js +18 -6
  6. package/dist/core/collaboration-timeline.js +9 -2
  7. package/dist/core/config.js +161 -103
  8. package/dist/core/router.js +1 -1
  9. package/dist/core/session-index.js +234 -61
  10. package/dist/core/session-manager.js +25 -1
  11. package/dist/core/task-session-details.js +175 -0
  12. package/dist/core/task-state-machine.js +10 -9
  13. package/dist/doctor.js +58 -39
  14. package/dist/domain/schemas.js +15 -1
  15. package/dist/orchestrator/collaboration-channel.js +35 -3
  16. package/dist/orchestrator/final-acceptance.js +86 -0
  17. package/dist/orchestrator/orchestrator.js +405 -69
  18. package/dist/orchestrator/prompts.js +42 -3
  19. package/dist/orchestrator/workspace-sandbox.js +16 -0
  20. package/dist/tui/App.js +401 -52
  21. package/dist/tui/AppShell.js +9 -3
  22. package/dist/tui/FeatureBoardView.js +7 -2
  23. package/dist/tui/InputBar.js +82 -15
  24. package/dist/tui/StatusBar.js +1 -1
  25. package/dist/tui/StatusDetailView.js +164 -0
  26. package/dist/tui/TaskSessionDetailView.js +222 -0
  27. package/dist/tui/TaskSessionsView.js +5 -2
  28. package/dist/tui/WorkerOutputView.js +1 -0
  29. package/dist/tui/WorkerOverviewView.js +23 -4
  30. package/dist/tui/keyboard.js +3 -0
  31. package/dist/tui/status-line.js +42 -0
  32. package/dist/version.js +1 -1
  33. package/dist/workers/capabilities.js +4 -3
  34. package/dist/workers/live-probe.js +4 -3
  35. package/dist/workers/mock-adapter.js +37 -5
  36. package/dist/workers/native-attach.js +32 -17
  37. package/dist/workers/process-adapter.js +2 -0
  38. package/dist/workers/provider.js +26 -0
  39. package/dist/workers/registry.js +12 -22
  40. package/package.json +7 -1
@@ -105,7 +105,7 @@ function taskSessionSummary(tasks, width) {
105
105
  const group = taskSessionStatusGroup(task.status);
106
106
  counts.set(group, (counts.get(group) ?? 0) + 1);
107
107
  }
108
- const parts = ["running", "done", "failed", "cancelled"].flatMap((group) => {
108
+ const parts = ["running", "paused", "done", "failed", "cancelled"].flatMap((group) => {
109
109
  const count = counts.get(group) ?? 0;
110
110
  return count > 0 ? [`${count} ${group}`] : [];
111
111
  });
@@ -149,7 +149,7 @@ function clampTaskIndex(index, count) {
149
149
  return Math.min(Math.max(0, count - 1), Math.max(0, Math.trunc(index)));
150
150
  }
151
151
  function taskSessionStatusGroup(status) {
152
- if (status === "done" || status === "failed" || status === "cancelled") {
152
+ if (status === "paused" || status === "done" || status === "failed" || status === "cancelled") {
153
153
  return status;
154
154
  }
155
155
  return "running";
@@ -165,6 +165,9 @@ function taskSessionStatusTone(task) {
165
165
  if (status === "done") {
166
166
  return "success";
167
167
  }
168
+ if (status === "paused") {
169
+ return "warning";
170
+ }
168
171
  if (status === "failed") {
169
172
  return "danger";
170
173
  }
@@ -2882,6 +2882,7 @@ function shouldDropNoisyProcessLine(trimmed, line) {
2882
2882
  /^202\d-\d\d-\d\dT.*codex_models_manager::manager/.test(trimmed) ||
2883
2883
  /^reasoning summaries:/i.test(trimmed) ||
2884
2884
  /^session id:/i.test(trimmed) ||
2885
+ /^collab:\s*(?:wait|waiting)$/i.test(trimmed) ||
2885
2886
  /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]$/.test(trimmed) ||
2886
2887
  line === "--------");
2887
2888
  }
@@ -158,18 +158,37 @@ function workerOverviewWorkerText(worker, selected, width) {
158
158
  const marker = selected ? "> " : " ";
159
159
  const label = safeWorkerOverviewText(worker.label);
160
160
  const state = worker.runtimeStatus?.state ?? "waiting";
161
+ const turn = workerOverviewTurnLabel(worker);
162
+ const model = workerOverviewModelLabel(worker);
161
163
  const phase = humanizeWorkerOverviewPhase(worker.runtimeStatus?.phase ?? "status pending");
162
164
  const session = worker.runtimeStatus?.native_session_id ? "session" : "";
163
165
  const summary = safeWorkerOverviewText(worker.runtimeStatus?.summary ?? "");
164
166
  const identity = `${worker.role}/${worker.engine}`;
165
167
  return fitWorkerOverviewCandidates([
166
- [marker + label, state, phase, session, summary].filter(Boolean).join(" · "),
167
- [marker + label, state, phase, session].filter(Boolean).join(" · "),
168
- [marker + label, state, session].filter(Boolean).join(" · "),
169
- [marker + identity, state].join(" · "),
168
+ [marker + label, turn, state, model, phase, session, summary].filter(Boolean).join(" · "),
169
+ [marker + label, turn, state, model, phase, session].filter(Boolean).join(" · "),
170
+ [marker + label, turn, state, model, session].filter(Boolean).join(" · "),
171
+ [marker + identity, turn, state, model].filter(Boolean).join(" · "),
172
+ [marker + identity, turn, state].filter(Boolean).join(" · "),
170
173
  marker.trimEnd()
171
174
  ], width);
172
175
  }
176
+ export function workerOverviewTurnLabel(worker) {
177
+ if (worker.role === "main") {
178
+ return "";
179
+ }
180
+ const suffix = worker.id.startsWith(`${worker.role}-${worker.engine}`)
181
+ ? worker.id.slice(`${worker.role}-${worker.engine}`.length).replace(/^-/, "")
182
+ : "";
183
+ const turn = suffix.match(/^(?:final-|wave-)?(\d{4})(?:-|$)/)?.[1];
184
+ return `turn ${Number(turn ?? "0001")}`;
185
+ }
186
+ function workerOverviewModelLabel(worker) {
187
+ const provider = safeWorkerOverviewText(worker.runtimeStatus?.model_provider ?? "");
188
+ const model = safeWorkerOverviewText(worker.runtimeStatus?.model_name ?? "");
189
+ const selection = [provider, model].filter(Boolean).join("/");
190
+ return selection ? `model ${selection}` : "";
191
+ }
173
192
  function workerOverviewWindowStart(selected, count, visibleCount) {
174
193
  if (visibleCount <= 0 || count <= visibleCount) {
175
194
  return 0;
@@ -25,6 +25,9 @@ export function isTaskSessionsShortcut(input, key) {
25
25
  export function isTaskResultShortcut(input, key) {
26
26
  return (key.ctrl === true && input.toLowerCase() === "d") || input === "\u0004";
27
27
  }
28
+ export function isStatusDetailsShortcut(input, key) {
29
+ return (key.ctrl === true && input.toLowerCase() === "s") || input === "\u0013";
30
+ }
28
31
  export function isWorkerSearchShortcut(input, key) {
29
32
  return (key.ctrl === true && input.toLowerCase() === "f") || input === "\u0006";
30
33
  }
@@ -129,6 +129,45 @@ export function formatRouteStatus(route) {
129
129
  }
130
130
  return `route ${details.join(" · ")}`;
131
131
  }
132
+ export function formatRouteSummaryStatus(route) {
133
+ if (!route) {
134
+ return "";
135
+ }
136
+ const details = [route.mode];
137
+ if (route.source === "forced") {
138
+ details.push("forced");
139
+ }
140
+ else if (route.source === "fallback") {
141
+ details.push("fallback");
142
+ const cause = route.router_failure_kind && route.router_failure_kind !== "unknown"
143
+ ? route.router_failure_kind
144
+ : classifyRouterFailure(route.reason) ?? "unknown";
145
+ details.push(cause === "timeout" ? "timeout" : cause.replaceAll("-", " "));
146
+ }
147
+ else if (route.router_recovered_from) {
148
+ details.push("recovered");
149
+ }
150
+ return `route ${details.join(" · ")}`;
151
+ }
152
+ export function formatRoutePendingSummaryStatus(state, elapsedMs) {
153
+ if (!state) {
154
+ return "";
155
+ }
156
+ if (state.mode !== "auto") {
157
+ return `route ${state.mode} · forced`;
158
+ }
159
+ if (state.phase === "retrying") {
160
+ return `route retry ${state.attempt}/${state.maxAttempts}`;
161
+ }
162
+ const details = [routePendingPhaseLabel(state)];
163
+ if (state.attempt > 1) {
164
+ details.push(`try ${state.attempt}`);
165
+ }
166
+ if (typeof elapsedMs === "number") {
167
+ details.push(formatRouteElapsed(Math.max(0, elapsedMs)));
168
+ }
169
+ return `route ${details.join(" · ")}`;
170
+ }
132
171
  function routeRecoveryLabel(route) {
133
172
  if (!route.router_recovered_from) {
134
173
  return null;
@@ -378,6 +417,9 @@ function compactStatus(status) {
378
417
  if (state === "cancelled" || state === "canceled") {
379
418
  return "stop";
380
419
  }
420
+ if (state === "paused") {
421
+ return "wait";
422
+ }
381
423
  if (state === "waiting" || state === "queued") {
382
424
  return "wait";
383
425
  }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "0.1.4";
1
+ export const version = "0.1.5";
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { basename } from "node:path";
3
+ import { workerProvider } from "./provider.js";
3
4
  export async function diagnoseAgentCapabilities(config, env, options) {
4
5
  const targets = capabilityTargets(config, options);
5
6
  const runner = options.runner ?? runCapabilityCommand;
@@ -33,7 +34,7 @@ function capabilityTargets(config, options) {
33
34
  }, "automated");
34
35
  }
35
36
  for (const engine of [...new Set(options.workerEngines)]) {
36
- const worker = config.workers[engine];
37
+ const worker = workerProvider(config, engine).config;
37
38
  addTarget(engine, worker.command, worker.capabilities, "automated");
38
39
  if (worker.nativeSession.enabled) {
39
40
  addTarget(engine, worker.command, worker.capabilities, "resume");
@@ -79,7 +80,7 @@ async function diagnoseCapabilityTarget(target, config, env, runner, timeoutMs)
79
80
  }
80
81
  }
81
82
  if (target.capabilities.profile === "codex" && target.surfaces.has("native")) {
82
- const sandbox = configuredCodexSandbox(config.workers.codex.interactive.args);
83
+ const sandbox = configuredCodexSandbox(workerProvider(config, target.engine).config.interactive.args);
83
84
  if (sandbox === "read-only") {
84
85
  incompatible.push("native resume uses read-only but feature attach requires writable --add-dir roots");
85
86
  }
@@ -139,7 +140,7 @@ function capabilityProbeSpecs(target) {
139
140
  return specs;
140
141
  }
141
142
  function configuredCapabilityIssues(target, config) {
142
- const worker = config.workers[target.engine];
143
+ const worker = workerProvider(config, target.engine).config;
143
144
  const issues = [];
144
145
  if (target.surfaces.has("resume")
145
146
  && worker.nativeSession.enabled
@@ -3,12 +3,13 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { ensureDir, readTextIfExists, writeText } from "../core/file-store.js";
5
5
  import { createWorkerRegistry, getAdapter } from "./registry.js";
6
+ import { workerProvider } from "./provider.js";
6
7
  export async function runLiveAgentProbes(config, workspaceRoot, engines, options = {}) {
7
8
  const activeEngines = [...new Set(engines)];
8
9
  if (activeEngines.length === 0) {
9
10
  return {
10
11
  ok: true,
11
- lines: ["agent live probe: skipped (no Codex or Claude workers active)"]
12
+ lines: ["agent live probe: skipped (no process Worker providers active)"]
12
13
  };
13
14
  }
14
15
  const probesRoot = join(workspaceRoot, config.dataDir, "probes");
@@ -39,7 +40,7 @@ export async function runLiveAgentProbes(config, workspaceRoot, engines, options
39
40
  }
40
41
  async function probeEngine(config, registry, engine, runRoot, nonce, timeoutOverride) {
41
42
  const startedAt = Date.now();
42
- const worker = config.workers[engine];
43
+ const worker = workerProvider(config, engine).config;
43
44
  const adapter = getAdapter(registry, engine);
44
45
  const cwd = join(runRoot, `${engine}-workspace`);
45
46
  await mkdir(cwd, { recursive: true });
@@ -106,7 +107,7 @@ async function probeEngine(config, registry, engine, runRoot, nonce, timeoutOver
106
107
  };
107
108
  }
108
109
  async function probeSpec(input) {
109
- const worker = input.config.workers[input.engine];
110
+ const worker = workerProvider(input.config, input.engine).config;
110
111
  await mkdir(input.dir, { recursive: true });
111
112
  const promptPath = join(input.dir, "prompt.md");
112
113
  await writeText(promptPath, `${input.challenge.prompt}\n`);
@@ -11,11 +11,28 @@ export class MockWorkerAdapter {
11
11
  await setStatus(spec, "running", "mock-running", `${spec.role} mock worker running`, nativeSessionId);
12
12
  await appendText(spec.outputLogPath, `[mock:${spec.role}] started\n`);
13
13
  if (spec.role === "judge") {
14
- await writeText(join(spec.filesDir, "requirements.md"), "# Requirements\n\n- [R-001] Mock requirements derived from the user request.\n");
15
- await writeText(join(spec.filesDir, "plan.md"), "# Plan\n\n1. [P-001] Implement the scoped change.\n2. [P-002] Run focused verification.\n");
16
- await writeText(join(spec.filesDir, "acceptance.md"), "# Acceptance\n\n- [A-001] [R-001] Focused tests pass for the requested behavior.\n");
17
- await writeText(join(spec.filesDir, "actor-brief.md"), "# Actor Brief\n\nImplement the requested change and write a worklog.\n");
18
- await writeText(join(spec.filesDir, "critic-brief.md"), "# Critic Brief\n\nReview the Actor output against acceptance criteria.\n");
14
+ if (spec.prompt.includes("# Role:") && spec.prompt.includes("· Final acceptance")) {
15
+ const criterionIds = promptJsonArray(spec.prompt, "Required acceptance criterion ids");
16
+ const changedPaths = promptJsonArray(spec.prompt, "Authoritative changed paths");
17
+ await writeJson(join(spec.filesDir, "final-acceptance.json"), {
18
+ version: 1,
19
+ decision: "approved",
20
+ summary: "Mock Final Judge verified every acceptance criterion.",
21
+ acceptance: criterionIds.map((criterionId) => ({
22
+ criterion_id: criterionId,
23
+ status: "passed",
24
+ evidence: "Mock verification completed."
25
+ })),
26
+ changed_paths: changedPaths
27
+ });
28
+ }
29
+ else {
30
+ await writeText(join(spec.filesDir, "requirements.md"), "# Requirements\n\n- [R-001] Mock requirements derived from the user request.\n");
31
+ await writeText(join(spec.filesDir, "plan.md"), "# Plan\n\n1. [P-001] Implement the scoped change.\n2. [P-002] Run focused verification.\n");
32
+ await writeText(join(spec.filesDir, "acceptance.md"), "# Acceptance\n\n- [A-001] [R-001] Focused tests pass for the requested behavior.\n");
33
+ await writeText(join(spec.filesDir, "actor-brief.md"), "# Actor Brief\n\nImplement the requested change and write a worklog.\n");
34
+ await writeText(join(spec.filesDir, "critic-brief.md"), "# Critic Brief\n\nReview the Actor output against acceptance criteria.\n");
35
+ }
19
36
  }
20
37
  if (spec.role === "actor") {
21
38
  await writeText(join(spec.filesDir, "worklog.md"), "# Worklog\n\n- Mock actor completed the implementation.\n");
@@ -69,6 +86,19 @@ function featureDirFromPrompt(prompt) {
69
86
  const line = prompt.split("\n").find((item) => item.startsWith("Feature directory: "));
70
87
  return line ? line.replace("Feature directory: ", "").trim() : null;
71
88
  }
89
+ function promptJsonArray(prompt, label) {
90
+ const line = prompt.split("\n").find((item) => item.startsWith(`${label}: `));
91
+ if (!line) {
92
+ return [];
93
+ }
94
+ try {
95
+ const value = JSON.parse(line.slice(label.length + 2));
96
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
97
+ }
98
+ catch {
99
+ return [];
100
+ }
101
+ }
72
102
  async function setStatus(spec, state, phase, summary, nativeSessionId) {
73
103
  const status = {
74
104
  worker_id: spec.workerId,
@@ -76,6 +106,8 @@ async function setStatus(spec, state, phase, summary, nativeSessionId) {
76
106
  ...(spec.featureTitle ? { feature_title: spec.featureTitle } : {}),
77
107
  role: spec.role,
78
108
  engine: spec.engine,
109
+ ...(spec.modelConfig?.name.trim() ? { model_name: spec.modelConfig.name.trim() } : {}),
110
+ ...(spec.modelConfig?.provider.trim() ? { model_provider: spec.modelConfig.provider.trim() } : {}),
79
111
  state,
80
112
  phase,
81
113
  last_event_at: new Date().toISOString(),
@@ -7,12 +7,26 @@ import { spawn } from "node-pty";
7
7
  import { pathExists, pathIsDirectory, readJson, readTextIfExists, removeIfExists, writeJson } from "../core/file-store.js";
8
8
  import { NativeSessionSchema, TaskMetaSchema } from "../domain/schemas.js";
9
9
  import { detectResumeSessionId } from "./native-session-detection.js";
10
+ import { workerProvider } from "./provider.js";
10
11
  const require = createRequire(import.meta.url);
11
12
  export async function buildNativeAttachLaunch(input) {
12
- const nativeSession = await readWorkerNativeSession(input.worker);
13
- const workerConfig = input.config.workers[input.worker.engine];
13
+ return buildNativeSessionLaunch(input, "resume");
14
+ }
15
+ export async function buildNativeForkLaunch(input) {
16
+ return buildNativeSessionLaunch(input, "fork");
17
+ }
18
+ async function buildNativeSessionLaunch(input, mode) {
19
+ const workerConfig = workerProvider(input.config, input.worker.engine).config;
20
+ const nativeSession = await readWorkerNativeSession(input.worker, workerConfig.capabilities.profile);
14
21
  const modelConfig = workerConfig.model;
15
22
  const env = modelEnvironment(modelConfig);
23
+ const interactiveArgs = mode === "fork"
24
+ ? workerConfig.interactive.forkArgs
25
+ : workerConfig.interactive.args;
26
+ if (mode === "fork" && interactiveArgs.length === 0) {
27
+ throw new Error(`Native session fork is not configured for ${input.worker.engine}. `
28
+ + `Set workers.${input.worker.engine}.interactive.forkArgs with a {sessionId} template.`);
29
+ }
16
30
  const workerDir = dirname(input.worker.statusPath);
17
31
  const taskDir = dirname(workerDir);
18
32
  if (!(await pathExists(nativeSession.cwd))) {
@@ -34,16 +48,17 @@ export async function buildNativeAttachLaunch(input) {
34
48
  command: workerConfig.interactive.command,
35
49
  args: nativeAttachArgs({
36
50
  args: [
37
- ...workerConfig.interactive.args,
51
+ ...interactiveArgs,
38
52
  ...modelConfig.args
39
53
  ].map((arg) => renderTemplate(arg, nativeSession.session_id, modelConfig)),
54
+ providerId: input.worker.engine,
40
55
  capabilities: workerConfig.capabilities,
41
56
  additionalDirs
42
57
  }),
43
58
  ...(Object.keys(env).length > 0 ? { env } : {}),
44
59
  cwd: nativeSession.cwd,
45
60
  sessionId: nativeSession.session_id,
46
- label: input.worker.label
61
+ label: mode === "fork" ? `${input.worker.label} · fork` : input.worker.label
47
62
  };
48
63
  }
49
64
  function nativeAttachArgs(input) {
@@ -51,18 +66,18 @@ function nativeAttachArgs(input) {
51
66
  return input.args;
52
67
  }
53
68
  const args = input.capabilities.profile === "codex"
54
- ? withCodexWritableSandbox(input.args)
69
+ ? withCodexWritableSandbox(input.args, input.providerId)
55
70
  : input.args;
56
71
  return [
57
72
  ...args,
58
73
  ...[...new Set(input.additionalDirs)].flatMap((directory) => (input.capabilities.writableDirArgs.map((arg) => arg.replaceAll("{dir}", directory))))
59
74
  ];
60
75
  }
61
- function withCodexWritableSandbox(args) {
76
+ function withCodexWritableSandbox(args, providerId) {
62
77
  const sandbox = codexSandboxSelection(args);
63
78
  if (sandbox === "read-only") {
64
79
  throw new Error("Codex native attach cannot use recorded worker directories with a read-only sandbox. "
65
- + "Set workers.codex.interactive.args to workspace-write or danger-full-access.");
80
+ + `Set workers.${providerId}.interactive.args to workspace-write or danger-full-access.`);
66
81
  }
67
82
  return sandbox ? args : [...args, "--sandbox", "workspace-write"];
68
83
  }
@@ -144,17 +159,17 @@ function ensureNodePtySpawnHelperExecutable() {
144
159
  chmodSync(helperPath, 0o755);
145
160
  }
146
161
  }
147
- async function readWorkerNativeSession(worker) {
162
+ async function readWorkerNativeSession(worker, profile) {
148
163
  const workerDir = dirname(worker.statusPath);
149
164
  const nativePath = join(workerDir, "native-session.json");
150
165
  const record = await readAttachNativeSessionIfValid(nativePath);
151
166
  if (!record) {
152
- const recoveredCodex = await recoverCodexNativeSession(worker, workerDir);
167
+ const recoveredCodex = await recoverCodexNativeSession(worker, workerDir, profile);
153
168
  if (recoveredCodex) {
154
169
  await writeJson(nativePath, NativeSessionSchema.parse(recoveredCodex));
155
170
  return recoveredCodex;
156
171
  }
157
- const recovered = await recoverClaudeNativeSession(worker, workerDir);
172
+ const recovered = await recoverClaudeNativeSession(worker, workerDir, profile);
158
173
  if (recovered) {
159
174
  await writeJson(nativePath, NativeSessionSchema.parse(recovered));
160
175
  return recovered;
@@ -181,8 +196,8 @@ async function readAttachNativeSessionIfValid(nativePath) {
181
196
  return null;
182
197
  }
183
198
  }
184
- async function recoverCodexNativeSession(worker, workerDir) {
185
- if (worker.engine !== "codex") {
199
+ async function recoverCodexNativeSession(worker, workerDir, profile) {
200
+ if (profile !== "codex") {
186
201
  return null;
187
202
  }
188
203
  const taskDir = dirname(workerDir);
@@ -197,7 +212,7 @@ async function recoverCodexNativeSession(worker, workerDir) {
197
212
  }
198
213
  const now = new Date().toISOString();
199
214
  return {
200
- engine: "codex",
215
+ engine: worker.engine,
201
216
  role: worker.role,
202
217
  worker_id: worker.id,
203
218
  session_id: sessionId,
@@ -208,8 +223,8 @@ async function recoverCodexNativeSession(worker, workerDir) {
208
223
  source: "output-detected"
209
224
  };
210
225
  }
211
- async function recoverClaudeNativeSession(worker, workerDir) {
212
- if (worker.engine !== "claude") {
226
+ async function recoverClaudeNativeSession(worker, workerDir, profile) {
227
+ if (profile !== "claude") {
213
228
  return null;
214
229
  }
215
230
  const prompt = await readTextIfExists(join(workerDir, "prompt.md"));
@@ -229,7 +244,7 @@ async function recoverClaudeNativeSession(worker, workerDir) {
229
244
  return null;
230
245
  }
231
246
  return {
232
- engine: "claude",
247
+ engine: worker.engine,
233
248
  role: worker.role,
234
249
  worker_id: worker.id,
235
250
  session_id: match.sessionId,
@@ -324,5 +339,5 @@ function renderTemplate(value, sessionId, modelConfig) {
324
339
  .replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => process.env[name] ?? "");
325
340
  }
326
341
  export function supportsNativeAttach(engine) {
327
- return engine === "codex" || engine === "claude" || engine === "mock";
342
+ return Boolean(engine);
328
343
  }
@@ -745,6 +745,8 @@ async function setStatus(spec, state, phase, summary, nativeSessionId) {
745
745
  ...(spec.featureTitle ? { feature_title: spec.featureTitle } : {}),
746
746
  role: spec.role,
747
747
  engine: spec.engine,
748
+ ...(spec.modelConfig?.name.trim() ? { model_name: spec.modelConfig.name.trim() } : {}),
749
+ ...(spec.modelConfig?.provider.trim() ? { model_provider: spec.modelConfig.provider.trim() } : {}),
748
750
  state,
749
751
  phase,
750
752
  last_event_at: new Date().toISOString(),
@@ -0,0 +1,26 @@
1
+ export function workerProvider(config, id) {
2
+ const provider = config.workers[id];
3
+ if (!provider) {
4
+ throw new Error(`Worker provider is not configured: ${id}`);
5
+ }
6
+ return { id, config: provider };
7
+ }
8
+ export function workerProviders(config) {
9
+ return Object.entries(config.workers)
10
+ .map(([id, provider]) => ({ id, config: provider }))
11
+ .sort((left, right) => left.id.localeCompare(right.id));
12
+ }
13
+ export function assignableWorkerProviderIds(config) {
14
+ return workerProviders(config)
15
+ .filter((provider) => provider.config.assignable)
16
+ .map((provider) => provider.id);
17
+ }
18
+ export function workerProviderLabel(config, id) {
19
+ const provider = config.workers[id];
20
+ if (!provider) {
21
+ return id;
22
+ }
23
+ const model = provider.model.name.trim();
24
+ const remote = provider.model.provider.trim();
25
+ return [id, model, remote].filter(Boolean).join("/");
26
+ }
@@ -1,29 +1,19 @@
1
1
  import { MockWorkerAdapter } from "./mock-adapter.js";
2
2
  import { ProcessWorkerAdapter } from "./process-adapter.js";
3
+ import { workerProviders } from "./provider.js";
3
4
  export function createWorkerRegistry(config) {
4
- return new Map([
5
- ["mock", new MockWorkerAdapter()],
6
- [
7
- "codex",
8
- new ProcessWorkerAdapter(config.workers.codex.command, config.workers.codex.args, "codex", {
9
- timeoutMs: config.workers.codex.timeoutMs,
10
- idleTimeoutMs: config.workers.codex.idleTimeoutMs,
11
- firstOutputTimeoutMs: config.workers.codex.firstOutputTimeoutMs,
12
- model: config.workers.codex.model,
13
- capabilities: config.workers.codex.capabilities
5
+ return new Map(workerProviders(config).map(({ id, config: provider }) => [
6
+ id,
7
+ id === "mock"
8
+ ? new MockWorkerAdapter()
9
+ : new ProcessWorkerAdapter(provider.command, provider.args, id, {
10
+ timeoutMs: provider.timeoutMs,
11
+ idleTimeoutMs: provider.idleTimeoutMs,
12
+ firstOutputTimeoutMs: provider.firstOutputTimeoutMs,
13
+ model: provider.model,
14
+ capabilities: provider.capabilities
14
15
  })
15
- ],
16
- [
17
- "claude",
18
- new ProcessWorkerAdapter(config.workers.claude.command, config.workers.claude.args, "claude", {
19
- timeoutMs: config.workers.claude.timeoutMs,
20
- idleTimeoutMs: config.workers.claude.idleTimeoutMs,
21
- firstOutputTimeoutMs: config.workers.claude.firstOutputTimeoutMs,
22
- model: config.workers.claude.model,
23
- capabilities: config.workers.claude.capabilities
24
- })
25
- ]
26
- ]);
16
+ ]));
27
17
  }
28
18
  export function getAdapter(registry, engine) {
29
19
  const adapter = registry.get(engine);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parallel-codex-tui",
3
- "version": "0.1.4",
3
+ "version": "0.1.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",
@@ -40,6 +40,7 @@
40
40
  "build": "tsc -p tsconfig.json",
41
41
  "postbuild": "node -e \"require('node:fs').chmodSync('dist/cli.js', 0o755)\"",
42
42
  "prepack": "npm run build",
43
+ "pretest": "node scripts/fix-node-pty-permissions.mjs",
43
44
  "verify:package": "node scripts/verify-package.mjs",
44
45
  "test": "vitest run",
45
46
  "test:watch": "vitest",
@@ -64,5 +65,10 @@
64
65
  "tsx": "^4.19.0",
65
66
  "typescript": "^5.8.0",
66
67
  "vitest": "^3.2.0"
68
+ },
69
+ "allowScripts": {
70
+ "esbuild@0.28.1": true,
71
+ "fsevents@2.3.3": true,
72
+ "node-pty@1.1.0": true
67
73
  }
68
74
  }