input-kanban 0.0.3 → 0.0.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.
- package/ENVIRONMENT.md +5 -2
- package/PROJECT_GUIDE.md +13 -5
- package/README.en.md +8 -4
- package/README.md +8 -4
- package/bin/input-kanban-format-events.js +12 -0
- package/bin/input-kanban-tmux-overview.js +66 -0
- package/package.json +2 -2
- package/public/index.html +152 -92
- package/src/eventFormatter.js +85 -0
- package/src/orchestrator.js +73 -150
- package/src/runners/tmuxRunner.js +73 -23
- package/src/runners/tmuxUtils.js +3 -1
- package/src/tmux.js +17 -0
package/src/orchestrator.js
CHANGED
|
@@ -7,32 +7,28 @@ import {
|
|
|
7
7
|
pathForRun, roleDir, safeIdPart, RUNNER
|
|
8
8
|
} from './utils.js';
|
|
9
9
|
import { matchThreadToMarkers } from './appServerClient.js';
|
|
10
|
+
import { formatCodexEventsJsonl } from './eventFormatter.js';
|
|
10
11
|
import { defaultRunner } from './runners/index.js';
|
|
11
12
|
|
|
12
13
|
const runner = defaultRunner;
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
'
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
'continue',
|
|
21
|
-
'password',
|
|
22
|
-
'authentication',
|
|
23
|
-
'authenticate'
|
|
24
|
-
];
|
|
14
|
+
const VALID_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
|
15
|
+
|
|
16
|
+
function normalizeSandbox(value, fallback = 'workspace-write') {
|
|
17
|
+
const sandbox = String(value || '').trim();
|
|
18
|
+
if (VALID_SANDBOXES.has(sandbox)) return sandbox;
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
25
21
|
|
|
26
22
|
function statePath(runDir) { return path.join(runDir, 'run_state.json'); }
|
|
27
23
|
function planPath(runDir) { return path.join(runDir, 'plan.json'); }
|
|
28
24
|
|
|
29
|
-
export async function createRun({ label = 'task', taskText = '', repo = DEFAULT_REPO, maxParallel = 3 } = {}) {
|
|
25
|
+
export async function createRun({ label = 'task', taskText = '', repo = DEFAULT_REPO, maxParallel = 3, workerSandbox = 'workspace-write' } = {}) {
|
|
30
26
|
const runId = makeRunId(label);
|
|
31
27
|
const runDir = pathForRun(runId);
|
|
32
28
|
await ensureDir(runDir);
|
|
33
29
|
await fsp.writeFile(path.join(runDir, 'task.md'), taskText || '');
|
|
34
30
|
const state = {
|
|
35
|
-
runId, label, repo: path.resolve(repo), maxParallel: Number(maxParallel) || 3,
|
|
31
|
+
runId, label, repo: path.resolve(repo), maxParallel: Number(maxParallel) || 3, workerSandbox: normalizeSandbox(workerSandbox),
|
|
36
32
|
runner: RUNNER,
|
|
37
33
|
status: 'created', createdAt: nowIso(), updatedAt: nowIso(),
|
|
38
34
|
planner: { status: 'pending' }, batches: [], tasks: [], judge: { status: 'pending' }
|
|
@@ -87,7 +83,7 @@ Preferred schema with blocking batches:
|
|
|
87
83
|
"id": "T-01",
|
|
88
84
|
"name": "short name",
|
|
89
85
|
"prompt": "complete worker prompt",
|
|
90
|
-
"sandbox": "workspace-write",
|
|
86
|
+
"sandbox": "${state.workerSandbox || 'workspace-write'}",
|
|
91
87
|
"expectedArtifacts": []
|
|
92
88
|
}
|
|
93
89
|
]
|
|
@@ -103,7 +99,7 @@ Backward-compatible schema also accepted:
|
|
|
103
99
|
"id": "T-01",
|
|
104
100
|
"name": "short name",
|
|
105
101
|
"prompt": "complete worker prompt",
|
|
106
|
-
"sandbox": "workspace-write",
|
|
102
|
+
"sandbox": "${state.workerSandbox || 'workspace-write'}",
|
|
107
103
|
"expectedArtifacts": []
|
|
108
104
|
}
|
|
109
105
|
]
|
|
@@ -114,6 +110,7 @@ Rules:
|
|
|
114
110
|
- Use batch maxParallel to express whether tasks in the same batch may run concurrently or serially.
|
|
115
111
|
- Keep tasks scoped and independently executable.
|
|
116
112
|
- Include exact output/artifact expectations in each worker prompt.
|
|
113
|
+
- Default worker sandbox for this run is ${state.workerSandbox || 'workspace-write'}; use that sandbox unless a task has a specific safety reason to be stricter.
|
|
117
114
|
- If the input already contains task sections, preserve their ids when practical.
|
|
118
115
|
|
|
119
116
|
User task:
|
|
@@ -163,7 +160,7 @@ export async function startPlanner(runId) {
|
|
|
163
160
|
await fsp.rm(planPath(runDir), { force: true });
|
|
164
161
|
const taskText = await fsp.readFile(path.join(runDir, 'task.md'), 'utf8');
|
|
165
162
|
const prompt = defaultPlannerPrompt(state, taskText);
|
|
166
|
-
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'planner', prompt, sandbox: 'read-only', cwd: state.repo, outDir });
|
|
163
|
+
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'planner', batchId: 'planner', runStatePath: statePath(runDir), prompt, sandbox: 'read-only', cwd: state.repo, outDir });
|
|
167
164
|
state.status = 'planning';
|
|
168
165
|
state.planner = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir, attempt: (state.plannerAttempts?.length || 0) + 1 };
|
|
169
166
|
await saveRun(state);
|
|
@@ -180,14 +177,14 @@ export async function startPlanner(runId) {
|
|
|
180
177
|
return state;
|
|
181
178
|
}
|
|
182
179
|
|
|
183
|
-
function normalizeTask(t, i, batch) {
|
|
180
|
+
function normalizeTask(t, i, batch, defaultSandbox = 'workspace-write') {
|
|
184
181
|
const id = safeIdPart(t.id || `T-${String(i + 1).padStart(2, '0')}`);
|
|
185
182
|
return {
|
|
186
183
|
id,
|
|
187
184
|
batchId: batch.id,
|
|
188
185
|
name: t.name || t.id || `Task ${i + 1}`,
|
|
189
186
|
prompt: t.prompt || t.instructions || '',
|
|
190
|
-
sandbox: t.sandbox
|
|
187
|
+
sandbox: normalizeSandbox(t.sandbox, defaultSandbox),
|
|
191
188
|
expectedArtifacts: Array.isArray(t.expectedArtifacts) ? t.expectedArtifacts : [],
|
|
192
189
|
status: 'pending'
|
|
193
190
|
};
|
|
@@ -220,7 +217,7 @@ async function rotatePlannerAttempt(state, runDir) {
|
|
|
220
217
|
}];
|
|
221
218
|
}
|
|
222
219
|
|
|
223
|
-
function normalizePlan(plan, defaultMaxParallel) {
|
|
220
|
+
function normalizePlan(plan, defaultMaxParallel, defaultSandbox = 'workspace-write') {
|
|
224
221
|
if (Array.isArray(plan.batches)) {
|
|
225
222
|
const batches = plan.batches.map((b, bi) => {
|
|
226
223
|
const batch = {
|
|
@@ -230,14 +227,14 @@ function normalizePlan(plan, defaultMaxParallel) {
|
|
|
230
227
|
status: 'pending',
|
|
231
228
|
tasks: []
|
|
232
229
|
};
|
|
233
|
-
batch.tasks = (Array.isArray(b.tasks) ? b.tasks : []).map((t, ti) => normalizeTask(t, ti, batch));
|
|
230
|
+
batch.tasks = (Array.isArray(b.tasks) ? b.tasks : []).map((t, ti) => normalizeTask(t, ti, batch, defaultSandbox));
|
|
234
231
|
return batch;
|
|
235
232
|
}).filter(b => b.tasks.length);
|
|
236
233
|
return { ...plan, batches, tasks: batches.flatMap(b => b.tasks) };
|
|
237
234
|
}
|
|
238
235
|
if (Array.isArray(plan.tasks)) {
|
|
239
236
|
const batch = { id: 'batch-1', name: '默认批次', maxParallel: Math.max(1, Number(defaultMaxParallel) || 1), status: 'pending', tasks: [] };
|
|
240
|
-
batch.tasks = plan.tasks.map((t, i) => normalizeTask(t, i, batch));
|
|
237
|
+
batch.tasks = plan.tasks.map((t, i) => normalizeTask(t, i, batch, defaultSandbox));
|
|
241
238
|
return { ...plan, batches: [batch], tasks: batch.tasks };
|
|
242
239
|
}
|
|
243
240
|
return null;
|
|
@@ -253,7 +250,7 @@ async function materializePlan(state) {
|
|
|
253
250
|
state.tasks = [];
|
|
254
251
|
return { ok: false, empty: false, error: state.planner.planParseError };
|
|
255
252
|
}
|
|
256
|
-
const normalized = normalizePlan(plan, state.maxParallel);
|
|
253
|
+
const normalized = normalizePlan(plan, state.maxParallel, state.workerSandbox || 'workspace-write');
|
|
257
254
|
if (!normalized || !Array.isArray(normalized.tasks)) {
|
|
258
255
|
state.planner.planParseError = 'planner JSON did not contain { batches: [...] } or { tasks: [...] }';
|
|
259
256
|
state.batches = [];
|
|
@@ -299,7 +296,7 @@ ORCHESTRATOR_BATCH_ID: ${task.batchId || 'batch-1'}
|
|
|
299
296
|
|
|
300
297
|
${task.prompt}
|
|
301
298
|
`;
|
|
302
|
-
const child = await runner.startCodexTask({ runId: state.runId, taskId: task.id, prompt: fullPrompt, sandbox: task.sandbox || 'workspace-write', cwd: state.repo, outDir });
|
|
299
|
+
const child = await runner.startCodexTask({ runId: state.runId, taskId: task.id, batchId: task.batchId || 'batch-1', runStatePath: statePath(runDir), prompt: fullPrompt, sandbox: task.sandbox || state.workerSandbox || 'workspace-write', cwd: state.repo, outDir });
|
|
303
300
|
Object.assign(task, { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir });
|
|
304
301
|
}
|
|
305
302
|
|
|
@@ -390,7 +387,7 @@ export async function startJudge(runId) {
|
|
|
390
387
|
const judgeInput = await buildJudgeInput(state);
|
|
391
388
|
await writeJsonAtomic(judgeInputPath, judgeInput);
|
|
392
389
|
const prompt = defaultJudgePrompt(state, judgeInputPath);
|
|
393
|
-
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', prompt, sandbox: 'read-only', cwd: state.repo, outDir });
|
|
390
|
+
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(pathForRun(runId)), prompt, sandbox: 'read-only', cwd: state.repo, outDir });
|
|
394
391
|
state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir };
|
|
395
392
|
state.status = 'judging';
|
|
396
393
|
await saveRun(state);
|
|
@@ -419,6 +416,7 @@ async function loadAndRefreshRun(runId, appClient = null, { light = false } = {}
|
|
|
419
416
|
for (const task of state.tasks || []) await refreshTask(state, task);
|
|
420
417
|
await refreshRole(state, state.judge, roleDir(pathForRun(runId), 'judge'));
|
|
421
418
|
await recoverCompletedJudge(state);
|
|
419
|
+
aggregateRunTmuxMetadata(state);
|
|
422
420
|
recomputeRunStatus(state);
|
|
423
421
|
await scheduleMoreWorkers(state);
|
|
424
422
|
recomputeRunStatus(state);
|
|
@@ -463,7 +461,6 @@ async function refreshRole(state, roleState, dir) {
|
|
|
463
461
|
else if (roleState.status === 'running' && !runner.hasRunning(state.runId, key)) roleState.status = 'unknown';
|
|
464
462
|
roleState.files = await standardFiles(dir);
|
|
465
463
|
await attachTmuxMetadata(roleState, dir);
|
|
466
|
-
roleState.attentionHint = await buildAttentionHint({ state, target: roleState, dir });
|
|
467
464
|
}
|
|
468
465
|
|
|
469
466
|
async function refreshTask(state, task) {
|
|
@@ -478,7 +475,7 @@ async function refreshTask(state, task) {
|
|
|
478
475
|
} else if (task.status === 'running' && !runner.hasRunning(state.runId, task.id)) task.status = 'unknown';
|
|
479
476
|
task.files = await standardFiles(dir);
|
|
480
477
|
await attachTmuxMetadata(task, dir);
|
|
481
|
-
task.attentionHint
|
|
478
|
+
delete task.attentionHint;
|
|
482
479
|
task.artifacts = [];
|
|
483
480
|
for (const rel of task.expectedArtifacts || []) task.artifacts.push({ path: rel, ...(await fileInfo(path.isAbsolute(rel) ? rel : path.join(state.repo, rel))) });
|
|
484
481
|
const batch = (state.batches || []).find(b => b.id === task.batchId);
|
|
@@ -494,60 +491,65 @@ async function attachTmuxMetadata(target, dir) {
|
|
|
494
491
|
delete target.tmux;
|
|
495
492
|
return;
|
|
496
493
|
}
|
|
494
|
+
if (raw.ready !== true) {
|
|
495
|
+
target.tmux = {
|
|
496
|
+
runner: 'tmux',
|
|
497
|
+
ready: false,
|
|
498
|
+
status: raw.status || 'pending',
|
|
499
|
+
sessionName: raw.sessionName || '',
|
|
500
|
+
windowName: raw.windowName || '',
|
|
501
|
+
target: raw.target || '',
|
|
502
|
+
runScript: raw.runScript || '',
|
|
503
|
+
startedAt: raw.startedAt || '',
|
|
504
|
+
error: raw.error || ''
|
|
505
|
+
};
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
497
508
|
const selectWindowCommand = raw.selectWindowCommand || raw.selectCommand || '';
|
|
498
509
|
target.tmux = {
|
|
499
510
|
runner: 'tmux',
|
|
511
|
+
ready: true,
|
|
512
|
+
status: raw.status || 'ready',
|
|
500
513
|
sessionName: raw.sessionName || '',
|
|
501
514
|
windowName: raw.windowName || '',
|
|
502
515
|
target: raw.target || '',
|
|
503
516
|
attachCommand: raw.attachCommand || '',
|
|
504
517
|
selectWindowCommand,
|
|
505
518
|
runScript: raw.runScript || '',
|
|
506
|
-
startedAt: raw.startedAt || ''
|
|
519
|
+
startedAt: raw.startedAt || '',
|
|
520
|
+
readyAt: raw.readyAt || ''
|
|
507
521
|
};
|
|
508
522
|
}
|
|
509
523
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
const now = Date.now();
|
|
538
|
-
const startedMs = Date.parse(target?.startedAt || '');
|
|
539
|
-
const runtimeMs = Number.isFinite(startedMs) ? now - startedMs : 0;
|
|
540
|
-
const recentMs = [target?.files?.events, target?.files?.stderr, target?.files?.lastMessage]
|
|
541
|
-
.filter(info => info?.exists && Number.isFinite(info.mtimeMs))
|
|
542
|
-
.map(info => info.mtimeMs)
|
|
543
|
-
.sort((a, b) => b - a)[0];
|
|
544
|
-
const idleMs = Number.isFinite(recentMs) ? now - recentMs : runtimeMs;
|
|
545
|
-
return {
|
|
546
|
-
idleMs,
|
|
547
|
-
runtimeMs,
|
|
548
|
-
isIdle: idleMs >= ATTENTION_IDLE_MS,
|
|
549
|
-
isLongIdle: runtimeMs >= ATTENTION_MIN_RUNTIME_MS && idleMs >= ATTENTION_IDLE_MS
|
|
524
|
+
function aggregateRunTmuxMetadata(state) {
|
|
525
|
+
const roles = [state.planner, ...(state.tasks || []), state.judge].filter(Boolean);
|
|
526
|
+
const entries = roles.map(role => role.tmux).filter(tmux => tmux?.runner === 'tmux');
|
|
527
|
+
const readyEntries = entries.filter(tmux => tmux.ready === true);
|
|
528
|
+
if (!entries.length) {
|
|
529
|
+
if ((state.runner || RUNNER) === 'tmux') {
|
|
530
|
+
state.tmux = {
|
|
531
|
+
runner: 'tmux',
|
|
532
|
+
hasTmuxSession: false
|
|
533
|
+
};
|
|
534
|
+
} else {
|
|
535
|
+
delete state.tmux;
|
|
536
|
+
}
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const withSession = readyEntries.find(tmux => tmux.sessionName || tmux.target || tmux.windowName);
|
|
540
|
+
if (!withSession) {
|
|
541
|
+
state.tmux = {
|
|
542
|
+
runner: 'tmux',
|
|
543
|
+
hasTmuxSession: false
|
|
544
|
+
};
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
state.tmux = {
|
|
548
|
+
runner: 'tmux',
|
|
549
|
+
hasTmuxSession: true,
|
|
550
|
+
tmuxSessionName: withSession.sessionName || ''
|
|
550
551
|
};
|
|
552
|
+
if (withSession.attachCommand) state.tmux.tmuxAttachCommand = withSession.attachCommand;
|
|
551
553
|
}
|
|
552
554
|
|
|
553
555
|
async function standardFiles(dir) {
|
|
@@ -665,7 +667,8 @@ async function buildJudgeInput(state) {
|
|
|
665
667
|
runner: state.runner || RUNNER,
|
|
666
668
|
createdAt: state.createdAt,
|
|
667
669
|
updatedAt: state.updatedAt,
|
|
668
|
-
maxParallel: state.maxParallel
|
|
670
|
+
maxParallel: state.maxParallel,
|
|
671
|
+
workerSandbox: state.workerSandbox || 'workspace-write'
|
|
669
672
|
},
|
|
670
673
|
taskText,
|
|
671
674
|
plan,
|
|
@@ -714,89 +717,9 @@ async function enrichFromAppServer(state, appClient) {
|
|
|
714
717
|
|
|
715
718
|
function summaryOfRun(s) {
|
|
716
719
|
const tasks = s.tasks || [];
|
|
717
|
-
return { runId: s.runId, label: s.label, repo: s.repo, status: s.status, runner: s.runner || RUNNER, archived: !!s.archived, createdAt: s.createdAt, updatedAt: s.updatedAt, total: tasks.length, completed: tasks.filter(t => t.status === 'completed').length, failed: tasks.filter(t => ['failed','unknown'].includes(t.status)).length, running: tasks.filter(t => t.status === 'running').length, batches: (s.batches || []).map(b => ({ id: b.id, name: b.name, status: b.status, total: b.tasks?.length || 0, completed: (b.tasks || []).filter(t => t.status === 'completed').length })) };
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
function formatCodexEventsJsonl(text) {
|
|
721
|
-
if (!text.trim()) return '暂无事件日志。';
|
|
722
|
-
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
723
|
-
return lines.map((line, index) => {
|
|
724
|
-
const seq = String(index + 1).padStart(3, '0');
|
|
725
|
-
let event;
|
|
726
|
-
try { event = JSON.parse(line); }
|
|
727
|
-
catch { return `[${seq}] 无法解析事件\n${line}`; }
|
|
728
|
-
return formatCodexEvent(seq, event);
|
|
729
|
-
}).join('\n\n');
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
function formatCodexEvent(seq, event) {
|
|
733
|
-
switch (event.type) {
|
|
734
|
-
case 'thread.started':
|
|
735
|
-
return `[${seq}] Codex 会话开始\n 会话ID: ${event.thread_id || '-'}`;
|
|
736
|
-
case 'turn.started':
|
|
737
|
-
return `[${seq}] 回合开始`;
|
|
738
|
-
case 'turn.completed':
|
|
739
|
-
return `[${seq}] 回合完成\n${formatKnownFields(event, ['status', 'error', 'usage'])}`.trimEnd();
|
|
740
|
-
case 'item.started':
|
|
741
|
-
return formatCodexItem(seq, '开始', event.item);
|
|
742
|
-
case 'item.completed':
|
|
743
|
-
return formatCodexItem(seq, '完成', event.item);
|
|
744
|
-
case 'error':
|
|
745
|
-
return `[${seq}] 错误\n${formatJson(event)}`;
|
|
746
|
-
default:
|
|
747
|
-
return `[${seq}] ${event.type || '未知事件'}\n${formatJson(event)}`;
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
function formatCodexItem(seq, action, item = {}) {
|
|
752
|
-
const type = item.type || 'unknown';
|
|
753
|
-
const title = `[${seq}] ${action}: ${displayItemType(type)}`;
|
|
754
|
-
if (type === 'command_execution') {
|
|
755
|
-
const parts = [title];
|
|
756
|
-
if (item.command) parts.push(` 命令: ${item.command}`);
|
|
757
|
-
if (item.status) parts.push(` 状态: ${item.status}`);
|
|
758
|
-
if (item.exit_code !== undefined && item.exit_code !== null) parts.push(` 退出码: ${item.exit_code}`);
|
|
759
|
-
if (item.aggregated_output) parts.push(` 输出:\n${indentText(truncateText(item.aggregated_output))}`);
|
|
760
|
-
return parts.join('\n');
|
|
761
|
-
}
|
|
762
|
-
if (type === 'agent_message' || type === 'agentMessage') {
|
|
763
|
-
const text = item.text || item.message || item.content || '';
|
|
764
|
-
return text ? `${title}\n 内容:\n${indentText(truncateText(String(text)))}` : title;
|
|
765
|
-
}
|
|
766
|
-
if (type === 'reasoning') {
|
|
767
|
-
const summary = item.summary || item.content || '';
|
|
768
|
-
return summary ? `${title}\n 摘要:\n${indentText(truncateText(Array.isArray(summary) ? summary.join('\n') : String(summary)))}` : title;
|
|
769
|
-
}
|
|
770
|
-
if (type === 'file_change' || type === 'fileChange') {
|
|
771
|
-
return `${title}\n${formatKnownFields(item, ['status', 'path', 'changes'])}`.trimEnd();
|
|
772
|
-
}
|
|
773
|
-
return `${title}\n${formatJson(item)}`;
|
|
720
|
+
return { runId: s.runId, label: s.label, repo: s.repo, status: s.status, runner: s.runner || RUNNER, workerSandbox: s.workerSandbox || 'workspace-write', archived: !!s.archived, createdAt: s.createdAt, updatedAt: s.updatedAt, total: tasks.length, completed: tasks.filter(t => t.status === 'completed').length, failed: tasks.filter(t => ['failed','unknown'].includes(t.status)).length, running: tasks.filter(t => t.status === 'running').length, batches: (s.batches || []).map(b => ({ id: b.id, name: b.name, status: b.status, total: b.tasks?.length || 0, completed: (b.tasks || []).filter(t => t.status === 'completed').length })) };
|
|
774
721
|
}
|
|
775
722
|
|
|
776
|
-
function displayItemType(type) {
|
|
777
|
-
return {
|
|
778
|
-
command_execution: '命令执行',
|
|
779
|
-
agent_message: '模型回复',
|
|
780
|
-
agentMessage: '模型回复',
|
|
781
|
-
reasoning: '推理',
|
|
782
|
-
file_change: '文件变更',
|
|
783
|
-
fileChange: '文件变更',
|
|
784
|
-
mcp_tool_call: 'MCP 工具调用',
|
|
785
|
-
mcpToolCall: 'MCP 工具调用'
|
|
786
|
-
}[type] || type;
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
function formatKnownFields(obj, fields) {
|
|
790
|
-
return fields
|
|
791
|
-
.filter(field => obj[field] !== undefined && obj[field] !== null)
|
|
792
|
-
.map(field => ` ${field}: ${typeof obj[field] === 'string' ? obj[field] : JSON.stringify(obj[field], null, 2)}`)
|
|
793
|
-
.join('\n');
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
function formatJson(value) { return indentText(JSON.stringify(value, null, 2)); }
|
|
797
|
-
function indentText(text) { return String(text).split('\n').map(line => ` ${line}`).join('\n'); }
|
|
798
|
-
function truncateText(text, max = 12000) { return text.length > max ? `${text.slice(0, max)}\n...<已截断 ${text.length - max} 字符>` : text; }
|
|
799
|
-
|
|
800
723
|
export async function readRunTaskText(runId) {
|
|
801
724
|
return await readTextMaybe(path.join(pathForRun(runId), 'task.md'), 1000000);
|
|
802
725
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
1
2
|
import fsp from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import {
|
|
@@ -14,7 +15,9 @@ import {
|
|
|
14
15
|
tmuxHasSession,
|
|
15
16
|
tmuxKillSession,
|
|
16
17
|
tmuxNewSession,
|
|
17
|
-
tmuxNewWindow
|
|
18
|
+
tmuxNewWindow,
|
|
19
|
+
tmuxSelectLayout,
|
|
20
|
+
tmuxSplitWindow
|
|
18
21
|
} from '../tmux.js';
|
|
19
22
|
|
|
20
23
|
function processKey(runId, taskId) {
|
|
@@ -27,9 +30,10 @@ function roleForTask(taskId) {
|
|
|
27
30
|
return 'worker';
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
function windowNameForTask(taskId) {
|
|
33
|
+
function windowNameForTask(taskId, batchId = null) {
|
|
31
34
|
const role = roleForTask(taskId);
|
|
32
|
-
|
|
35
|
+
if (role === 'worker') return sanitizeTmuxWindowName(batchId || 'batch-1');
|
|
36
|
+
return sanitizeTmuxWindowName(role);
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
function sessionNameForRun(runId) {
|
|
@@ -40,7 +44,17 @@ function shellQuote(value) {
|
|
|
40
44
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
const BIN_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../bin');
|
|
48
|
+
const FORMATTER_BIN = path.join(BIN_DIR, 'input-kanban-format-events.js');
|
|
49
|
+
const OVERVIEW_BIN = path.join(BIN_DIR, 'input-kanban-tmux-overview.js');
|
|
50
|
+
|
|
51
|
+
function buildOverviewCommand(runStatePath) {
|
|
52
|
+
const quotedStatePath = shellQuote(runStatePath);
|
|
53
|
+
const quotedOverviewBin = shellQuote(OVERVIEW_BIN);
|
|
54
|
+
return `while true; do clear; node ${quotedOverviewBin} ${quotedStatePath}; sleep 2; done`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildRunScript({ codexBin, formatterBin = FORMATTER_BIN, sandbox, cwd, outDir, runId, taskId, role }) {
|
|
44
58
|
return `#!/usr/bin/env bash
|
|
45
59
|
set -u
|
|
46
60
|
|
|
@@ -48,18 +62,30 @@ CODEX_BIN=${shellQuote(codexBin)}
|
|
|
48
62
|
SANDBOX=${shellQuote(sandbox)}
|
|
49
63
|
CWD=${shellQuote(cwd)}
|
|
50
64
|
OUT_DIR=${shellQuote(outDir)}
|
|
65
|
+
RUN_ID=${shellQuote(runId)}
|
|
66
|
+
TASK_ID=${shellQuote(taskId)}
|
|
67
|
+
ROLE=${shellQuote(role)}
|
|
51
68
|
PROMPT_FILE="$OUT_DIR/prompt.md"
|
|
52
69
|
EVENTS="$OUT_DIR/events.jsonl"
|
|
53
70
|
STDERR_LOG="$OUT_DIR/stderr.log"
|
|
71
|
+
FORMATTER_BIN=${shellQuote(formatterBin)}
|
|
54
72
|
LAST_MESSAGE="$OUT_DIR/last_message.md"
|
|
55
73
|
EXIT_CODE="$OUT_DIR/exit_code"
|
|
56
74
|
|
|
57
75
|
cd "$CWD"
|
|
58
76
|
rm -f "$EXIT_CODE"
|
|
59
|
-
|
|
77
|
+
touch "$EVENTS" "$STDERR_LOG"
|
|
78
|
+
"$CODEX_BIN" exec --json --sandbox "$SANDBOX" -C "$CWD" -o "$LAST_MESSAGE" "$(<"$PROMPT_FILE")" > >(tee -a "$EVENTS" | node "$FORMATTER_BIN") 2> >(tee -a "$STDERR_LOG" >&2)
|
|
60
79
|
code=$?
|
|
61
80
|
printf '%s' "$code" > "$EXIT_CODE"
|
|
62
|
-
|
|
81
|
+
printf '\\nInput Kanban tmux task completed.\\n'
|
|
82
|
+
printf 'runId: %s\\n' "$RUN_ID"
|
|
83
|
+
printf 'taskId: %s\\n' "$TASK_ID"
|
|
84
|
+
printf 'role: %s\\n' "$ROLE"
|
|
85
|
+
printf 'exit code: %s\\n' "$code"
|
|
86
|
+
printf 'artifact dir: %s\\n' "$OUT_DIR"
|
|
87
|
+
printf 'Type exit or press Ctrl-D to close this tmux window.\\n'
|
|
88
|
+
exec "\${SHELL:-/bin/sh}" -i
|
|
63
89
|
`;
|
|
64
90
|
}
|
|
65
91
|
|
|
@@ -71,11 +97,11 @@ export function createTmuxRunner({
|
|
|
71
97
|
} = {}) {
|
|
72
98
|
const runningWindows = new Map();
|
|
73
99
|
|
|
74
|
-
async function startCodexTask({ runId, taskId, prompt, sandbox, cwd, outDir }) {
|
|
100
|
+
async function startCodexTask({ runId, taskId, batchId = null, runStatePath = null, prompt, sandbox, cwd, outDir }) {
|
|
75
101
|
await ensureDir(outDir);
|
|
76
102
|
const sessionName = sessionNameForRun(runId);
|
|
77
|
-
const windowName = windowNameForTask(taskId);
|
|
78
103
|
const role = roleForTask(taskId);
|
|
104
|
+
const windowName = windowNameForTask(taskId, batchId);
|
|
79
105
|
const key = processKey(runId, taskId);
|
|
80
106
|
const promptFile = path.join(outDir, 'prompt.md');
|
|
81
107
|
const runScript = path.join(outDir, 'run.sh');
|
|
@@ -84,7 +110,7 @@ export function createTmuxRunner({
|
|
|
84
110
|
const startedAt = nowIso();
|
|
85
111
|
|
|
86
112
|
await fsp.writeFile(promptFile, prompt);
|
|
87
|
-
await fsp.writeFile(runScript, buildRunScript({ codexBin, sandbox, cwd, outDir }));
|
|
113
|
+
await fsp.writeFile(runScript, buildRunScript({ codexBin, sandbox, cwd, outDir, runId, taskId, role }));
|
|
88
114
|
await fsp.chmod(runScript, 0o755);
|
|
89
115
|
|
|
90
116
|
const metadata = {
|
|
@@ -94,32 +120,56 @@ export function createTmuxRunner({
|
|
|
94
120
|
runId,
|
|
95
121
|
taskId,
|
|
96
122
|
role,
|
|
123
|
+
batchId,
|
|
97
124
|
sessionName,
|
|
98
125
|
windowName,
|
|
99
126
|
target: `${sessionName}:${windowName}`,
|
|
100
|
-
attachCommand: `${tmuxBin} attach-session -t ${sessionName}`,
|
|
101
|
-
selectWindowCommand: `${tmuxBin} select-window -t ${sessionName}:${windowName}`,
|
|
102
|
-
selectCommand: `${tmuxBin} select-window -t ${sessionName}:${windowName}`,
|
|
103
127
|
runScript,
|
|
104
128
|
promptFile,
|
|
105
129
|
cwd,
|
|
106
130
|
sandbox,
|
|
107
|
-
startedAt
|
|
131
|
+
startedAt,
|
|
132
|
+
ready: false,
|
|
133
|
+
status: 'pending'
|
|
108
134
|
};
|
|
109
135
|
await writeJsonAtomic(metadataFile, metadata);
|
|
110
136
|
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
137
|
+
const overviewCommand = buildOverviewCommand(runStatePath || path.join(path.dirname(path.dirname(outDir)), 'run_state.json'));
|
|
138
|
+
const tmuxCommandOptions = { ...tmuxOptions, tmuxBin, cwd };
|
|
139
|
+
try {
|
|
140
|
+
if (await tmuxHasSession(sessionName, tmuxCommandOptions)) {
|
|
141
|
+
if (!runningWindows.has(`${runId}:__window:${windowName}`)) {
|
|
142
|
+
await tmuxNewWindow(sessionName, windowName, { ...tmuxCommandOptions, command: overviewCommand });
|
|
143
|
+
runningWindows.set(`${runId}:__window:${windowName}`, { sessionName, windowName, overview: true });
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
await tmuxNewSession(sessionName, { ...tmuxCommandOptions, windowName, command: overviewCommand });
|
|
147
|
+
runningWindows.set(`${runId}:__window:${windowName}`, { sessionName, windowName, overview: true });
|
|
148
|
+
}
|
|
149
|
+
await tmuxSplitWindow(sessionName, windowName, { ...tmuxCommandOptions, vertical: true, command: runScript });
|
|
150
|
+
await tmuxSelectLayout(sessionName, windowName, 'tiled', tmuxCommandOptions);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
await writeJsonAtomic(metadataFile, {
|
|
153
|
+
...metadata,
|
|
154
|
+
ready: false,
|
|
155
|
+
status: 'failed',
|
|
156
|
+
error: error?.message || String(error),
|
|
157
|
+
failedAt: nowIso()
|
|
158
|
+
});
|
|
159
|
+
throw error;
|
|
121
160
|
}
|
|
122
161
|
|
|
162
|
+
await writeJsonAtomic(metadataFile, {
|
|
163
|
+
...metadata,
|
|
164
|
+
ready: true,
|
|
165
|
+
status: 'ready',
|
|
166
|
+
attachCommand: `${tmuxBin} attach-session -t ${sessionName}`,
|
|
167
|
+
selectWindowCommand: `${tmuxBin} select-window -t ${sessionName}:${windowName}`,
|
|
168
|
+
selectCommand: `${tmuxBin} select-window -t ${sessionName}:${windowName}`,
|
|
169
|
+
paneCommand: `${tmuxBin} select-window -t ${sessionName}:${windowName}`,
|
|
170
|
+
readyAt: nowIso()
|
|
171
|
+
});
|
|
172
|
+
|
|
123
173
|
const listeners = [];
|
|
124
174
|
let exited = false;
|
|
125
175
|
let exitCode = null;
|
package/src/runners/tmuxUtils.js
CHANGED
package/src/tmux.js
CHANGED
|
@@ -132,6 +132,23 @@ export async function tmuxKillSession(sessionName, options = {}) {
|
|
|
132
132
|
return runTmux(['kill-session', '-t', sanitizeTmuxSessionName(sessionName)], options);
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
export async function tmuxSplitWindow(sessionName, windowName, options = {}) {
|
|
136
|
+
const session = sanitizeTmuxSessionName(sessionName);
|
|
137
|
+
const window = sanitizeTmuxWindowName(windowName);
|
|
138
|
+
const args = ['split-window', '-t', `${session}:${window}`];
|
|
139
|
+
if (options.vertical) args.push('-v');
|
|
140
|
+
else args.push('-h');
|
|
141
|
+
if (options.cwd) args.push('-c', options.cwd);
|
|
142
|
+
if (options.command) args.push(options.command);
|
|
143
|
+
return runTmux(args, options);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function tmuxSelectLayout(sessionName, windowName, layout = 'tiled', options = {}) {
|
|
147
|
+
const session = sanitizeTmuxSessionName(sessionName);
|
|
148
|
+
const window = sanitizeTmuxWindowName(windowName);
|
|
149
|
+
return runTmux(['select-layout', '-t', `${session}:${window}`, layout], options);
|
|
150
|
+
}
|
|
151
|
+
|
|
135
152
|
export async function tmuxKillWindow(sessionName, windowName, options = {}) {
|
|
136
153
|
const session = sanitizeTmuxSessionName(sessionName);
|
|
137
154
|
const window = sanitizeTmuxWindowName(windowName);
|