parallel-codex-tui 0.1.3 → 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 (78) hide show
  1. package/.parallel-codex/config.example.toml +136 -3
  2. package/README.md +299 -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-preflight.js +18 -0
  7. package/dist/cli-startup-recovery.js +82 -0
  8. package/dist/cli-workspace-picker.js +330 -0
  9. package/dist/cli-workspace-transition.js +33 -0
  10. package/dist/cli-workspace.js +7 -71
  11. package/dist/cli.js +234 -24
  12. package/dist/core/collaboration-timeline.js +268 -0
  13. package/dist/core/config-errors.js +14 -0
  14. package/dist/core/config.js +297 -109
  15. package/dist/core/file-store.js +119 -6
  16. package/dist/core/lease-finalization.js +22 -0
  17. package/dist/core/paths.js +7 -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 +462 -35
  25. package/dist/core/session-index.js +412 -88
  26. package/dist/core/session-manager.js +1110 -40
  27. package/dist/core/task-session-details.js +175 -0
  28. package/dist/core/task-state-machine.js +18 -0
  29. package/dist/core/workspace-commit-recovery.js +118 -0
  30. package/dist/core/workspace.js +19 -11
  31. package/dist/doctor.js +373 -34
  32. package/dist/domain/schemas.js +142 -7
  33. package/dist/orchestrator/collaboration-channel.js +289 -6
  34. package/dist/orchestrator/feature-plan.js +70 -0
  35. package/dist/orchestrator/final-acceptance.js +86 -0
  36. package/dist/orchestrator/judge-artifacts.js +236 -0
  37. package/dist/orchestrator/orchestrator.js +2086 -203
  38. package/dist/orchestrator/prompts.js +168 -5
  39. package/dist/orchestrator/supervisor-summary.js +56 -2
  40. package/dist/orchestrator/workspace-sandbox.js +927 -0
  41. package/dist/tui/App.js +3187 -161
  42. package/dist/tui/AppShell.js +196 -25
  43. package/dist/tui/CollaborationTimelineView.js +327 -0
  44. package/dist/tui/FeatureBoardView.js +232 -0
  45. package/dist/tui/InputBar.js +581 -57
  46. package/dist/tui/RouterDiagnosticsView.js +469 -0
  47. package/dist/tui/StatusBar.js +610 -57
  48. package/dist/tui/StatusDetailView.js +164 -0
  49. package/dist/tui/TaskSessionDetailView.js +222 -0
  50. package/dist/tui/TaskSessionsView.js +210 -0
  51. package/dist/tui/TerminalOutput.js +53 -9
  52. package/dist/tui/WorkerOutputView.js +1404 -161
  53. package/dist/tui/WorkerOverviewView.js +269 -0
  54. package/dist/tui/chat-history.js +25 -0
  55. package/dist/tui/chat-input.js +67 -19
  56. package/dist/tui/chat-paste.js +76 -0
  57. package/dist/tui/display-width.js +41 -3
  58. package/dist/tui/incremental-text-file.js +101 -0
  59. package/dist/tui/keyboard.js +49 -0
  60. package/dist/tui/markdown-text.js +14 -0
  61. package/dist/tui/raw-input-decoder.js +3 -0
  62. package/dist/tui/scrolling.js +2 -1
  63. package/dist/tui/status-line.js +360 -11
  64. package/dist/tui/task-memory.js +13 -1
  65. package/dist/tui/task-result.js +105 -0
  66. package/dist/tui/terminal-screen.js +13 -1
  67. package/dist/tui/theme-contrast.js +144 -0
  68. package/dist/tui/theme-preview.js +109 -0
  69. package/dist/tui/theme.js +158 -0
  70. package/dist/version.js +1 -1
  71. package/dist/workers/capabilities.js +213 -0
  72. package/dist/workers/live-probe.js +177 -0
  73. package/dist/workers/mock-adapter.js +73 -8
  74. package/dist/workers/native-attach.js +106 -16
  75. package/dist/workers/process-adapter.js +572 -77
  76. package/dist/workers/provider.js +26 -0
  77. package/dist/workers/registry.js +12 -20
  78. package/package.json +11 -2
