parallel-codex-tui 0.1.0 → 0.1.3
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.
- package/README.md +39 -1
- package/dist/bootstrap.js +17 -5
- package/dist/cli-args.js +65 -14
- package/dist/cli-workspace.js +104 -0
- package/dist/cli.js +82 -24
- package/dist/core/app-root.js +8 -0
- package/dist/core/config.js +38 -0
- package/dist/core/file-store.js +11 -0
- package/dist/core/paths.js +3 -0
- package/dist/core/router.js +13 -9
- package/dist/core/session-index.js +53 -9
- package/dist/core/session-manager.js +101 -9
- package/dist/core/workspace.js +118 -0
- package/dist/doctor.js +58 -24
- package/dist/orchestrator/orchestrator.js +37 -19
- package/dist/tui/App.js +10 -8
- package/dist/tui/task-memory.js +3 -0
- package/dist/version.js +1 -1
- package/dist/workers/native-attach.js +69 -5
- package/dist/workers/native-session-detection.js +17 -0
- package/dist/workers/process-adapter.js +11 -5
- package/package.json +14 -2
|
@@ -121,10 +121,19 @@ export class SessionIndex {
|
|
|
121
121
|
source=excluded.source`)
|
|
122
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
123
|
}
|
|
124
|
+
async deleteNativeSession(taskId, workerId) {
|
|
125
|
+
this.db.prepare("DELETE FROM native_sessions WHERE task_id = ? AND worker_id = ?").run(taskId, workerId);
|
|
126
|
+
}
|
|
124
127
|
async countRows(table) {
|
|
125
128
|
const row = this.db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get();
|
|
126
129
|
return row.count;
|
|
127
130
|
}
|
|
131
|
+
async workerNativeSessionId(taskId, workerId) {
|
|
132
|
+
const row = this.db
|
|
133
|
+
.prepare("SELECT native_session_id FROM workers WHERE task_id = ? AND worker_id = ?")
|
|
134
|
+
.get(taskId, workerId);
|
|
135
|
+
return row?.native_session_id ?? null;
|
|
136
|
+
}
|
|
128
137
|
async rebuildFromFiles() {
|
|
129
138
|
this.db.exec("DELETE FROM native_sessions; DELETE FROM workers; DELETE FROM turns; DELETE FROM tasks;");
|
|
130
139
|
const sessions = join(this.projectRoot, this.dataDir, "sessions");
|
|
@@ -145,7 +154,10 @@ export class SessionIndex {
|
|
|
145
154
|
async rebuildTask(taskDir, taskId) {
|
|
146
155
|
const metaPath = join(taskDir, "meta.json");
|
|
147
156
|
if (await pathExists(metaPath)) {
|
|
148
|
-
|
|
157
|
+
const meta = await readJsonIfValid(metaPath, TaskMetaSchema);
|
|
158
|
+
if (meta) {
|
|
159
|
+
await this.upsertTask(meta);
|
|
160
|
+
}
|
|
149
161
|
}
|
|
150
162
|
const turnsDir = join(taskDir, "turns");
|
|
151
163
|
if (await pathExists(turnsDir)) {
|
|
@@ -153,7 +165,10 @@ export class SessionIndex {
|
|
|
153
165
|
for (const turnEntry of turnEntries) {
|
|
154
166
|
const turnPath = join(turnsDir, turnEntry.name, "turn.json");
|
|
155
167
|
if (turnEntry.isDirectory() && (await pathExists(turnPath))) {
|
|
156
|
-
|
|
168
|
+
const turn = await readJsonIfValid(turnPath, TurnMetaSchema);
|
|
169
|
+
if (turn) {
|
|
170
|
+
await this.upsertTurn(taskId, turn);
|
|
171
|
+
}
|
|
157
172
|
}
|
|
158
173
|
}
|
|
159
174
|
}
|
|
@@ -164,17 +179,46 @@ export class SessionIndex {
|
|
|
164
179
|
}
|
|
165
180
|
const workerDir = join(taskDir, entry.name);
|
|
166
181
|
const statusPath = join(workerDir, "status.json");
|
|
182
|
+
const nativePath = join(workerDir, "native-session.json");
|
|
167
183
|
if (await pathExists(statusPath)) {
|
|
168
|
-
await this.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
184
|
+
const status = await this.readRebuildWorkerStatus(statusPath, nativePath);
|
|
185
|
+
if (status) {
|
|
186
|
+
await this.upsertWorker(taskId, status, {
|
|
187
|
+
dir: workerDir,
|
|
188
|
+
statusPath,
|
|
189
|
+
outputLogPath: join(workerDir, "output.log")
|
|
190
|
+
});
|
|
191
|
+
}
|
|
173
192
|
}
|
|
174
|
-
const nativePath = join(workerDir, "native-session.json");
|
|
175
193
|
if (await pathExists(nativePath)) {
|
|
176
|
-
|
|
194
|
+
const nativeSession = await readJsonIfValid(nativePath, NativeSessionSchema);
|
|
195
|
+
if (nativeSession) {
|
|
196
|
+
await this.upsertNativeSession(taskId, nativeSession);
|
|
197
|
+
}
|
|
177
198
|
}
|
|
178
199
|
}
|
|
179
200
|
}
|
|
201
|
+
async readRebuildWorkerStatus(statusPath, nativePath) {
|
|
202
|
+
const status = await readJsonIfValid(statusPath, WorkerStatusSchema);
|
|
203
|
+
if (!status) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
if (status.native_session_id && !(await readJsonIfValid(nativePath, NativeSessionSchema))) {
|
|
207
|
+
const nextStatus = { ...status };
|
|
208
|
+
delete nextStatus.native_session_id;
|
|
209
|
+
return WorkerStatusSchema.parse(nextStatus);
|
|
210
|
+
}
|
|
211
|
+
return status;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function readJsonIfValid(path, schema) {
|
|
215
|
+
if (!(await pathExists(path))) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
return await readJson(path, schema);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
180
224
|
}
|
|
@@ -3,7 +3,7 @@ import { basename, dirname, join } from "node:path";
|
|
|
3
3
|
import { appendJsonLine, ensureDir, pathExists, readJson, readTextIfExists, removeIfExists, writeJson, writeText } from "./file-store.js";
|
|
4
4
|
import { formatTaskTimestamp, taskDir } from "./paths.js";
|
|
5
5
|
import { sessionsRoot } from "./paths.js";
|
|
6
|
-
import { NativeSessionSchema, RouteDecisionSchema, TaskMetaSchema, TurnMetaSchema } from "../domain/schemas.js";
|
|
6
|
+
import { NativeSessionSchema, RouteDecisionSchema, TaskMetaSchema, TurnMetaSchema, WorkerStatusSchema } from "../domain/schemas.js";
|
|
7
7
|
export class SessionManager {
|
|
8
8
|
projectRoot;
|
|
9
9
|
dataDir;
|
|
@@ -57,6 +57,10 @@ export class SessionManager {
|
|
|
57
57
|
eventsPath: join(dir, "events.jsonl")
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
+
async hasTask(taskId) {
|
|
61
|
+
const task = this.taskFromId(taskId);
|
|
62
|
+
return Boolean(await readTaskMetaIfValid(task.metaPath));
|
|
63
|
+
}
|
|
60
64
|
async latestTask() {
|
|
61
65
|
const root = sessionsRoot(this.projectRoot, this.dataDir);
|
|
62
66
|
if (!(await pathExists(root))) {
|
|
@@ -70,7 +74,10 @@ export class SessionManager {
|
|
|
70
74
|
}
|
|
71
75
|
const metaPath = join(root, entry.name, "meta.json");
|
|
72
76
|
if (await pathExists(metaPath)) {
|
|
73
|
-
const meta = await
|
|
77
|
+
const meta = await readTaskMetaIfValid(metaPath);
|
|
78
|
+
if (!meta) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
74
81
|
if (meta.mode === "complex") {
|
|
75
82
|
tasks.push(meta);
|
|
76
83
|
}
|
|
@@ -159,7 +166,15 @@ export class SessionManager {
|
|
|
159
166
|
if (!(await pathExists(path))) {
|
|
160
167
|
return null;
|
|
161
168
|
}
|
|
162
|
-
|
|
169
|
+
try {
|
|
170
|
+
return await readJson(path, NativeSessionSchema);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
await removeIfExists(path);
|
|
174
|
+
await this.clearWorkerStatusNativeSession(worker);
|
|
175
|
+
await this.index?.deleteNativeSession(this.taskIdFromWorkerDir(worker.dir), this.workerIdFromWorkerDir(worker.dir));
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
163
178
|
}
|
|
164
179
|
async writeNativeSession(worker, record) {
|
|
165
180
|
await writeJson(join(worker.dir, "native-session.json"), NativeSessionSchema.parse(record));
|
|
@@ -170,13 +185,21 @@ export class SessionManager {
|
|
|
170
185
|
if (!(await pathExists(nativeSessionPath))) {
|
|
171
186
|
return;
|
|
172
187
|
}
|
|
173
|
-
const record = await
|
|
188
|
+
const record = await readNativeSessionIfValid(nativeSessionPath);
|
|
189
|
+
if (!record) {
|
|
190
|
+
await removeIfExists(nativeSessionPath);
|
|
191
|
+
await this.clearWorkerStatusNativeSession(worker);
|
|
192
|
+
await this.index?.deleteNativeSession(this.taskIdFromWorkerDir(worker.dir), this.workerIdFromWorkerDir(worker.dir));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
174
195
|
await writeJson(join(worker.dir, "native-session.retired.json"), {
|
|
175
196
|
...record,
|
|
176
197
|
retired_at: this.now().toISOString(),
|
|
177
198
|
retired_reason: reason
|
|
178
199
|
});
|
|
179
200
|
await removeIfExists(nativeSessionPath);
|
|
201
|
+
await this.clearWorkerStatusNativeSession(worker);
|
|
202
|
+
await this.index?.deleteNativeSession(this.taskIdFromWorkerDir(worker.dir), record.worker_id);
|
|
180
203
|
}
|
|
181
204
|
async appendEvent(task, type, message) {
|
|
182
205
|
const event = {
|
|
@@ -198,7 +221,10 @@ export class SessionManager {
|
|
|
198
221
|
if (!this.index || !(await pathExists(task.metaPath))) {
|
|
199
222
|
return;
|
|
200
223
|
}
|
|
201
|
-
|
|
224
|
+
const meta = await readTaskMetaIfValid(task.metaPath);
|
|
225
|
+
if (meta) {
|
|
226
|
+
await this.index.upsertTask(meta);
|
|
227
|
+
}
|
|
202
228
|
}
|
|
203
229
|
async backfillInitialTurn(task, fallbackRoute) {
|
|
204
230
|
const firstTurn = this.turnFiles(task, "0001");
|
|
@@ -213,10 +239,8 @@ export class SessionManager {
|
|
|
213
239
|
if (!request) {
|
|
214
240
|
return;
|
|
215
241
|
}
|
|
216
|
-
const route = (await
|
|
217
|
-
|
|
218
|
-
: fallbackRoute;
|
|
219
|
-
const meta = (await pathExists(task.metaPath)) ? await readJson(task.metaPath, TaskMetaSchema) : null;
|
|
242
|
+
const route = (await readRouteDecisionIfValid(task.routePath)) ?? fallbackRoute;
|
|
243
|
+
const meta = await readTaskMetaIfValid(task.metaPath);
|
|
220
244
|
await this.writeTurn(task, "0001", request, route, meta ? new Date(meta.created_at) : this.now());
|
|
221
245
|
}
|
|
222
246
|
async writeTurn(task, turnId, request, route, createdAt) {
|
|
@@ -247,6 +271,30 @@ export class SessionManager {
|
|
|
247
271
|
taskIdFromWorkerDir(workerDir) {
|
|
248
272
|
return basename(dirname(workerDir));
|
|
249
273
|
}
|
|
274
|
+
workerIdFromWorkerDir(workerDir) {
|
|
275
|
+
return basename(workerDir);
|
|
276
|
+
}
|
|
277
|
+
async clearWorkerStatusNativeSession(worker) {
|
|
278
|
+
const statusPath = join(worker.dir, "status.json");
|
|
279
|
+
if (!(await pathExists(statusPath))) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const status = await readWorkerStatusIfValid(statusPath);
|
|
283
|
+
if (!status) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (!status.native_session_id) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const nextStatus = { ...status };
|
|
290
|
+
delete nextStatus.native_session_id;
|
|
291
|
+
await writeJson(statusPath, WorkerStatusSchema.parse(nextStatus));
|
|
292
|
+
await this.index?.upsertWorker(this.taskIdFromWorkerDir(worker.dir), nextStatus, {
|
|
293
|
+
dir: worker.dir,
|
|
294
|
+
statusPath,
|
|
295
|
+
outputLogPath: join(worker.dir, "output.log")
|
|
296
|
+
});
|
|
297
|
+
}
|
|
250
298
|
async clearWorkerArtifacts(dir, role) {
|
|
251
299
|
const files = role === "actor"
|
|
252
300
|
? ["worklog.md", "patch.diff"]
|
|
@@ -262,3 +310,47 @@ function titleFromRequest(request) {
|
|
|
262
310
|
const firstLine = request.trim().split("\n")[0] ?? "Untitled task";
|
|
263
311
|
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
|
|
264
312
|
}
|
|
313
|
+
async function readTaskMetaIfValid(metaPath) {
|
|
314
|
+
if (!(await pathExists(metaPath))) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
return await readJson(metaPath, TaskMetaSchema);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async function readRouteDecisionIfValid(routePath) {
|
|
325
|
+
if (!(await pathExists(routePath))) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
return await readJson(routePath, RouteDecisionSchema);
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async function readNativeSessionIfValid(nativeSessionPath) {
|
|
336
|
+
if (!(await pathExists(nativeSessionPath))) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
return await readJson(nativeSessionPath, NativeSessionSchema);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async function readWorkerStatusIfValid(statusPath) {
|
|
347
|
+
if (!(await pathExists(statusPath))) {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
return await readJson(statusPath, WorkerStatusSchema);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ensureDir, pathExists, pathIsDirectory, readJson, readTextIfExists, writeJson, writeText } from "./file-store.js";
|
|
5
|
+
const lastWorkspaceFile = "last-workspace";
|
|
6
|
+
const workspacesFile = "workspaces.json";
|
|
7
|
+
const maxRememberedWorkspaces = 20;
|
|
8
|
+
const WorkspaceRegistrySchema = z.object({
|
|
9
|
+
version: z.literal(1).default(1),
|
|
10
|
+
workspaces: z
|
|
11
|
+
.array(z.object({
|
|
12
|
+
path: z.string().min(1),
|
|
13
|
+
last_used_at: z.string().min(1)
|
|
14
|
+
}))
|
|
15
|
+
.default([])
|
|
16
|
+
});
|
|
17
|
+
export async function resolveWorkspaceSelection(input) {
|
|
18
|
+
if (input.explicitWorkspace?.trim()) {
|
|
19
|
+
return resolveWorkspacePath(input.cwd, input.explicitWorkspace);
|
|
20
|
+
}
|
|
21
|
+
const [latest] = await listWorkspaceChoices(input.appRoot);
|
|
22
|
+
return latest ? latest.path : input.cwd;
|
|
23
|
+
}
|
|
24
|
+
export async function prepareWorkspace(appRoot, workspaceRoot) {
|
|
25
|
+
const resolved = resolveWorkspacePath(process.cwd(), workspaceRoot);
|
|
26
|
+
if ((await pathExists(resolved)) && !(await pathIsDirectory(resolved))) {
|
|
27
|
+
throw new Error(`Workspace path exists but is not a directory: ${resolved}`);
|
|
28
|
+
}
|
|
29
|
+
await ensureDir(resolved);
|
|
30
|
+
await ensureDir(join(resolved, ".parallel-codex"));
|
|
31
|
+
await rememberWorkspace(appRoot, resolved);
|
|
32
|
+
return resolved;
|
|
33
|
+
}
|
|
34
|
+
export async function hasSavedWorkspace(appRoot) {
|
|
35
|
+
return (await listWorkspaceChoices(appRoot)).length > 0;
|
|
36
|
+
}
|
|
37
|
+
export async function listWorkspaceChoices(appRoot) {
|
|
38
|
+
const entries = await readWorkspaceEntries(appRoot);
|
|
39
|
+
const legacy = (await readTextIfExists(lastWorkspacePath(appRoot))).trim();
|
|
40
|
+
if (legacy) {
|
|
41
|
+
entries.push({ path: legacy, last_used_at: "" });
|
|
42
|
+
}
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
const unique = entries
|
|
45
|
+
.map((entry, index) => ({
|
|
46
|
+
path: resolveStoredWorkspacePath(appRoot, entry.path),
|
|
47
|
+
lastUsedAt: entry.last_used_at || null,
|
|
48
|
+
order: index
|
|
49
|
+
}))
|
|
50
|
+
.filter((entry) => {
|
|
51
|
+
if (seen.has(entry.path)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
seen.add(entry.path);
|
|
55
|
+
return true;
|
|
56
|
+
})
|
|
57
|
+
.sort((left, right) => {
|
|
58
|
+
const byDate = (right.lastUsedAt ?? "").localeCompare(left.lastUsedAt ?? "");
|
|
59
|
+
return byDate === 0 ? left.order - right.order : byDate;
|
|
60
|
+
});
|
|
61
|
+
const choices = await Promise.all(unique.map(async (entry) => {
|
|
62
|
+
const exists = await pathExists(entry.path);
|
|
63
|
+
const isDirectory = exists && (await pathIsDirectory(entry.path));
|
|
64
|
+
return {
|
|
65
|
+
path: entry.path,
|
|
66
|
+
exists: isDirectory,
|
|
67
|
+
lastUsedAt: entry.lastUsedAt,
|
|
68
|
+
selectable: !exists || isDirectory
|
|
69
|
+
};
|
|
70
|
+
}));
|
|
71
|
+
return choices
|
|
72
|
+
.filter((choice) => choice.selectable)
|
|
73
|
+
.map(({ selectable: _selectable, ...choice }) => choice);
|
|
74
|
+
}
|
|
75
|
+
async function rememberWorkspace(appRoot, workspaceRoot) {
|
|
76
|
+
const now = new Date().toISOString();
|
|
77
|
+
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));
|
|
88
|
+
}
|
|
89
|
+
async function readWorkspaceEntries(appRoot) {
|
|
90
|
+
const file = workspacesPath(appRoot);
|
|
91
|
+
if (!(await pathExists(file))) {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
return (await readJson(file, WorkspaceRegistrySchema)).workspaces;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function lastWorkspacePath(appRoot) {
|
|
102
|
+
return join(appRoot, ".parallel-codex", lastWorkspaceFile);
|
|
103
|
+
}
|
|
104
|
+
function workspacesPath(appRoot) {
|
|
105
|
+
return join(appRoot, ".parallel-codex", workspacesFile);
|
|
106
|
+
}
|
|
107
|
+
export function resolveWorkspacePath(cwd, value) {
|
|
108
|
+
if (value === "~") {
|
|
109
|
+
return homedir();
|
|
110
|
+
}
|
|
111
|
+
if (value.startsWith("~/")) {
|
|
112
|
+
return resolve(homedir(), value.slice(2));
|
|
113
|
+
}
|
|
114
|
+
return resolve(cwd, value);
|
|
115
|
+
}
|
|
116
|
+
function resolveStoredWorkspacePath(appRoot, value) {
|
|
117
|
+
return resolveWorkspacePath(appRoot, value);
|
|
118
|
+
}
|
package/dist/doctor.js
CHANGED
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
2
|
import { access } from "node:fs/promises";
|
|
3
3
|
import { delimiter, join } from "node:path";
|
|
4
|
+
import { prepareAppRoot } from "./core/app-root.js";
|
|
4
5
|
import { configPath, loadConfig } from "./core/config.js";
|
|
5
6
|
import { pathExists } from "./core/file-store.js";
|
|
7
|
+
import { prepareWorkspace } from "./core/workspace.js";
|
|
6
8
|
export async function runDoctor(appRoot, workspaceRoot, env = process.env) {
|
|
7
9
|
const lines = ["parallel-codex-tui doctor"];
|
|
8
10
|
let ok = true;
|
|
11
|
+
await prepareAppRoot(appRoot);
|
|
12
|
+
const preparedWorkspace = await prepareWorkspace(appRoot, workspaceRoot);
|
|
9
13
|
if (isSupportedNodeVersion(process.versions.node)) {
|
|
10
14
|
lines.push(`Node.js: ok (${process.versions.node})`);
|
|
11
15
|
}
|
|
12
16
|
else {
|
|
13
17
|
ok = false;
|
|
14
|
-
lines.push(`Node.js: unsupported (${process.versions.node}; need
|
|
15
|
-
}
|
|
16
|
-
if (await pathExists(workspaceRoot)) {
|
|
17
|
-
lines.push(`workspace: ok (${workspaceRoot})`);
|
|
18
|
-
}
|
|
19
|
-
else {
|
|
20
|
-
ok = false;
|
|
21
|
-
lines.push(`workspace: missing (${workspaceRoot})`);
|
|
18
|
+
lines.push(`Node.js: unsupported (${process.versions.node}; need 26+)`);
|
|
22
19
|
}
|
|
20
|
+
lines.push(`workspace: ok (${preparedWorkspace})`);
|
|
23
21
|
const localConfigPath = configPath(appRoot);
|
|
24
22
|
if (!(await pathExists(localConfigPath))) {
|
|
25
23
|
ok = false;
|
|
@@ -51,21 +49,49 @@ export async function runDoctor(appRoot, workspaceRoot, env = process.env) {
|
|
|
51
49
|
lines.push(`${command}: missing`);
|
|
52
50
|
}
|
|
53
51
|
}
|
|
52
|
+
for (const check of configuredWorkerModelEnvChecks(config, env)) {
|
|
53
|
+
if (check.ok) {
|
|
54
|
+
lines.push(`${check.label}: ok`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
ok = false;
|
|
58
|
+
lines.push(`${check.label}: missing env ${check.envName}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
54
61
|
return {
|
|
55
62
|
ok,
|
|
56
63
|
text: `${lines.join("\n")}\n`
|
|
57
64
|
};
|
|
58
65
|
}
|
|
59
|
-
function isSupportedNodeVersion(version) {
|
|
66
|
+
export function isSupportedNodeVersion(version) {
|
|
60
67
|
const [majorRaw = "0", minorRaw = "0"] = version.split(".");
|
|
61
68
|
const major = Number.parseInt(majorRaw, 10);
|
|
62
69
|
const minor = Number.parseInt(minorRaw, 10);
|
|
63
|
-
return major
|
|
70
|
+
return major >= 26;
|
|
64
71
|
}
|
|
65
72
|
function configuredCommands(config) {
|
|
73
|
+
return Array.from(new Set(configuredEngines(config, { includeRouter: true })
|
|
74
|
+
.map((engine) => commandForEngine(config, engine))
|
|
75
|
+
.filter((command) => Boolean(command) && command !== "mock")));
|
|
76
|
+
}
|
|
77
|
+
function commandForEngine(config, engine) {
|
|
78
|
+
if (engine === "router-codex") {
|
|
79
|
+
return config.router.codex.command;
|
|
80
|
+
}
|
|
81
|
+
if (engine === "codex") {
|
|
82
|
+
return config.workers.codex.command;
|
|
83
|
+
}
|
|
84
|
+
if (engine === "claude") {
|
|
85
|
+
return config.workers.claude.command;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
function configuredEngines(config, options) {
|
|
66
90
|
const engines = new Set();
|
|
67
|
-
if (config.router.defaultMode === "auto") {
|
|
91
|
+
if (config.router.defaultMode === "auto" && options.includeRouter) {
|
|
68
92
|
engines.add("router-codex");
|
|
93
|
+
}
|
|
94
|
+
if (config.router.defaultMode === "auto") {
|
|
69
95
|
engines.add(config.pairing.main);
|
|
70
96
|
engines.add(config.pairing.judge);
|
|
71
97
|
engines.add(config.pairing.actor);
|
|
@@ -79,21 +105,29 @@ function configuredCommands(config) {
|
|
|
79
105
|
engines.add(config.pairing.actor);
|
|
80
106
|
engines.add(config.pairing.critic);
|
|
81
107
|
}
|
|
82
|
-
return Array.from(
|
|
83
|
-
.map((engine) => commandForEngine(config, engine))
|
|
84
|
-
.filter((command) => Boolean(command) && command !== "mock")));
|
|
108
|
+
return Array.from(engines);
|
|
85
109
|
}
|
|
86
|
-
function
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
110
|
+
function configuredWorkerEngines(config) {
|
|
111
|
+
return configuredEngines(config, { includeRouter: false }).filter((engine) => engine === "codex" || engine === "claude");
|
|
112
|
+
}
|
|
113
|
+
function configuredWorkerModelEnvChecks(config, env) {
|
|
114
|
+
const checks = [];
|
|
115
|
+
for (const engine of configuredWorkerEngines(config)) {
|
|
116
|
+
const worker = engine === "codex" ? config.workers.codex : config.workers.claude;
|
|
117
|
+
for (const [key, value] of Object.entries(worker.model.env)) {
|
|
118
|
+
for (const envName of referencedEnvNames(value)) {
|
|
119
|
+
checks.push({
|
|
120
|
+
label: `workers.${engine}.model.env.${key}`,
|
|
121
|
+
envName,
|
|
122
|
+
ok: Boolean(env[envName])
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
95
126
|
}
|
|
96
|
-
return
|
|
127
|
+
return checks;
|
|
128
|
+
}
|
|
129
|
+
function referencedEnvNames(value) {
|
|
130
|
+
return Array.from(value.matchAll(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g), (match) => match[1] ?? "");
|
|
97
131
|
}
|
|
98
132
|
async function commandExists(command, env) {
|
|
99
133
|
if (command.includes("/")) {
|
|
@@ -12,14 +12,16 @@ export class Orchestrator {
|
|
|
12
12
|
sessions;
|
|
13
13
|
workers;
|
|
14
14
|
routeRunner;
|
|
15
|
-
|
|
15
|
+
routerCwd;
|
|
16
|
+
constructor(config, sessions, workers, routeRunner, routerCwd = config.projectRoot) {
|
|
16
17
|
this.config = config;
|
|
17
18
|
this.sessions = sessions;
|
|
18
19
|
this.workers = workers;
|
|
19
20
|
this.routeRunner = routeRunner;
|
|
21
|
+
this.routerCwd = routerCwd;
|
|
20
22
|
}
|
|
21
23
|
async handleRequest(input) {
|
|
22
|
-
const route = await
|
|
24
|
+
const route = await this.routeRequest(input.request);
|
|
23
25
|
const workers = [];
|
|
24
26
|
if (route.mode === "simple") {
|
|
25
27
|
input.onStatus?.({ taskId: "main", main: "running" });
|
|
@@ -104,7 +106,7 @@ export class Orchestrator {
|
|
|
104
106
|
}
|
|
105
107
|
async handleTaskTurn(input) {
|
|
106
108
|
const task = this.sessions.taskFromId(input.taskId);
|
|
107
|
-
const route = await
|
|
109
|
+
const route = await this.routeRequest(input.request);
|
|
108
110
|
const turn = await this.sessions.appendTurn(task, {
|
|
109
111
|
request: input.request,
|
|
110
112
|
route
|
|
@@ -170,19 +172,7 @@ export class Orchestrator {
|
|
|
170
172
|
}
|
|
171
173
|
}
|
|
172
174
|
async routeTaskFollowUp(input) {
|
|
173
|
-
const
|
|
174
|
-
const meta = (await pathExists(task.metaPath)) ? await readJson(task.metaPath, TaskMetaSchema) : null;
|
|
175
|
-
const route = await routeRequestWithCodex([
|
|
176
|
-
"Active task follow-up routing.",
|
|
177
|
-
`Task id: ${input.taskId}`,
|
|
178
|
-
`Task status: ${meta?.status ?? "unknown"}`,
|
|
179
|
-
"",
|
|
180
|
-
"Choose simple for status/log/reason/explanation questions that can be answered from session files.",
|
|
181
|
-
"Choose complex for requests that change direction, ask for code changes, reruns, fixes, new experiments, or implementation work.",
|
|
182
|
-
"",
|
|
183
|
-
"Follow-up request:",
|
|
184
|
-
input.request
|
|
185
|
-
].join("\n"), this.config, this.routeRunner, input.cwd);
|
|
175
|
+
const route = await this.routeRequest(input.request);
|
|
186
176
|
return {
|
|
187
177
|
mode: route.mode,
|
|
188
178
|
taskId: route.mode === "complex" ? input.taskId : null,
|
|
@@ -191,7 +181,7 @@ export class Orchestrator {
|
|
|
191
181
|
}
|
|
192
182
|
async answerTaskQuestion(input) {
|
|
193
183
|
const task = this.sessions.taskFromId(input.taskId);
|
|
194
|
-
const meta =
|
|
184
|
+
const meta = await readTaskMetaIfValid(task.metaPath);
|
|
195
185
|
const workerSummaries = await Promise.all(["judge", "actor", "critic"].map((role) => this.readLatestWorkerQuestionSummary(task, role)));
|
|
196
186
|
const workers = workerSummaries.filter((worker) => worker !== null);
|
|
197
187
|
const failed = workers.find((worker) => worker.status.state === "failed");
|
|
@@ -228,7 +218,10 @@ export class Orchestrator {
|
|
|
228
218
|
if (!(await pathExists(statusPath))) {
|
|
229
219
|
continue;
|
|
230
220
|
}
|
|
231
|
-
const status = await
|
|
221
|
+
const status = await readWorkerStatusIfValid(statusPath);
|
|
222
|
+
if (!status) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
232
225
|
workers.push({
|
|
233
226
|
id: status.worker_id,
|
|
234
227
|
role: status.role,
|
|
@@ -240,6 +233,9 @@ export class Orchestrator {
|
|
|
240
233
|
}
|
|
241
234
|
return workers.sort((left, right) => workerRoleOrder(left.role) - workerRoleOrder(right.role));
|
|
242
235
|
}
|
|
236
|
+
routeRequest(request) {
|
|
237
|
+
return routeRequestWithCodex(request, this.config, this.routeRunner, this.routerCwd);
|
|
238
|
+
}
|
|
243
239
|
async runMain(input, workers) {
|
|
244
240
|
const engine = this.config.pairing.main;
|
|
245
241
|
const dir = this.sessions.mainSessionDir();
|
|
@@ -476,7 +472,10 @@ export class Orchestrator {
|
|
|
476
472
|
if (!(await pathExists(files.statusPath))) {
|
|
477
473
|
continue;
|
|
478
474
|
}
|
|
479
|
-
const status = await
|
|
475
|
+
const status = await readWorkerStatusIfValid(files.statusPath);
|
|
476
|
+
if (!status) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
480
479
|
return {
|
|
481
480
|
status,
|
|
482
481
|
logTail: tailText(await readTextIfExists(files.outputLogPath), 8)
|
|
@@ -490,6 +489,25 @@ function ensureWorkerSuccess(workerId, exitCode) {
|
|
|
490
489
|
throw new Error(`${workerId} failed with exit code ${exitCode}`);
|
|
491
490
|
}
|
|
492
491
|
}
|
|
492
|
+
async function readWorkerStatusIfValid(statusPath) {
|
|
493
|
+
try {
|
|
494
|
+
return await readJson(statusPath, WorkerStatusSchema);
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
async function readTaskMetaIfValid(metaPath) {
|
|
501
|
+
if (!(await pathExists(metaPath))) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
return await readJson(metaPath, TaskMetaSchema);
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
493
511
|
function summarizeRetirementReason(reason) {
|
|
494
512
|
const lines = reason
|
|
495
513
|
.split(/\r?\n/)
|
package/dist/tui/App.js
CHANGED
|
@@ -10,7 +10,7 @@ import { AppShell } from "./AppShell.js";
|
|
|
10
10
|
import { InputBar } from "./InputBar.js";
|
|
11
11
|
import { applyNativeInputChunk } from "./native-input.js";
|
|
12
12
|
import { nextScrollOffset } from "./scrolling.js";
|
|
13
|
-
import { chooseSubmitTarget, nextSubmitMemoryState } from "./task-memory.js";
|
|
13
|
+
import { chooseSubmitTarget, nextSubmitMemoryState, shouldClearWorkersForSubmit } from "./task-memory.js";
|
|
14
14
|
import { TerminalOutput } from "./TerminalOutput.js";
|
|
15
15
|
import { NativeTerminalScreen } from "./terminal-screen.js";
|
|
16
16
|
import { WorkerOutputView } from "./WorkerOutputView.js";
|
|
@@ -405,13 +405,6 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, prepareNa
|
|
|
405
405
|
setInput("");
|
|
406
406
|
busyRef.current = true;
|
|
407
407
|
setBusy(true);
|
|
408
|
-
setWorkers([]);
|
|
409
|
-
selectedWorkerIndexRef.current = 0;
|
|
410
|
-
autoSelectedFailedWorkerRef.current = false;
|
|
411
|
-
userSelectedWorkerRef.current = false;
|
|
412
|
-
setSelectedWorkerIndex(0);
|
|
413
|
-
setWorkerScrollOffset(0);
|
|
414
|
-
setWorkerMaxScrollOffset(0);
|
|
415
408
|
setMessages((current) => [...current, { from: "user", text: request }]);
|
|
416
409
|
try {
|
|
417
410
|
const callbacks = {
|
|
@@ -433,6 +426,15 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, prepareNa
|
|
|
433
426
|
})
|
|
434
427
|
: undefined;
|
|
435
428
|
const target = chooseSubmitTarget(memory, followUpRoute);
|
|
429
|
+
if (shouldClearWorkersForSubmit(target)) {
|
|
430
|
+
setWorkers([]);
|
|
431
|
+
selectedWorkerIndexRef.current = 0;
|
|
432
|
+
autoSelectedFailedWorkerRef.current = false;
|
|
433
|
+
userSelectedWorkerRef.current = false;
|
|
434
|
+
setSelectedWorkerIndex(0);
|
|
435
|
+
setWorkerScrollOffset(0);
|
|
436
|
+
setWorkerMaxScrollOffset(0);
|
|
437
|
+
}
|
|
436
438
|
const result = target.kind === "task-turn"
|
|
437
439
|
? await orchestrator.handleTaskTurn({
|
|
438
440
|
request,
|
package/dist/tui/task-memory.js
CHANGED
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "0.1.
|
|
1
|
+
export const version = "0.1.3";
|