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
package/dist/cli.js CHANGED
@@ -1,48 +1,304 @@
1
1
  #!/usr/bin/env node
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { join } from "node:path";
3
4
  import { render } from "ink";
4
- import { parseCliArgs } from "./cli-args.js";
5
+ import { ZodError } from "zod";
6
+ import { parseCliArgs, validateCliArgs } from "./cli-args.js";
7
+ import { selectWorkspaceForCli } from "./cli-workspace.js";
8
+ import { commitWorkspaceTransition } from "./cli-workspace-transition.js";
9
+ import { WorkspaceSelectionCancelledError } from "./cli-workspace-picker.js";
10
+ import { startupRecoveryMessages } from "./cli-startup-recovery.js";
5
11
  import { createRuntime } from "./bootstrap.js";
6
- import { configPath, writeDefaultConfig } from "./core/config.js";
12
+ import { prepareAppRoot } from "./core/app-root.js";
13
+ import { formatConfigErrorMessage } from "./core/config-errors.js";
14
+ import { configPath, loadConfig, withUiThemeOverride, writeDefaultConfig } from "./core/config.js";
7
15
  import { pathExists } from "./core/file-store.js";
16
+ import { readRouterAudit } from "./core/router-audit.js";
17
+ import { listWorkspaceChoices } from "./core/workspace.js";
8
18
  import { runDoctor } from "./doctor.js";
19
+ import { helpText } from "./cli-help.js";
9
20
  import { App } from "./tui/App.js";
21
+ import { formatTuiThemeCatalog } from "./tui/theme-preview.js";
22
+ import { configureTuiTheme } from "./tui/theme.js";
23
+ import { routerDiagnosticsPolicy } from "./tui/RouterDiagnosticsView.js";
10
24
  import { version } from "./version.js";