@@ -0,0 +1,175 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { NativeSessionSchema, RetiredNativeSessionSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
4
+ import { pathExists, readJson, readTextIfExists } from "./file-store.js";
5
+ export async function loadTaskSessionDetails(input) {
6
+ const [turns, workers] = await Promise.all([
7
+ readTaskTurns(input.taskDir),
8
+ readTaskWorkers(input.taskDir, input.modelNames ?? {})
9
+ ]);
10
+ const turnById = new Map(turns.map((turn) => [turn.turnId, turn]));
11
+ for (const worker of workers) {
12
+ let turn = turnById.get(worker.turnId);
13
+ if (!turn) {
14
+ turn = {
15
+ turnId: worker.turnId,
16
+ createdAt: worker.lastActivityAt,
17
+ request: "",
18
+ workers: []
19
+ };
20
+ turnById.set(worker.turnId, turn);
21
+ turns.push(turn);
22
+ }
23
+ turn.workers.push(worker);
24
+ }
25
+ turns.sort((left, right) => left.turnId.localeCompare(right.turnId));
26
+ for (const turn of turns) {
27
+ turn.workers.sort(compareTaskSessionWorkers);
28
+ }
29
+ return {
30
+ task: input.task,
31
+ projectName: basename(input.task.cwd) || input.task.cwd,
32
+ projectPath: input.task.cwd,
33
+ turns,
34
+ workers
35
+ };
36
+ }
37
+ async function readTaskTurns(taskDir) {
38
+ const turnsDir = join(taskDir, "turns");
39
+ let entries;
40
+ try {
41
+ entries = await readdir(turnsDir, { withFileTypes: true });
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ const turns = await Promise.all(entries
47
+ .filter((entry) => entry.isDirectory() && /^\d{4}$/.test(entry.name))
48
+ .map(async (entry) => {
49
+ const dir = join(turnsDir, entry.name);
50
+ try {
51
+ const meta = await readJson(join(dir, "turn.json"), TurnMetaSchema);
52
+ if (meta.turn_id !== entry.name) {
53
+ return null;
54
+ }
55
+ return {
56
+ turnId: meta.turn_id,
57
+ createdAt: meta.created_at,
58
+ request: compactTaskRequest(await readTextIfExists(join(dir, "user.md"))),
59
+ workers: []
60
+ };
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }));
66
+ return turns
67
+ .filter((turn) => turn !== null)
68
+ .sort((left, right) => left.turnId.localeCompare(right.turnId));
69
+ }
70
+ async function readTaskWorkers(taskDir, modelNames) {
71
+ let entries;
72
+ try {
73
+ entries = await readdir(taskDir, { withFileTypes: true });
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ const workers = await Promise.all(entries
79
+ .filter((entry) => entry.isDirectory())
80
+ .map(async (entry) => {
81
+ const dir = join(taskDir, entry.name);
82
+ const statusPath = join(dir, "status.json");
83
+ if (!(await pathExists(statusPath))) {
84
+ return null;
85
+ }
86
+ try {
87
+ const status = await readJson(statusPath, WorkerStatusSchema);
88
+ const nativeSession = await readActiveNativeSession(dir, status.worker_id);
89
+ return {
90
+ id: status.worker_id,
91
+ turnId: taskSessionWorkerTurnId(status.worker_id, status.feature_id),
92
+ ...(status.feature_id ? { featureId: status.feature_id } : {}),
93
+ ...(status.feature_title ? { featureTitle: status.feature_title } : {}),
94
+ role: status.role,
95
+ engine: status.engine,
96
+ model: status.model_name?.trim() || modelNames[status.engine]?.trim() || "",
97
+ ...(status.model_provider?.trim() ? { modelProvider: status.model_provider.trim() } : {}),
98
+ state: status.state,
99
+ phase: status.phase,
100
+ summary: status.summary,
101
+ lastActivityAt: laterTimestamp(status.last_event_at, nativeSession?.lastUsedAt),
102
+ dir,
103
+ statusPath,
104
+ outputLogPath: join(dir, "output.log"),
105
+ nativeSession
106
+ };
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }));
112
+ return workers
113
+ .filter((worker) => worker !== null)
114
+ .sort((left, right) => (left.turnId.localeCompare(right.turnId)
115
+ || compareTaskSessionWorkers(left, right)));
116
+ }
117
+ async function readActiveNativeSession(workerDir, workerId) {
118
+ const activePath = join(workerDir, "native-session.json");
119
+ if (!(await pathExists(activePath))) {
120
+ return null;
121
+ }
122
+ try {
123
+ const active = await readJson(activePath, NativeSessionSchema);
124
+ if (active.worker_id !== workerId) {
125
+ return null;
126
+ }
127
+ const retiredPath = join(workerDir, "native-session.retired.json");
128
+ if (await pathExists(retiredPath)) {
129
+ try {
130
+ const retired = await readJson(retiredPath, RetiredNativeSessionSchema);
131
+ if (retired.session_id === active.session_id) {
132
+ return null;
133
+ }
134
+ }
135
+ catch {
136
+ // A malformed retirement record cannot hide a valid active session.
137
+ }
138
+ }
139
+ return {
140
+ sessionId: active.session_id,
141
+ cwd: active.cwd,
142
+ writableDirs: active.writable_dirs ?? [],
143
+ createdAt: active.created_at,
144
+ lastUsedAt: active.last_used_at,
145
+ source: active.source
146
+ };
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
152
+ export function taskSessionWorkerTurnId(workerId, featureId) {
153
+ const featureTurn = featureId?.match(/^(\d{4})(?:-|$)/)?.[1];
154
+ const waveTurn = workerId.match(/-wave-(\d{4})-/)?.[1];
155
+ const finalTurn = workerId.match(/-final-(\d{4})$/)?.[1];
156
+ const taskTurn = workerId.match(/-(\d{4})$/)?.[1];
157
+ return featureTurn ?? waveTurn ?? finalTurn ?? taskTurn ?? "0001";
158
+ }
159
+ function compareTaskSessionWorkers(left, right) {
160
+ return taskSessionWorkerStage(left) - taskSessionWorkerStage(right)
161
+ || left.id.localeCompare(right.id);
162
+ }
163
+ function taskSessionWorkerStage(worker) {
164
+ if (worker.role === "judge" && /-final-\d{4}$/.test(worker.id)) {
165
+ return 4;
166
+ }
167
+ return ["main", "judge", "actor", "critic"].indexOf(worker.role);
168
+ }
169
+ function compactTaskRequest(request) {
170
+ const value = request.replace(/\s+/g, " ").trim();
171
+ return value.length > 160 ? `${value.slice(0, 157)}...` : value;
172
+ }
173
+ function laterTimestamp(left, right) {
174
+ return right && right.localeCompare(left) > 0 ? right : left;
175
+ }
@@ -0,0 +1,18 @@
1
+ const TASK_STATE_TRANSITIONS = {
2
+ created: new Set(["routed", "paused", "failed", "cancelled"]),
3
+ routed: new Set(["judging", "paused", "failed", "cancelled"]),
4
+ judging: new Set(["ready_for_pair", "paused", "failed", "cancelled"]),
5
+ ready_for_pair: new Set(["actor_running", "paused", "done", "failed", "cancelled"]),
6
+ actor_running: new Set(["critic_running", "verifying", "paused", "failed", "cancelled"]),
7
+ critic_running: new Set(["revision_needed", "integrating", "paused", "failed", "cancelled"]),
8
+ revision_needed: new Set(["actor_running", "paused", "failed", "cancelled"]),
9
+ integrating: new Set(["actor_running", "verifying", "paused", "done", "failed", "cancelled"]),
10
+ verifying: new Set(["revision_needed", "integrating", "paused", "done", "failed", "cancelled"]),
11
+ paused: new Set(["routed", "judging", "ready_for_pair", "failed", "cancelled"]),
12
+ done: new Set(["routed", "cancelled"]),
13
+ failed: new Set(["routed", "judging", "ready_for_pair"]),
14
+ cancelled: new Set(["routed", "judging", "ready_for_pair"])
15
+ };
16
+ export function taskStateTransitionAllowed(from, to) {
17
+ return from === to || TASK_STATE_TRANSITIONS[from].has(to);
18
+ }
@@ -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);