parallel-codex-tui 0.1.0

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 +62 -0
  2. package/LICENSE +21 -0
  3. package/README.md +150 -0
  4. package/dist/bootstrap.js +25 -0
  5. package/dist/cli-args.js +26 -0
  6. package/dist/cli.js +48 -0
  7. package/dist/core/config.js +304 -0
  8. package/dist/core/file-store.js +56 -0
  9. package/dist/core/paths.js +17 -0
  10. package/dist/core/router.js +151 -0
  11. package/dist/core/session-index.js +180 -0
  12. package/dist/core/session-manager.js +264 -0
  13. package/dist/doctor.js +121 -0
  14. package/dist/domain/schemas.js +78 -0
  15. package/dist/orchestrator/collaboration-channel.js +103 -0
  16. package/dist/orchestrator/orchestrator.js +545 -0
  17. package/dist/orchestrator/prompts.js +124 -0
  18. package/dist/orchestrator/supervisor-summary.js +38 -0
  19. package/dist/tui/App.js +740 -0
  20. package/dist/tui/AppShell.js +129 -0
  21. package/dist/tui/InputBar.js +141 -0
  22. package/dist/tui/StatusBar.js +288 -0
  23. package/dist/tui/TerminalOutput.js +22 -0
  24. package/dist/tui/WorkerOutputView.js +4015 -0
  25. package/dist/tui/chat-input.js +37 -0
  26. package/dist/tui/display-width.js +132 -0
  27. package/dist/tui/keyboard.js +38 -0
  28. package/dist/tui/native-input.js +120 -0
  29. package/dist/tui/raw-input-decoder.js +18 -0
  30. package/dist/tui/scrolling.js +25 -0
  31. package/dist/tui/status-line.js +90 -0
  32. package/dist/tui/task-memory.js +26 -0
  33. package/dist/tui/terminal-screen.js +168 -0
  34. package/dist/version.js +1 -0
  35. package/dist/workers/mock-adapter.js +62 -0
  36. package/dist/workers/native-attach.js +189 -0
  37. package/dist/workers/process-adapter.js +265 -0
  38. package/dist/workers/registry.js +32 -0
  39. package/dist/workers/types.js +1 -0
  40. package/package.json +53 -0