11
- const helpText = `Usage: parallel-codex-tui [options]
12
-
13
- Options:
14
- -w, --workspace <path> Project workspace for worker sessions and edits
15
- --app-root <path> App root for configuration lookup
16
- -t, --task <id> Open an existing task session
17
- --init Write .parallel-codex/config.toml if missing
18
- --doctor Check local configuration and agent commands
19
- -v, --version Print the current version
20
- -h, --help Print this help message`;
21
- const cliArgs = parseCliArgs(process.argv.slice(2), process.cwd());
22
- const localConfigPath = configPath(cliArgs.appRoot);
23
- if (cliArgs.help) {
24
- console.log(helpText);
25
- }
26
- else if (cliArgs.version) {
27
- console.log(`parallel-codex-tui ${version}`);
28
- }
29
- else if (cliArgs.doctor) {
30
- const result = await runDoctor(cliArgs.appRoot, cliArgs.workspaceRoot);
31
- process.stdout.write(result.text);
32
- process.exitCode = result.ok ? 0 : 1;
33
- }
34
- else if (cliArgs.init) {
35
- if (await pathExists(localConfigPath)) {
36
- console.log(`Config already exists: ${localConfigPath}`);
25
+ main().catch((error) => {
26
+ if (error instanceof WorkspaceSelectionCancelledError) {
27
+ return;
28
+ }
29
+ process.stderr.write(`${formatStartupError(error)}\n`);
30
+ process.exit(1);
31
+ });
32
+ async function main() {
33
+ const rawArgs = process.argv.slice(2);
34
+ const cliArgErrors = validateCliArgs(rawArgs);
35
+ if (cliArgErrors.length > 0) {
36
+ process.stderr.write(`${cliArgErrors.join("\n")}\n`);
37
+ process.exit(1);
38
+ }
39
+ const cliArgs = parseCliArgs(rawArgs, process.cwd());
40
+ if (!cliArgs.help && !cliArgs.themes && !cliArgs.version) {
41
+ await prepareAppRoot(cliArgs.appRoot);
42
+ }
43
+ const localConfigPath = configPath(cliArgs.appRoot);
44
+ if (cliArgs.help) {
45
+ console.log(helpText);
46
+ }
47
+ else if (cliArgs.version) {
48
+ console.log(`parallel-codex-tui ${version}`);
49
+ }
50
+ else if (cliArgs.themes) {
51
+ console.log(formatTuiThemeCatalog(cliArgs.theme ? [cliArgs.theme] : undefined).join("\n"));
52
+ }
53
+ else if (cliArgs.doctor) {
54
+ const workspaceRoot = await selectWorkspaceForCli({
55
+ appRoot: cliArgs.appRoot,
56
+ cwd: process.cwd(),
57
+ explicitWorkspace: cliArgs.explicitWorkspace,
58
+ interactive: false
59
+ });
60
+ const result = await runDoctor(cliArgs.appRoot, workspaceRoot, process.env, {
61
+ probeAgents: cliArgs.probeAgents,
62
+ probeRouter: cliArgs.probeRouter,
63
+ theme: cliArgs.theme
64
+ });
65
+ process.stdout.write(result.text);
66
+ process.exitCode = result.ok ? 0 : 1;
67
+ }
68
+ else if (cliArgs.init) {
69
+ if (await pathExists(localConfigPath)) {
70
+ console.log(`Config already exists: ${localConfigPath}`);
71
+ }
72
+ else {
73
+ await writeDefaultConfig(cliArgs.appRoot);
74
+ console.log(`Wrote ${localConfigPath}`);
75
+ }
37
76
  }
38
77
  else {
39
- await writeDefaultConfig(cliArgs.appRoot);
40
- console.log(`Wrote ${localConfigPath}`);
78
+ const startupConfig = await loadConfig(cliArgs.appRoot);
79
+ configureTuiTheme({
80
+ theme: cliArgs.theme ?? startupConfig.ui.theme,
81
+ colors: startupConfig.ui.colors
82
+ });
83
+ const workspaceRoot = await selectWorkspaceForCli({
84
+ appRoot: cliArgs.appRoot,
85
+ cwd: process.cwd(),
86
+ explicitWorkspace: cliArgs.explicitWorkspace
87
+ });
88
+ let current = await loadInteractiveWorkspace(cliArgs.appRoot, workspaceRoot, cliArgs.taskId);
89
+ if (!canRenderInteractiveTui()) {
90
+ current.runtime.index.close();
91
+ throw new Error("parallel-codex-tui requires an interactive terminal. Use --help, --version, --init, or --doctor for non-interactive command modes.");
92
+ }
93
+ let instance = null;
94
+ const shutdownController = new AbortController();
95
+ const deferredWorkspaceClosures = new Set();
96
+ const appElement = (state) => (_jsx(App, { config: withUiThemeOverride(state.runtime.config, cliArgs.theme), orchestrator: state.runtime.orchestrator, cwd: state.runtime.workspaceRoot, initialTaskId: state.initialTaskId, initialRoute: state.initialRoute, initialWorkers: state.initialWorkers, initialCanRetryTask: state.initialCanRetryTask, initialMessages: state.initialMessages, workspaceChoices: state.workspaceChoices, shutdownSignal: shutdownController.signal, loadRouterDiagnostics: async () => {
97
+ const [records, latestConfig] = await Promise.all([
98
+ readRouterAudit(join(state.runtime.routerCwd, "routes.jsonl"), 100),
99
+ loadConfig(cliArgs.appRoot)
100
+ ]);
101
+ return {
102
+ records,
103
+ policy: routerDiagnosticsPolicy(latestConfig.router)
104
+ };
105
+ }, loadTaskSessions: (options) => state.runtime.index.listTasks(100, options), renameTaskSession: async (taskId, title) => {
106
+ await state.runtime.sessions.renameTask(taskId, title);
107
+ }, setTaskSessionArchived: async (taskId, archived) => {
108
+ await state.runtime.sessions.setTaskArchived(taskId, archived);
109
+ }, deleteTaskSession: async (taskId) => {
110
+ await state.runtime.sessions.deleteTask(taskId);
111
+ }, exportTaskSession: async (taskId) => (await state.runtime.sessions.exportTask(taskId)).path, loadCollaborationTimeline: (taskId) => state.runtime.sessions.readCollaborationTimeline(taskId), activateTaskSession: async (taskId) => {
112
+ if (!taskId) {
113
+ await state.runtime.index.setActiveTaskId(null);
114
+ return null;
115
+ }
116
+ if (!(await state.runtime.sessions.hasTask(taskId))) {
117
+ throw new Error(`Task session not found in workspace ${state.runtime.workspaceRoot}: ${taskId}`);
118
+ }
119
+ const task = state.runtime.sessions.taskFromId(taskId);
120
+ const meta = await state.runtime.sessions.readMeta(task);
121
+ if (meta.archived_at) {
122
+ throw new Error(`Task session is archived: ${taskId}`);
123
+ }
124
+ const [route, workers, canRetry] = await Promise.all([
125
+ state.runtime.sessions.readLatestRoute(task),
126
+ state.runtime.orchestrator.listTaskWorkers(taskId),
127
+ state.runtime.orchestrator.canRetryTask(taskId)
128
+ ]);
129
+ await state.runtime.index.setActiveTaskId(taskId);
130
+ return { taskId, route, workers, canRetry };
131
+ }, switchWorkspace: async (workspace) => {
132
+ if (workspace === current.runtime.workspaceRoot) {
133
+ return;
134
+ }
135
+ retryDeferredWorkspaceClosures(deferredWorkspaceClosures);
136
+ const next = await loadInteractiveWorkspace(cliArgs.appRoot, workspace, null);
137
+ const previous = current;
138
+ current = commitWorkspaceTransition({
139
+ previous,
140
+ next,
141
+ render: (state) => {
142
+ if (!instance) {
143
+ throw new Error("Interactive TUI is not ready to switch workspaces.");
144
+ }
145
+ instance.rerender(appElement(state));
146
+ },
147
+ close: closeInteractiveWorkspace,
148
+ deferClose: (state) => deferredWorkspaceClosures.add(state)
149
+ });
150
+ }, persistChatMessage: (message, taskId) => state.runtime.sessions.appendChatMessage({
151
+ ...message,
152
+ taskId
153
+ }) }, state.runtime.workspaceRoot));
154
+ const removeSigintHandler = installInteractiveSigintExitHandler(() => shutdownController.abort());
155
+ try {
156
+ instance = render(appElement(current), { exitOnCtrlC: false });
157
+ await instance.waitUntilExit();
158
+ }
159
+ finally {
160
+ removeSigintHandler();
161
+ retryDeferredWorkspaceClosures(deferredWorkspaceClosures);
162
+ }
163
+ }
164
+ }
165
+ async function loadInteractiveWorkspace(appRoot, workspaceRoot, requestedTaskId) {
166
+ const runtime = await createRuntime(appRoot, workspaceRoot);
167
+ try {
168
+ if (requestedTaskId) {
169
+ if (!(await runtime.sessions.hasTask(requestedTaskId))) {
170
+ throw new Error(`Task session not found in workspace ${runtime.workspaceRoot}: ${requestedTaskId}`);
171
+ }
172
+ const requestedMeta = await runtime.sessions.readMeta(runtime.sessions.taskFromId(requestedTaskId));
173
+ if (requestedMeta.archived_at) {
174
+ throw new Error(`Task session is archived in workspace ${runtime.workspaceRoot}: ${requestedTaskId}`);
175
+ }
176
+ }
177
+ const [latestTask, rememberedTaskId] = await Promise.all([
178
+ runtime.sessions.latestTask(),
179
+ runtime.index.activeTaskId()
180
+ ]);
181
+ const rememberedTaskIsRestorable = typeof rememberedTaskId === "string"
182
+ && await runtime.sessions.hasTask(rememberedTaskId)
183
+ && !(await runtime.sessions.readMeta(runtime.sessions.taskFromId(rememberedTaskId))).archived_at;
184
+ let initialTaskId;
185
+ if (requestedTaskId) {
186
+ initialTaskId = requestedTaskId;
187
+ }
188
+ else if (rememberedTaskId === null) {
189
+ initialTaskId = null;
190
+ }
191
+ else if (rememberedTaskId && rememberedTaskIsRestorable) {
192
+ initialTaskId = rememberedTaskId;
193
+ }
194
+ else {
195
+ initialTaskId = latestTask?.id ?? null;
196
+ }
197
+ if (initialTaskId && initialTaskId !== rememberedTaskId) {
198
+ await runtime.index.setActiveTaskId(initialTaskId);
199
+ }
200
+ else if (!initialTaskId && typeof rememberedTaskId === "string") {
201
+ await runtime.index.setActiveTaskId(null);
202
+ }
203
+ const [initialRoute, initialWorkers, initialCanRetryTask, initialHistory, workspaceChoices] = await Promise.all([
204
+ initialTaskId
205
+ ? runtime.sessions.readLatestRoute(runtime.sessions.taskFromId(initialTaskId))
206
+ : null,
207
+ initialTaskId ? runtime.orchestrator.listTaskWorkers(initialTaskId) : [],
208
+ initialTaskId ? runtime.orchestrator.canRetryTask(initialTaskId) : false,
209
+ runtime.sessions.readChatHistory(),
210
+ listWorkspaceChoices(appRoot)
211
+ ]);
212
+ const recoveryMessages = startupRecoveryMessages(runtime.recoveredTasks, initialTaskId, runtime.pendingTaskCreations);
213
+ return {
214
+ runtime,
215
+ initialTaskId,
216
+ initialRoute,
217
+ initialWorkers,
218
+ initialCanRetryTask,
219
+ initialMessages: [
220
+ ...initialHistory.map(({ from, text, task_id }) => ({
221
+ from,
222
+ text,
223
+ ...(task_id ? { taskId: task_id } : {})
224
+ })),
225
+ ...recoveryMessages
226
+ ],
227
+ workspaceChoices
228
+ };
229
+ }
230
+ catch (error) {
231
+ runtime.index.close();
232
+ throw error;
233
+ }
234
+ }
235
+ function closeInteractiveWorkspace(state) {
236
+ state.runtime.index.close();
237
+ }
238
+ function retryDeferredWorkspaceClosures(states) {
239
+ for (const state of states) {
240
+ try {
241
+ closeInteractiveWorkspace(state);
242
+ states.delete(state);
243
+ }
244
+ catch {
245
+ // Process exit remains the final cleanup boundary if an index cannot be closed yet.
246
+ }
41
247
  }
42
248
  }
43
- else {
44
- const runtime = await createRuntime(cliArgs.appRoot, cliArgs.workspaceRoot);
45
- const latestTask = await runtime.sessions.latestTask();
46
- const initialTaskId = cliArgs.taskId ?? latestTask?.id ?? null;
47
- render(_jsx(App, { config: runtime.config, orchestrator: runtime.orchestrator, cwd: runtime.workspaceRoot, initialTaskId: initialTaskId }), { exitOnCtrlC: false });
249
+ function canRenderInteractiveTui() {
250
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
251
+ }
252
+ function installInteractiveSigintExitHandler(requestGracefulExit) {
253
+ let interrupted = false;
254
+ const onSigint = () => {
255
+ if (!interrupted) {
256
+ interrupted = true;
257
+ try {
258
+ requestGracefulExit();
259
+ return;
260
+ }
261
+ catch {
262
+ // Fall through to the force-exit path when graceful shutdown cannot start.
263
+ }
264
+ }
265
+ restoreInteractiveTerminal();
266
+ process.exit(0);
267
+ };
268
+ process.on("SIGINT", onSigint);
269
+ return () => process.off("SIGINT", onSigint);
270
+ }
271
+ function restoreInteractiveTerminal() {
272
+ if (process.stdin.isTTY && process.stdin.isRaw && typeof process.stdin.setRawMode === "function") {
273
+ try {
274
+ process.stdin.setRawMode(false);
275
+ }
276
+ catch {
277
+ // The active view may already be releasing raw mode.
278
+ }
279
+ }
280
+ process.stdin.pause();
281
+ if (process.stdout.isTTY) {
282
+ process.stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l\x1b[?2004l\x1b[?25h");
283
+ }
284
+ }
285
+ function formatStartupError(error) {
286
+ const message = isConfigStartupError(error)
287
+ ? formatConfigErrorMessage(error)
288
+ : error instanceof Error ? error.message : String(error);
289
+ if (isConfigStartupError(error)) {
290
+ return `Config error: ${message}\nRun parallel-codex-tui --doctor for details.`;
291
+ }
292
+ return `Startup error: ${message}`;
293
+ }
294
+ function isConfigStartupError(error) {
295
+ if (error instanceof ZodError) {
296
+ return true;
297
+ }
298
+ if (!(error instanceof Error)) {
299
+ return false;
300
+ }
301
+ return (error.message.startsWith("Invalid config section ") ||
302
+ error.name.toLowerCase().includes("toml") ||
303
+ error.message.toLowerCase().includes("toml"));
48
304
  }
@@ -0,0 +1,8 @@
1
+ import { ensureDir, pathExists, pathIsDirectory } from "./file-store.js";
2
+ export async function prepareAppRoot(appRoot) {
3
+ if ((await pathExists(appRoot)) && !(await pathIsDirectory(appRoot))) {
4
+ throw new Error(`App root path exists but is not a directory: ${appRoot}`);
5
+ }
6
+ await ensureDir(appRoot);
7
+ return appRoot;
8
+ }
@@ -0,0 +1,261 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { EventRecordSchema, FeatureStatusSchema } from "../domain/schemas.js";
5
+ import { readTextIfExists } from "./file-store.js";
6
+ const FeatureDialogueSchema = z.object({
7
+ time: z.string().datetime(),
8
+ feature_id: z.string().min(1),
9
+ turn_id: z.string().min(1),
10
+ type: z.string().min(1),
11
+ role: z.enum(["actor", "critic"]),
12
+ message: z.string().default(""),
13
+ paths: z.record(z.string()).default({})
14
+ });
15
+ const FindingResolutionSchema = z.object({
16
+ version: z.literal(1),
17
+ decision: z.enum(["pending", "approved", "inconsistent"]),
18
+ fixed_ids: z.array(z.string().min(1)),
19
+ unresolved_ids: z.array(z.string().min(1))
20
+ });
21
+ export async function loadCollaborationTimeline(taskId, taskDir) {
22
+ const [features, dialogueText, taskEventsText] = await Promise.all([
23
+ readCollaborationFeatures(taskDir),
24
+ readTextIfExists(join(taskDir, "dialogue", "actor-critic.jsonl")),
25
+ readTextIfExists(join(taskDir, "events.jsonl"))
26
+ ]);
27
+ const featureById = new Map(features.map((feature) => [feature.id, feature]));
28
+ const dialogue = parseJsonLines(dialogueText, FeatureDialogueSchema);
29
+ const taskEvents = parseJsonLines(taskEventsText, EventRecordSchema)
30
+ .filter((event) => event.type.startsWith("feature.wave_"));
31
+ const events = [
32
+ ...dialogue.map((event, index) => {
33
+ const feature = featureById.get(event.feature_id);
34
+ const artifactRefs = collaborationArtifactRefs(event.paths ?? {});
35
+ return {
36
+ id: `dialogue-${index}-${event.time}`,
37
+ time: event.time,
38
+ type: event.type,
39
+ role: event.role,
40
+ action: collaborationDialogueAction(event.type),
41
+ message: event.type === "critic.revision_requested" && feature?.latestFinding
42
+ ? appendCollaborationEvidence(event.message ?? "", feature.latestFinding)
43
+ : event.message ?? "",
44
+ turnId: event.turn_id,
45
+ featureId: event.feature_id,
46
+ featureTitle: feature?.title ?? event.feature_id,
47
+ findings: feature?.findings ?? 0,
48
+ replies: feature?.replies ?? 0,
49
+ ...(typeof feature?.resolvedFindings === "number"
50
+ ? { resolvedFindings: feature.resolvedFindings }
51
+ : {}),
52
+ ...(typeof feature?.unresolvedFindings === "number"
53
+ ? { unresolvedFindings: feature.unresolvedFindings }
54
+ : {}),
55
+ artifacts: artifactRefs.map((artifact) => artifact.label),
56
+ artifactRefs
57
+ };
58
+ }),
59
+ ...taskEvents.map((event, index) => ({
60
+ id: `task-${index}-${event.time}`,
61
+ time: event.time,
62
+ type: event.type,
63
+ role: "supervisor",
64
+ action: collaborationWaveAction(event.type),
65
+ message: event.message ?? "",
66
+ artifacts: ["task events"],
67
+ artifactRefs: [{ label: "task events", path: join(taskDir, "events.jsonl") }]
68
+ })),
69
+ ...features.map((feature) => ({
70
+ id: `state-${feature.id}-${feature.updatedAt}`,
71
+ time: feature.updatedAt,
72
+ type: "feature.state",
73
+ role: "supervisor",
74
+ action: collaborationFeatureStateAction(feature.state),
75
+ message: [
76
+ `${feature.title} · ${humanizeFeatureState(feature.state)}`,
77
+ ...(feature.latestFinding ? [`finding: ${feature.latestFinding}`] : []),
78
+ ...(feature.latestReply ? [`reply: ${feature.latestReply}`] : [])
79
+ ].join(" · "),
80
+ turnId: feature.turnId,
81
+ featureId: feature.id,
82
+ featureTitle: feature.title,
83
+ findings: feature.findings,
84
+ replies: feature.replies,
85
+ ...(typeof feature.resolvedFindings === "number"
86
+ ? { resolvedFindings: feature.resolvedFindings }
87
+ : {}),
88
+ ...(typeof feature.unresolvedFindings === "number"
89
+ ? { unresolvedFindings: feature.unresolvedFindings }
90
+ : {}),
91
+ artifacts: feature.artifactRefs.map((artifact) => artifact.label),
92
+ artifactRefs: feature.artifactRefs
93
+ }))
94
+ ].sort((left, right) => left.time.localeCompare(right.time) || left.id.localeCompare(right.id));
95
+ return { taskId, features, events };
96
+ }
97
+ async function readCollaborationFeatures(taskDir) {
98
+ const root = join(taskDir, "features");
99
+ let entries;
100
+ try {
101
+ entries = await readdir(root, { withFileTypes: true });
102
+ }
103
+ catch {
104
+ return [];
105
+ }
106
+ const features = await Promise.all(entries
107
+ .filter((entry) => entry.isDirectory())
108
+ .map(async (entry) => {
109
+ const dir = join(root, entry.name);
110
+ const [statusText, spec, findings, replies, resolutionText] = await Promise.all([
111
+ readTextIfExists(join(dir, "status.json")),
112
+ readTextIfExists(join(dir, "spec.md")),
113
+ readTextIfExists(join(dir, "critic-findings.jsonl")),
114
+ readTextIfExists(join(dir, "actor-replies.jsonl")),
115
+ readTextIfExists(join(dir, "finding-resolution.json"))
116
+ ]);
117
+ const status = parseJsonValue(statusText, FeatureStatusSchema);
118
+ if (!status) {
119
+ return null;
120
+ }
121
+ const findingEvidence = readMailboxEvidence(findings);
122
+ const replyEvidence = readMailboxEvidence(replies);
123
+ const resolution = parseJsonValue(resolutionText, FindingResolutionSchema);
124
+ return {
125
+ id: status.feature_id,
126
+ title: status.title?.trim() || featureSpecTitle(spec) || status.feature_id,
127
+ description: status.description?.trim() ?? "",
128
+ dependsOn: status.depends_on ?? [],
129
+ turnId: status.turn_id,
130
+ state: status.state,
131
+ updatedAt: status.updated_at,
132
+ findings: findingEvidence.count,
133
+ replies: replyEvidence.count,
134
+ artifactRefs: [
135
+ { label: "status", path: join(dir, "status.json") },
136
+ { label: "spec", path: join(dir, "spec.md") },
137
+ { label: "critic findings", path: join(dir, "critic-findings.jsonl") },
138
+ { label: "actor replies", path: join(dir, "actor-replies.jsonl") },
139
+ ...(resolution ? [{ label: "finding resolution", path: join(dir, "finding-resolution.json") }] : [])
140
+ ],
141
+ ...(resolution
142
+ ? {
143
+ resolvedFindings: new Set(resolution.fixed_ids).size,
144
+ unresolvedFindings: new Set(resolution.unresolved_ids).size
145
+ }
146
+ : {}),
147
+ ...(findingEvidence.latest ? { latestFinding: findingEvidence.latest } : {}),
148
+ ...(replyEvidence.latest ? { latestReply: replyEvidence.latest } : {})
149
+ };
150
+ }));
151
+ return features
152
+ .filter((feature) => feature !== null)
153
+ .sort((left, right) => left.turnId.localeCompare(right.turnId) || left.id.localeCompare(right.id));
154
+ }
155
+ function collaborationArtifactRefs(paths) {
156
+ return Object.entries(paths)
157
+ .map(([label, path]) => ({ label: label.trim(), path: path.trim() }))
158
+ .filter((artifact) => artifact.label && artifact.path)
159
+ .sort((left, right) => left.label.localeCompare(right.label) || left.path.localeCompare(right.path));
160
+ }
161
+ function parseJsonLines(text, schema) {
162
+ const records = [];
163
+ for (const line of text.split(/\r?\n/)) {
164
+ if (!line.trim()) {
165
+ continue;
166
+ }
167
+ try {
168
+ const parsed = schema.safeParse(JSON.parse(line));
169
+ if (parsed.success) {
170
+ records.push(parsed.data);
171
+ }
172
+ }
173
+ catch {
174
+ // A partial mailbox write must not hide earlier collaboration evidence.
175
+ }
176
+ }
177
+ return records;
178
+ }
179
+ function parseJsonValue(text, schema) {
180
+ try {
181
+ const parsed = schema.safeParse(JSON.parse(text));
182
+ return parsed.success ? parsed.data : null;
183
+ }
184
+ catch {
185
+ return null;
186
+ }
187
+ }
188
+ function readMailboxEvidence(text) {
189
+ let count = 0;
190
+ let latest = null;
191
+ for (const line of text.split(/\r?\n/)) {
192
+ if (!line.trim()) {
193
+ continue;
194
+ }
195
+ try {
196
+ const value = JSON.parse(line);
197
+ if (value && typeof value === "object" && !Array.isArray(value)) {
198
+ count += 1;
199
+ latest = mailboxRecordSummary(value) || latest;
200
+ }
201
+ }
202
+ catch {
203
+ // Ignore partial or malformed mailbox rows.
204
+ }
205
+ }
206
+ return { count, latest };
207
+ }
208
+ function mailboxRecordSummary(value) {
209
+ for (const key of ["message", "summary", "notes", "resolution", "title", "description", "issue", "status"]) {
210
+ const field = value[key];
211
+ if (typeof field === "string" && field.trim()) {
212
+ return field.replace(/\s+/g, " ").trim();
213
+ }
214
+ }
215
+ return "";
216
+ }
217
+ function appendCollaborationEvidence(message, evidence) {
218
+ const base = message.trim();
219
+ if (!base || base.includes(evidence)) {
220
+ return base || evidence;
221
+ }
222
+ return `${base} · ${evidence}`;
223
+ }
224
+ function featureSpecTitle(spec) {
225
+ const title = spec.match(/^Title:\s*(.+)$/m)?.[1]?.trim();
226
+ return title || null;
227
+ }
228
+ function collaborationDialogueAction(type) {
229
+ if (type === "feature.created") {
230
+ return "mailbox created";
231
+ }
232
+ if (type === "actor.completed") {
233
+ return "implementation completed";
234
+ }
235
+ if (type === "critic.completed") {
236
+ return "review completed";
237
+ }
238
+ if (type === "critic.revision_requested") {
239
+ return "revision requested";
240
+ }
241
+ return humanizeCollaborationType(type);
242
+ }
243
+ function collaborationWaveAction(type) {
244
+ const action = type.replace(/^feature\.wave_/, "wave ");
245
+ return humanizeCollaborationType(action);
246
+ }
247
+ function collaborationFeatureStateAction(state) {
248
+ if (state === "approved") {
249
+ return "feature approved";
250
+ }
251
+ if (state === "revision_needed") {
252
+ return "revision pending";
253
+ }
254
+ return `feature ${humanizeFeatureState(state)}`;
255
+ }
256
+ function humanizeFeatureState(state) {
257
+ return state.replaceAll("_", " ");
258
+ }
259
+ function humanizeCollaborationType(type) {
260
+ return type.replace(/[._-]+/g, " ").replace(/\s+/g, " ").trim();
261
+ }
@@ -0,0 +1,14 @@
1
+ import { ZodError } from "zod";
2
+ export function formatConfigErrorMessage(error) {
3
+ if (error instanceof ZodError) {
4
+ return error.issues
5
+ .map((issue) => ({
6
+ message: issue.message,
7
+ path: issue.path.map(String).join(".") || "config"
8
+ }))
9
+ .sort((left, right) => left.path.localeCompare(right.path))
10
+ .map((issue) => `${issue.path}: ${issue.message}`)
11
+ .join("\n");
12
+ }
13
+ return error instanceof Error ? error.message : String(error);
14
+ }