@@ -0,0 +1,17 @@
1
+ import { join } from "node:path";
2
+ function pad(value) {
3
+ return String(value).padStart(2, "0");
4
+ }
5
+ export function formatTaskTimestamp(date) {
6
+ return ([
7
+ date.getUTCFullYear(),
8
+ pad(date.getUTCMonth() + 1),
9
+ pad(date.getUTCDate())
10
+ ].join("") + `-${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`);
11
+ }
12
+ export function sessionsRoot(projectRoot, dataDir) {
13
+ return join(projectRoot, dataDir, "sessions");
14
+ }
15
+ export function taskDir(projectRoot, dataDir, taskId) {
16
+ return join(sessionsRoot(projectRoot, dataDir), taskId);
17
+ }
@@ -0,0 +1,151 @@
1
+ import { spawn } from "node:child_process";
2
+ import { RouteDecisionSchema } from "../domain/schemas.js";
3
+ export async function routeRequestWithCodex(request, config, runner = runCodexRouterProcess, cwd = config.projectRoot) {
4
+ if (config.router.defaultMode === "simple") {
5
+ return simpleRoute("Forced simple mode from config.", config);
6
+ }
7
+ if (config.router.defaultMode === "complex") {
8
+ return complexRoute("Forced complex mode from config.", config);
9
+ }
10
+ try {
11
+ const output = await runner(buildCodexRouterPrompt(request, config), config, cwd);
12
+ const route = parseCodexRoute(output, config);
13
+ return RouteDecisionSchema.parse(route);
14
+ }
15
+ catch (error) {
16
+ const fallback = fallbackRoute(config);
17
+ return {
18
+ ...fallback,
19
+ reason: `Codex router failed: ${error instanceof Error ? error.message : String(error)}. ${fallback.reason}`
20
+ };
21
+ }
22
+ }
23
+ export async function runCodexRouterProcess(prompt, config, cwd = config.projectRoot) {
24
+ const { command, args, timeoutMs } = config.router.codex;
25
+ return new Promise((resolve, reject) => {
26
+ const child = spawn(command, args, {
27
+ cwd,
28
+ env: process.env,
29
+ stdio: ["pipe", "pipe", "pipe"]
30
+ });
31
+ let stdout = "";
32
+ let stderr = "";
33
+ let settled = false;
34
+ let timeout;
35
+ const finish = (error, output = stdout) => {
36
+ if (settled) {
37
+ return;
38
+ }
39
+ settled = true;
40
+ if (timeout) {
41
+ clearTimeout(timeout);
42
+ }
43
+ if (error) {
44
+ reject(error);
45
+ }
46
+ else {
47
+ resolve(output);
48
+ }
49
+ };
50
+ if (timeoutMs > 0) {
51
+ timeout = setTimeout(() => {
52
+ child.kill("SIGTERM");
53
+ finish(new Error(`Codex router timed out after ${timeoutMs}ms`));
54
+ }, timeoutMs);
55
+ }
56
+ child.stdout.on("data", (chunk) => {
57
+ stdout += chunk.toString("utf8");
58
+ });
59
+ child.stderr.on("data", (chunk) => {
60
+ stderr += chunk.toString("utf8");
61
+ });
62
+ child.on("error", (error) => {
63
+ finish(error);
64
+ });
65
+ child.on("close", (code, signal) => {
66
+ if (code === 0) {
67
+ finish(null);
68
+ return;
69
+ }
70
+ const detail = (stderr || stdout).trim();
71
+ finish(new Error(`Codex router exited with ${signal ? `signal ${signal}` : `code ${code ?? 1}`}${detail ? `: ${detail}` : ""}`));
72
+ });
73
+ child.stdin.write(prompt);
74
+ child.stdin.end();
75
+ });
76
+ }
77
+ function buildCodexRouterPrompt(request, config) {
78
+ return [
79
+ "You are a routing classifier for parallel-codex-tui.",
80
+ "Return only one JSON object. No markdown. No commentary.",
81
+ "",
82
+ "Choose mode:",
83
+ '- "simple": short chat, explanation, status question, no code or project action.',
84
+ '- "complex": implementation, modification, optimization, debugging, testing, strategy, scoring, experiments, or project work.',
85
+ "",
86
+ "JSON schema:",
87
+ JSON.stringify({
88
+ mode: "simple|complex",
89
+ reason: "short explanation",
90
+ suggested_roles: ["judge", "actor", "critic"],
91
+ judge_engine: config.pairing.judge,
92
+ actor_engine: config.pairing.actor,
93
+ critic_engine: config.pairing.critic
94
+ }),
95
+ "",
96
+ "User request:",
97
+ request
98
+ ].join("\n");
99
+ }
100
+ function parseCodexRoute(output, config) {
101
+ const jsonText = extractJsonObject(output);
102
+ const parsed = JSON.parse(jsonText);
103
+ return {
104
+ mode: parsed.mode === "complex" ? "complex" : "simple",
105
+ reason: parsed.reason || "Codex router decision.",
106
+ suggested_roles: parsed.mode === "complex" ? ["judge", "actor", "critic"] : [],
107
+ judge_engine: parsed.judge_engine ?? config.pairing.judge,
108
+ actor_engine: parsed.actor_engine ?? config.pairing.actor,
109
+ critic_engine: parsed.critic_engine ?? config.pairing.critic
110
+ };
111
+ }
112
+ function extractJsonObject(output) {
113
+ const trimmed = output.trim();
114
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
115
+ return trimmed;
116
+ }
117
+ const match = trimmed.match(/\{[\s\S]*\}/);
118
+ if (!match) {
119
+ throw new Error("No JSON object in Codex router output");
120
+ }
121
+ return match[0];
122
+ }
123
+ function fallbackRoute(config) {
124
+ if (config.router.codex.fallback === "simple") {
125
+ return simpleRoute("Codex router fallback forced simple.", config);
126
+ }
127
+ if (config.router.codex.fallback === "complex") {
128
+ return complexRoute("Codex router fallback forced complex.", config);
129
+ }
130
+ return complexRoute("Codex router fallback forced complex.", config);
131
+ }
132
+ function simpleRoute(reason, config) {
133
+ return {
134
+ mode: "simple",
135
+ reason,
136
+ suggested_roles: [],
137
+ judge_engine: config.pairing.judge,
138
+ actor_engine: config.pairing.actor,
139
+ critic_engine: config.pairing.critic
140
+ };
141
+ }
142
+ function complexRoute(reason, config) {
143
+ return {
144
+ mode: "complex",
145
+ reason,
146
+ suggested_roles: ["judge", "actor", "critic"],
147
+ judge_engine: config.pairing.judge,
148
+ actor_engine: config.pairing.actor,
149
+ critic_engine: config.pairing.critic
150
+ };
151
+ }
@@ -0,0 +1,180 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { NativeSessionSchema, TaskMetaSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
5
+ import { ensureDir, pathExists, readJson } from "./file-store.js";
6
+ export class SessionIndex {
7
+ db;
8
+ projectRoot;
9
+ dataDir;
10
+ constructor(db, projectRoot, dataDir) {
11
+ this.db = db;
12
+ this.projectRoot = projectRoot;
13
+ this.dataDir = dataDir;
14
+ }
15
+ static async open(projectRoot, dataDir) {
16
+ await ensureDir(join(projectRoot, dataDir));
17
+ const index = new SessionIndex(new DatabaseSync(join(projectRoot, dataDir, "session-index.sqlite")), projectRoot, dataDir);
18
+ index.initialize();
19
+ return index;
20
+ }
21
+ initialize() {
22
+ this.db.exec(`
23
+ CREATE TABLE IF NOT EXISTS tasks (
24
+ id TEXT PRIMARY KEY,
25
+ title TEXT NOT NULL,
26
+ created_at TEXT NOT NULL,
27
+ cwd TEXT NOT NULL,
28
+ mode TEXT NOT NULL,
29
+ status TEXT NOT NULL
30
+ );
31
+
32
+ CREATE TABLE IF NOT EXISTS turns (
33
+ task_id TEXT NOT NULL,
34
+ turn_id TEXT NOT NULL,
35
+ created_at TEXT NOT NULL,
36
+ request_path TEXT NOT NULL,
37
+ PRIMARY KEY (task_id, turn_id)
38
+ );
39
+
40
+ CREATE TABLE IF NOT EXISTS workers (
41
+ task_id TEXT NOT NULL,
42
+ worker_id TEXT NOT NULL,
43
+ role TEXT NOT NULL,
44
+ engine TEXT NOT NULL,
45
+ state TEXT NOT NULL,
46
+ phase TEXT NOT NULL,
47
+ summary TEXT NOT NULL,
48
+ status_path TEXT NOT NULL,
49
+ output_log_path TEXT NOT NULL,
50
+ dir TEXT NOT NULL,
51
+ native_session_id TEXT,
52
+ PRIMARY KEY (task_id, worker_id)
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS native_sessions (
56
+ task_id TEXT NOT NULL,
57
+ worker_id TEXT NOT NULL,
58
+ engine TEXT NOT NULL,
59
+ role TEXT NOT NULL,
60
+ session_id TEXT NOT NULL,
61
+ cwd TEXT NOT NULL,
62
+ created_at TEXT NOT NULL,
63
+ last_used_at TEXT NOT NULL,
64
+ source TEXT NOT NULL,
65
+ PRIMARY KEY (task_id, worker_id)
66
+ );
67
+ `);
68
+ }
69
+ async upsertTask(task) {
70
+ this.db
71
+ .prepare(`INSERT INTO tasks (id, title, created_at, cwd, mode, status)
72
+ VALUES (?, ?, ?, ?, ?, ?)
73
+ ON CONFLICT(id) DO UPDATE SET
74
+ title=excluded.title,
75
+ created_at=excluded.created_at,
76
+ cwd=excluded.cwd,
77
+ mode=excluded.mode,
78
+ status=excluded.status`)
79
+ .run(task.id, task.title, task.created_at, task.cwd, task.mode, task.status);
80
+ }
81
+ async upsertTurn(taskId, turn) {
82
+ this.db
83
+ .prepare(`INSERT INTO turns (task_id, turn_id, created_at, request_path)
84
+ VALUES (?, ?, ?, ?)
85
+ ON CONFLICT(task_id, turn_id) DO UPDATE SET
86
+ created_at=excluded.created_at,
87
+ request_path=excluded.request_path`)
88
+ .run(taskId, turn.turn_id, turn.created_at, turn.request_path);
89
+ }
90
+ async upsertWorker(taskId, status, paths) {
91
+ this.db
92
+ .prepare(`INSERT INTO workers (
93
+ task_id, worker_id, role, engine, state, phase, summary, status_path, output_log_path, dir, native_session_id
94
+ )
95
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
96
+ ON CONFLICT(task_id, worker_id) DO UPDATE SET
97
+ role=excluded.role,
98
+ engine=excluded.engine,
99
+ state=excluded.state,
100
+ phase=excluded.phase,
101
+ summary=excluded.summary,
102
+ status_path=excluded.status_path,
103
+ output_log_path=excluded.output_log_path,
104
+ dir=excluded.dir,
105
+ native_session_id=excluded.native_session_id`)
106
+ .run(taskId, status.worker_id, status.role, status.engine, status.state, status.phase, status.summary, paths.statusPath, paths.outputLogPath, paths.dir, status.native_session_id ?? null);
107
+ }
108
+ async upsertNativeSession(taskId, record) {
109
+ this.db
110
+ .prepare(`INSERT INTO native_sessions (
111
+ task_id, worker_id, engine, role, session_id, cwd, created_at, last_used_at, source
112
+ )
113
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
114
+ ON CONFLICT(task_id, worker_id) DO UPDATE SET
115
+ engine=excluded.engine,
116
+ role=excluded.role,
117
+ session_id=excluded.session_id,
118
+ cwd=excluded.cwd,
119
+ created_at=excluded.created_at,
120
+ last_used_at=excluded.last_used_at,
121
+ source=excluded.source`)
122
+ .run(taskId, record.worker_id, record.engine, record.role, record.session_id, record.cwd, record.created_at, record.last_used_at, record.source);
123
+ }
124
+ async countRows(table) {
125
+ const row = this.db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get();
126
+ return row.count;
127
+ }
128
+ async rebuildFromFiles() {
129
+ this.db.exec("DELETE FROM native_sessions; DELETE FROM workers; DELETE FROM turns; DELETE FROM tasks;");
130
+ const sessions = join(this.projectRoot, this.dataDir, "sessions");
131
+ if (!(await pathExists(sessions))) {
132
+ return;
133
+ }
134
+ const taskEntries = await readdir(sessions, { withFileTypes: true });
135
+ for (const taskEntry of taskEntries) {
136
+ if (!taskEntry.isDirectory() || !taskEntry.name.startsWith("task-")) {
137
+ continue;
138
+ }
139
+ await this.rebuildTask(join(sessions, taskEntry.name), taskEntry.name);
140
+ }
141
+ }
142
+ close() {
143
+ this.db.close();
144
+ }
145
+ async rebuildTask(taskDir, taskId) {
146
+ const metaPath = join(taskDir, "meta.json");
147
+ if (await pathExists(metaPath)) {
148
+ await this.upsertTask(await readJson(metaPath, TaskMetaSchema));
149
+ }
150
+ const turnsDir = join(taskDir, "turns");
151
+ if (await pathExists(turnsDir)) {
152
+ const turnEntries = await readdir(turnsDir, { withFileTypes: true });
153
+ for (const turnEntry of turnEntries) {
154
+ const turnPath = join(turnsDir, turnEntry.name, "turn.json");
155
+ if (turnEntry.isDirectory() && (await pathExists(turnPath))) {
156
+ await this.upsertTurn(taskId, await readJson(turnPath, TurnMetaSchema));
157
+ }
158
+ }
159
+ }
160
+ const entries = await readdir(taskDir, { withFileTypes: true });
161
+ for (const entry of entries) {
162
+ if (!entry.isDirectory() || entry.name === "turns") {
163
+ continue;
164
+ }
165
+ const workerDir = join(taskDir, entry.name);
166
+ const statusPath = join(workerDir, "status.json");
167
+ if (await pathExists(statusPath)) {
168
+ await this.upsertWorker(taskId, await readJson(statusPath, WorkerStatusSchema), {
169
+ dir: workerDir,
170
+ statusPath,
171
+ outputLogPath: join(workerDir, "output.log")
172
+ });
173
+ }
174
+ const nativePath = join(workerDir, "native-session.json");
175
+ if (await pathExists(nativePath)) {
176
+ await this.upsertNativeSession(taskId, await readJson(nativePath, NativeSessionSchema));
177
+ }
178
+ }
179
+ }
180
+ }
@@ -0,0 +1,264 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { appendJsonLine, ensureDir, pathExists, readJson, readTextIfExists, removeIfExists, writeJson, writeText } from "./file-store.js";
4
+ import { formatTaskTimestamp, taskDir } from "./paths.js";
5
+ import { sessionsRoot } from "./paths.js";
6
+ import { NativeSessionSchema, RouteDecisionSchema, TaskMetaSchema, TurnMetaSchema } from "../domain/schemas.js";
7
+ export class SessionManager {
8
+ projectRoot;
9
+ dataDir;
10
+ now;
11
+ randomId;
12
+ index;
13
+ constructor(options) {
14
+ this.projectRoot = options.projectRoot;
15
+ this.dataDir = options.dataDir;
16
+ this.now = options.now ?? (() => new Date());
17
+ this.randomId = options.randomId ?? (() => Math.random().toString(16).slice(2, 6));
18
+ this.index = options.index;
19
+ }
20
+ async createTask(input) {
21
+ const createdAt = this.now();
22
+ const id = `task-${formatTaskTimestamp(createdAt)}-${this.randomId()}`;
23
+ const dir = taskDir(this.projectRoot, this.dataDir, id);
24
+ const meta = {
25
+ id,
26
+ title: titleFromRequest(input.request),
27
+ created_at: createdAt.toISOString(),
28
+ cwd: input.cwd,
29
+ mode: input.route.mode,
30
+ status: "created"
31
+ };
32
+ await ensureDir(dir);
33
+ await writeJson(join(dir, "meta.json"), TaskMetaSchema.parse(meta));
34
+ await writeText(join(dir, "user-request.md"), `${input.request.trim()}\n`);
35
+ await this.index?.upsertTask(meta);
36
+ await writeJson(join(dir, "route.json"), RouteDecisionSchema.parse(input.route));
37
+ await this.writeTurn({ id, dir }, "0001", input.request, input.route, createdAt);
38
+ await this.appendEvent({ id, dir }, "task.created", "Task session created");
39
+ return {
40
+ id,
41
+ dir,
42
+ metaPath: join(dir, "meta.json"),
43
+ routePath: join(dir, "route.json"),
44
+ eventsPath: join(dir, "events.jsonl")
45
+ };
46
+ }
47
+ mainSessionDir() {
48
+ return join(this.projectRoot, this.dataDir, "sessions", "main");
49
+ }
50
+ taskFromId(taskId) {
51
+ const dir = taskDir(this.projectRoot, this.dataDir, taskId);
52
+ return {
53
+ id: taskId,
54
+ dir,
55
+ metaPath: join(dir, "meta.json"),
56
+ routePath: join(dir, "route.json"),
57
+ eventsPath: join(dir, "events.jsonl")
58
+ };
59
+ }
60
+ async latestTask() {
61
+ const root = sessionsRoot(this.projectRoot, this.dataDir);
62
+ if (!(await pathExists(root))) {
63
+ return null;
64
+ }
65
+ const entries = await readdir(root, { withFileTypes: true });
66
+ const tasks = [];
67
+ for (const entry of entries) {
68
+ if (!entry.isDirectory() || !entry.name.startsWith("task-")) {
69
+ continue;
70
+ }
71
+ const metaPath = join(root, entry.name, "meta.json");
72
+ if (await pathExists(metaPath)) {
73
+ const meta = await readJson(metaPath, TaskMetaSchema);
74
+ if (meta.mode === "complex") {
75
+ tasks.push(meta);
76
+ }
77
+ }
78
+ }
79
+ const latest = tasks.sort((left, right) => left.created_at.localeCompare(right.created_at)).at(-1);
80
+ if (!latest) {
81
+ return null;
82
+ }
83
+ const dir = taskDir(this.projectRoot, this.dataDir, latest.id);
84
+ return {
85
+ id: latest.id,
86
+ dir,
87
+ metaPath: join(dir, "meta.json"),
88
+ routePath: join(dir, "route.json"),
89
+ eventsPath: join(dir, "events.jsonl")
90
+ };
91
+ }
92
+ async appendTurn(task, input) {
93
+ await this.indexTaskFromFiles(task);
94
+ await this.backfillInitialTurn(task, input.route);
95
+ const turnId = await this.nextTurnId(task);
96
+ const turn = await this.writeTurn(task, turnId, input.request, input.route, this.now());
97
+ await this.appendEvent(task, "turn.created", `Turn ${turnId} created`);
98
+ return turn;
99
+ }
100
+ async latestTurn(task) {
101
+ const root = join(task.dir, "turns");
102
+ if (!(await pathExists(root))) {
103
+ return null;
104
+ }
105
+ const entries = await readdir(root, { withFileTypes: true });
106
+ const turnId = entries
107
+ .filter((entry) => entry.isDirectory() && /^\d{4}$/.test(entry.name))
108
+ .map((entry) => entry.name)
109
+ .sort()
110
+ .at(-1);
111
+ if (!turnId) {
112
+ return null;
113
+ }
114
+ return this.turnFiles(task, turnId);
115
+ }
116
+ async readMeta(task) {
117
+ return readJson(task.metaPath, TaskMetaSchema);
118
+ }
119
+ async updateTaskStatus(task, status) {
120
+ const meta = await this.readMeta(task);
121
+ await writeJson(task.metaPath, TaskMetaSchema.parse({ ...meta, status }));
122
+ await this.index?.upsertTask({ ...meta, status });
123
+ await this.appendEvent(task, `task.${status}`, `Task moved to ${status}`);
124
+ }
125
+ async initializeWorker(task, input) {
126
+ const dir = join(task.dir, input.workerId);
127
+ const promptPath = join(dir, "prompt.md");
128
+ const outputLogPath = join(dir, "output.log");
129
+ const statusPath = join(dir, "status.json");
130
+ const status = {
131
+ worker_id: input.workerId,
132
+ role: input.role,
133
+ engine: input.engine,
134
+ state: "idle",
135
+ phase: "initialized",
136
+ last_event_at: this.now().toISOString(),
137
+ summary: "Worker initialized"
138
+ };
139
+ await ensureDir(dir);
140
+ await this.clearWorkerArtifacts(dir, input.role);
141
+ await writeText(promptPath, input.prompt);
142
+ await writeText(outputLogPath, "");
143
+ await writeJson(statusPath, status);
144
+ await this.index?.upsertWorker(task.id, status, {
145
+ dir,
146
+ statusPath,
147
+ outputLogPath
148
+ });
149
+ return {
150
+ workerId: input.workerId,
151
+ dir,
152
+ promptPath,
153
+ outputLogPath,
154
+ statusPath
155
+ };
156
+ }
157
+ async readNativeSession(worker) {
158
+ const path = join(worker.dir, "native-session.json");
159
+ if (!(await pathExists(path))) {
160
+ return null;
161
+ }
162
+ return readJson(path, NativeSessionSchema);
163
+ }
164
+ async writeNativeSession(worker, record) {
165
+ await writeJson(join(worker.dir, "native-session.json"), NativeSessionSchema.parse(record));
166
+ await this.index?.upsertNativeSession(this.taskIdFromWorkerDir(worker.dir), record);
167
+ }
168
+ async retireNativeSession(worker, reason) {
169
+ const nativeSessionPath = join(worker.dir, "native-session.json");
170
+ if (!(await pathExists(nativeSessionPath))) {
171
+ return;
172
+ }
173
+ const record = await readJson(nativeSessionPath, NativeSessionSchema);
174
+ await writeJson(join(worker.dir, "native-session.retired.json"), {
175
+ ...record,
176
+ retired_at: this.now().toISOString(),
177
+ retired_reason: reason
178
+ });
179
+ await removeIfExists(nativeSessionPath);
180
+ }
181
+ async appendEvent(task, type, message) {
182
+ const event = {
183
+ time: this.now().toISOString(),
184
+ type,
185
+ message,
186
+ task_id: task.id
187
+ };
188
+ await appendJsonLine(join(task.dir, "events.jsonl"), event);
189
+ }
190
+ async nextTurnId(task) {
191
+ const latest = await this.latestTurn(task);
192
+ if (!latest) {
193
+ return (await pathExists(join(task.dir, "user-request.md"))) ? "0002" : "0001";
194
+ }
195
+ return String(Number(latest.turnId) + 1).padStart(4, "0");
196
+ }
197
+ async indexTaskFromFiles(task) {
198
+ if (!this.index || !(await pathExists(task.metaPath))) {
199
+ return;
200
+ }
201
+ await this.index.upsertTask(await readJson(task.metaPath, TaskMetaSchema));
202
+ }
203
+ async backfillInitialTurn(task, fallbackRoute) {
204
+ const firstTurn = this.turnFiles(task, "0001");
205
+ if (await pathExists(firstTurn.metaPath)) {
206
+ return;
207
+ }
208
+ const userRequestPath = join(task.dir, "user-request.md");
209
+ if (!(await pathExists(userRequestPath))) {
210
+ return;
211
+ }
212
+ const request = (await readTextIfExists(userRequestPath)).trim();
213
+ if (!request) {
214
+ return;
215
+ }
216
+ const route = (await pathExists(task.routePath))
217
+ ? await readJson(task.routePath, RouteDecisionSchema)
218
+ : fallbackRoute;
219
+ const meta = (await pathExists(task.metaPath)) ? await readJson(task.metaPath, TaskMetaSchema) : null;
220
+ await this.writeTurn(task, "0001", request, route, meta ? new Date(meta.created_at) : this.now());
221
+ }
222
+ async writeTurn(task, turnId, request, route, createdAt) {
223
+ const files = this.turnFiles(task, turnId);
224
+ const turnMeta = {
225
+ task_id: task.id,
226
+ turn_id: turnId,
227
+ created_at: createdAt.toISOString(),
228
+ request_path: `turns/${turnId}/user.md`
229
+ };
230
+ await ensureDir(files.dir);
231
+ await writeText(files.userPath, `${request.trim()}\n`);
232
+ await writeJson(files.routePath, RouteDecisionSchema.parse(route));
233
+ await writeJson(files.metaPath, TurnMetaSchema.parse(turnMeta));
234
+ await this.index?.upsertTurn(task.id, turnMeta);
235
+ return files;
236
+ }
237
+ turnFiles(task, turnId) {
238
+ const dir = join(task.dir, "turns", turnId);
239
+ return {
240
+ turnId,
241
+ dir,
242
+ metaPath: join(dir, "turn.json"),
243
+ userPath: join(dir, "user.md"),
244
+ routePath: join(dir, "route.json")
245
+ };
246
+ }
247
+ taskIdFromWorkerDir(workerDir) {
248
+ return basename(dirname(workerDir));
249
+ }
250
+ async clearWorkerArtifacts(dir, role) {
251
+ const files = role === "actor"
252
+ ? ["worklog.md", "patch.diff"]
253
+ : role === "critic"
254
+ ? ["review.md"]
255
+ : [];
256
+ for (const file of files) {
257
+ await writeText(join(dir, file), "");
258
+ }
259
+ }
260
+ }
261
+ function titleFromRequest(request) {
262
+ const firstLine = request.trim().split("\n")[0] ?? "Untitled task";
263
+ return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
264
+ }