input-kanban 0.0.2 → 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.
@@ -1,26 +1,35 @@
1
- import { spawn } from 'node:child_process';
2
1
  import fs from 'node:fs';
3
2
  import fsp from 'node:fs/promises';
4
3
  import path from 'node:path';
5
4
  import {
6
- CODEX_BIN, DEFAULT_REPO, RUNS_DIR, ensureDir, nowIso, makeRunId, readJson,
5
+ DEFAULT_REPO, RUNS_DIR, ensureDir, nowIso, makeRunId, readJson,
7
6
  writeJsonAtomic, fileInfo, readTextMaybe, extractFirstJsonObject, listRunDirs,
8
- pathForRun, roleDir, safeIdPart
7
+ pathForRun, roleDir, safeIdPart, RUNNER
9
8
  } from './utils.js';
10
9
  import { matchThreadToMarkers } from './appServerClient.js';
10
+ import { formatCodexEventsJsonl } from './eventFormatter.js';
11
+ import { defaultRunner } from './runners/index.js';
11
12
 
12
- const runningChildren = new Map(); // key: `${runId}:${taskId}` -> child
13
+ const runner = defaultRunner;
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
+ }
13
21
 
14
22
  function statePath(runDir) { return path.join(runDir, 'run_state.json'); }
15
23
  function planPath(runDir) { return path.join(runDir, 'plan.json'); }
16
24
 
17
- 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' } = {}) {
18
26
  const runId = makeRunId(label);
19
27
  const runDir = pathForRun(runId);
20
28
  await ensureDir(runDir);
21
29
  await fsp.writeFile(path.join(runDir, 'task.md'), taskText || '');
22
30
  const state = {
23
- runId, label, repo: path.resolve(repo), maxParallel: Number(maxParallel) || 3,
31
+ runId, label, repo: path.resolve(repo), maxParallel: Number(maxParallel) || 3, workerSandbox: normalizeSandbox(workerSandbox),
32
+ runner: RUNNER,
24
33
  status: 'created', createdAt: nowIso(), updatedAt: nowIso(),
25
34
  planner: { status: 'pending' }, batches: [], tasks: [], judge: { status: 'pending' }
26
35
  };
@@ -74,7 +83,7 @@ Preferred schema with blocking batches:
74
83
  "id": "T-01",
75
84
  "name": "short name",
76
85
  "prompt": "complete worker prompt",
77
- "sandbox": "workspace-write",
86
+ "sandbox": "${state.workerSandbox || 'workspace-write'}",
78
87
  "expectedArtifacts": []
79
88
  }
80
89
  ]
@@ -90,7 +99,7 @@ Backward-compatible schema also accepted:
90
99
  "id": "T-01",
91
100
  "name": "short name",
92
101
  "prompt": "complete worker prompt",
93
- "sandbox": "workspace-write",
102
+ "sandbox": "${state.workerSandbox || 'workspace-write'}",
94
103
  "expectedArtifacts": []
95
104
  }
96
105
  ]
@@ -101,6 +110,7 @@ Rules:
101
110
  - Use batch maxParallel to express whether tasks in the same batch may run concurrently or serially.
102
111
  - Keep tasks scoped and independently executable.
103
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.
104
114
  - If the input already contains task sections, preserve their ids when practical.
105
115
 
106
116
  User task:
@@ -132,24 +142,6 @@ Plan: ${path.join(pathForRun(state.runId), 'plan.json')}
132
142
  `;
133
143
  }
134
144
 
135
- function spawnCodex({ state, taskId, prompt, sandbox, cwd, outDir }) {
136
- const events = path.join(outDir, 'events.jsonl');
137
- const stderr = path.join(outDir, 'stderr.log');
138
- const last = path.join(outDir, 'last_message.md');
139
- fs.writeFileSync(path.join(outDir, 'prompt.md'), prompt);
140
- const args = ['exec', '--json', '--sandbox', sandbox, '-C', cwd, '-o', last, prompt];
141
- const child = spawn(CODEX_BIN, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
142
- child.stdout.pipe(fs.createWriteStream(events, { flags: 'a' }));
143
- child.stderr.pipe(fs.createWriteStream(stderr, { flags: 'a' }));
144
- const key = `${state.runId}:${taskId}`;
145
- runningChildren.set(key, child);
146
- child.on('exit', code => {
147
- try { fs.writeFileSync(path.join(outDir, 'exit_code'), String(code)); } catch {}
148
- runningChildren.delete(key);
149
- });
150
- return child;
151
- }
152
-
153
145
  export async function startPlanner(runId) {
154
146
  const state = await loadRun(runId);
155
147
  if (!state) throw new Error(`run not found: ${runId}`);
@@ -168,11 +160,11 @@ export async function startPlanner(runId) {
168
160
  await fsp.rm(planPath(runDir), { force: true });
169
161
  const taskText = await fsp.readFile(path.join(runDir, 'task.md'), 'utf8');
170
162
  const prompt = defaultPlannerPrompt(state, taskText);
171
- const child = spawnCodex({ state, 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 });
172
164
  state.status = 'planning';
173
165
  state.planner = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir, attempt: (state.plannerAttempts?.length || 0) + 1 };
174
166
  await saveRun(state);
175
- child.on('exit', async code => {
167
+ child.onExit(async code => {
176
168
  const s = await loadRun(runId); if (!s || s.status === 'stopped') return;
177
169
  s.planner.exitCode = code; s.planner.endedAt = nowIso(); s.planner.status = code === 0 ? 'completed' : 'failed';
178
170
  const planResult = await materializePlan(s);
@@ -185,14 +177,14 @@ export async function startPlanner(runId) {
185
177
  return state;
186
178
  }
187
179
 
188
- function normalizeTask(t, i, batch) {
180
+ function normalizeTask(t, i, batch, defaultSandbox = 'workspace-write') {
189
181
  const id = safeIdPart(t.id || `T-${String(i + 1).padStart(2, '0')}`);
190
182
  return {
191
183
  id,
192
184
  batchId: batch.id,
193
185
  name: t.name || t.id || `Task ${i + 1}`,
194
186
  prompt: t.prompt || t.instructions || '',
195
- sandbox: t.sandbox || 'workspace-write',
187
+ sandbox: normalizeSandbox(t.sandbox, defaultSandbox),
196
188
  expectedArtifacts: Array.isArray(t.expectedArtifacts) ? t.expectedArtifacts : [],
197
189
  status: 'pending'
198
190
  };
@@ -225,7 +217,7 @@ async function rotatePlannerAttempt(state, runDir) {
225
217
  }];
226
218
  }
227
219
 
228
- function normalizePlan(plan, defaultMaxParallel) {
220
+ function normalizePlan(plan, defaultMaxParallel, defaultSandbox = 'workspace-write') {
229
221
  if (Array.isArray(plan.batches)) {
230
222
  const batches = plan.batches.map((b, bi) => {
231
223
  const batch = {
@@ -235,14 +227,14 @@ function normalizePlan(plan, defaultMaxParallel) {
235
227
  status: 'pending',
236
228
  tasks: []
237
229
  };
238
- 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));
239
231
  return batch;
240
232
  }).filter(b => b.tasks.length);
241
233
  return { ...plan, batches, tasks: batches.flatMap(b => b.tasks) };
242
234
  }
243
235
  if (Array.isArray(plan.tasks)) {
244
236
  const batch = { id: 'batch-1', name: '默认批次', maxParallel: Math.max(1, Number(defaultMaxParallel) || 1), status: 'pending', tasks: [] };
245
- batch.tasks = plan.tasks.map((t, i) => normalizeTask(t, i, batch));
237
+ batch.tasks = plan.tasks.map((t, i) => normalizeTask(t, i, batch, defaultSandbox));
246
238
  return { ...plan, batches: [batch], tasks: batch.tasks };
247
239
  }
248
240
  return null;
@@ -258,7 +250,7 @@ async function materializePlan(state) {
258
250
  state.tasks = [];
259
251
  return { ok: false, empty: false, error: state.planner.planParseError };
260
252
  }
261
- const normalized = normalizePlan(plan, state.maxParallel);
253
+ const normalized = normalizePlan(plan, state.maxParallel, state.workerSandbox || 'workspace-write');
262
254
  if (!normalized || !Array.isArray(normalized.tasks)) {
263
255
  state.planner.planParseError = 'planner JSON did not contain { batches: [...] } or { tasks: [...] }';
264
256
  state.batches = [];
@@ -304,7 +296,7 @@ ORCHESTRATOR_BATCH_ID: ${task.batchId || 'batch-1'}
304
296
 
305
297
  ${task.prompt}
306
298
  `;
307
- const child = spawnCodex({ state, 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 });
308
300
  Object.assign(task, { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir });
309
301
  }
310
302
 
@@ -312,12 +304,7 @@ export async function stopRun(runId, { reason = 'stopped by user' } = {}) {
312
304
  const state = await loadRun(runId);
313
305
  if (!state) throw new Error(`run not found: ${runId}`);
314
306
  const stoppedAt = nowIso();
315
- for (const [key, child] of runningChildren.entries()) {
316
- if (key.startsWith(`${runId}:`)) {
317
- try { child.kill('TERM'); } catch {}
318
- runningChildren.delete(key);
319
- }
320
- }
307
+ await runner.stopRun(runId);
321
308
  for (const roleState of [state.planner, state.judge]) {
322
309
  if (roleState?.status === 'running') Object.assign(roleState, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
323
310
  }
@@ -400,11 +387,11 @@ export async function startJudge(runId) {
400
387
  const judgeInput = await buildJudgeInput(state);
401
388
  await writeJsonAtomic(judgeInputPath, judgeInput);
402
389
  const prompt = defaultJudgePrompt(state, judgeInputPath);
403
- const child = spawnCodex({ state, 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 });
404
391
  state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir };
405
392
  state.status = 'judging';
406
393
  await saveRun(state);
407
- child.on('exit', async code => {
394
+ child.onExit(async code => {
408
395
  const s = await loadRun(runId); if (!s || s.status === 'stopped') return;
409
396
  s.judge.exitCode = code; s.judge.endedAt = nowIso(); s.judge.status = code === 0 ? 'completed' : 'failed';
410
397
  const text = await readTextMaybe(path.join(outDir, 'last_message.md'), 1000000);
@@ -423,9 +410,13 @@ export async function refreshRun(runId, appClient = null) {
423
410
  async function loadAndRefreshRun(runId, appClient = null, { light = false } = {}) {
424
411
  const state = await loadRun(runId);
425
412
  if (!state) return null;
413
+ state.runner = state.runner || RUNNER;
426
414
  await refreshRole(state, state.planner, roleDir(pathForRun(runId), 'planner'));
415
+ await recoverCompletedPlanner(state);
427
416
  for (const task of state.tasks || []) await refreshTask(state, task);
428
417
  await refreshRole(state, state.judge, roleDir(pathForRun(runId), 'judge'));
418
+ await recoverCompletedJudge(state);
419
+ aggregateRunTmuxMetadata(state);
429
420
  recomputeRunStatus(state);
430
421
  await scheduleMoreWorkers(state);
431
422
  recomputeRunStatus(state);
@@ -434,19 +425,42 @@ async function loadAndRefreshRun(runId, appClient = null, { light = false } = {}
434
425
  return state;
435
426
  }
436
427
 
428
+ async function recoverCompletedPlanner(state) {
429
+ if (state.planner?.status !== 'completed' || state.tasks?.length || state.batches?.length) return;
430
+ const planResult = await materializePlan(state);
431
+ if (planResult.ok) state.status = 'planned';
432
+ else if (planResult.empty) state.status = 'plan_empty';
433
+ else state.status = 'plan_failed';
434
+ }
435
+
436
+ async function recoverCompletedJudge(state) {
437
+ if (!['completed', 'failed'].includes(state.judge?.status)) return;
438
+ if (state.judge.status === 'completed' && !state.judge.verdict) {
439
+ const outDir = roleDir(pathForRun(state.runId), 'judge');
440
+ const text = await readTextMaybe(path.join(outDir, 'last_message.md'), 1000000);
441
+ const verdict = extractFirstJsonObject(text);
442
+ if (verdict) {
443
+ state.judge.verdict = verdict;
444
+ await writeJsonAtomic(path.join(outDir, 'verdict.json'), verdict);
445
+ }
446
+ }
447
+ state.status = state.judge.status === 'completed' ? 'judged' : 'judge_failed';
448
+ }
449
+
437
450
  async function refreshRole(state, roleState, dir) {
438
451
  if (!roleState) return;
439
452
  const exitPath = path.join(dir, 'exit_code');
440
453
  const exit = await readTextMaybe(exitPath, 1000);
441
454
  const exitInfo = await fileInfo(exitPath);
442
- const key = `${state.runId}:${roleState === state.judge ? 'judge' : 'planner'}`;
455
+ const key = roleState === state.judge ? 'judge' : 'planner';
443
456
  if (exit !== '') {
444
457
  roleState.exitCode = Number(exit.trim());
445
458
  if (!roleState.endedAt && exitInfo.exists) roleState.endedAt = exitInfo.mtime;
446
459
  if (roleState.status === 'running') roleState.status = roleState.exitCode === 0 ? 'completed' : 'failed';
447
460
  }
448
- else if (roleState.status === 'running' && !runningChildren.has(key)) roleState.status = 'unknown';
461
+ else if (roleState.status === 'running' && !runner.hasRunning(state.runId, key)) roleState.status = 'unknown';
449
462
  roleState.files = await standardFiles(dir);
463
+ await attachTmuxMetadata(roleState, dir);
450
464
  }
451
465
 
452
466
  async function refreshTask(state, task) {
@@ -454,13 +468,14 @@ async function refreshTask(state, task) {
454
468
  const exitPath = path.join(dir, 'exit_code');
455
469
  const exit = await readTextMaybe(exitPath, 1000);
456
470
  const exitInfo = await fileInfo(exitPath);
457
- const key = `${state.runId}:${task.id}`;
458
471
  if (exit !== '') {
459
472
  task.exitCode = Number(exit.trim());
460
473
  if (!task.endedAt && exitInfo.exists) task.endedAt = exitInfo.mtime;
461
474
  if (task.status === 'running') task.status = task.exitCode === 0 ? 'completed' : 'failed';
462
- } else if (task.status === 'running' && !runningChildren.has(key)) task.status = 'unknown';
475
+ } else if (task.status === 'running' && !runner.hasRunning(state.runId, task.id)) task.status = 'unknown';
463
476
  task.files = await standardFiles(dir);
477
+ await attachTmuxMetadata(task, dir);
478
+ delete task.attentionHint;
464
479
  task.artifacts = [];
465
480
  for (const rel of task.expectedArtifacts || []) task.artifacts.push({ path: rel, ...(await fileInfo(path.isAbsolute(rel) ? rel : path.join(state.repo, rel))) });
466
481
  const batch = (state.batches || []).find(b => b.id === task.batchId);
@@ -470,13 +485,82 @@ async function refreshTask(state, task) {
470
485
  }
471
486
  }
472
487
 
488
+ async function attachTmuxMetadata(target, dir) {
489
+ const raw = await readJson(path.join(dir, 'tmux.json'), null);
490
+ if (!raw || raw.runner !== 'tmux') {
491
+ delete target.tmux;
492
+ return;
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
+ }
508
+ const selectWindowCommand = raw.selectWindowCommand || raw.selectCommand || '';
509
+ target.tmux = {
510
+ runner: 'tmux',
511
+ ready: true,
512
+ status: raw.status || 'ready',
513
+ sessionName: raw.sessionName || '',
514
+ windowName: raw.windowName || '',
515
+ target: raw.target || '',
516
+ attachCommand: raw.attachCommand || '',
517
+ selectWindowCommand,
518
+ runScript: raw.runScript || '',
519
+ startedAt: raw.startedAt || '',
520
+ readyAt: raw.readyAt || ''
521
+ };
522
+ }
523
+
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 || ''
551
+ };
552
+ if (withSession.attachCommand) state.tmux.tmuxAttachCommand = withSession.attachCommand;
553
+ }
554
+
473
555
  async function standardFiles(dir) {
474
556
  return {
475
557
  prompt: await fileInfo(path.join(dir, 'prompt.md')),
476
558
  events: await fileInfo(path.join(dir, 'events.jsonl')),
477
559
  stderr: await fileInfo(path.join(dir, 'stderr.log')),
478
560
  lastMessage: await fileInfo(path.join(dir, 'last_message.md')),
479
- exitCode: await fileInfo(path.join(dir, 'exit_code'))
561
+ exitCode: await fileInfo(path.join(dir, 'exit_code')),
562
+ runScript: await fileInfo(path.join(dir, 'run.sh')),
563
+ tmux: await fileInfo(path.join(dir, 'tmux.json'))
480
564
  };
481
565
  }
482
566
 
@@ -567,6 +651,7 @@ async function buildJudgeInput(state) {
567
651
  resultJson: await readJson(path.join(dir, 'result.json'), null),
568
652
  evidenceJson: await readJson(path.join(dir, 'evidence.json'), null),
569
653
  manualCompletion: task.manualCompletion || await readJson(path.join(dir, 'manual_completion.json'), null),
654
+ tmux: task.tmux || null,
570
655
  stderrTail: await readTextMaybe(path.join(dir, 'stderr.log'), 20000)
571
656
  });
572
657
  }
@@ -579,9 +664,11 @@ async function buildJudgeInput(state) {
579
664
  label: state.label,
580
665
  repo: state.repo,
581
666
  status: state.status,
667
+ runner: state.runner || RUNNER,
582
668
  createdAt: state.createdAt,
583
669
  updatedAt: state.updatedAt,
584
- maxParallel: state.maxParallel
670
+ maxParallel: state.maxParallel,
671
+ workerSandbox: state.workerSandbox || 'workspace-write'
585
672
  },
586
673
  taskText,
587
674
  plan,
@@ -597,6 +684,7 @@ async function buildJudgeInput(state) {
597
684
  exitCode: state.planner?.exitCode ?? null,
598
685
  planParseError: state.planner?.planParseError,
599
686
  planEmpty: !!state.planner?.planEmpty,
687
+ tmux: state.planner?.tmux || null,
600
688
  lastMessage: await readTextMaybe(path.join(roleDir(runDir, 'planner'), 'last_message.md'), 200000)
601
689
  },
602
690
  tasks
@@ -629,96 +717,16 @@ async function enrichFromAppServer(state, appClient) {
629
717
 
630
718
  function summaryOfRun(s) {
631
719
  const tasks = s.tasks || [];
632
- return { runId: s.runId, label: s.label, repo: s.repo, status: s.status, 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 })) };
633
- }
634
-
635
- function formatCodexEventsJsonl(text) {
636
- if (!text.trim()) return '暂无事件日志。';
637
- const lines = text.split(/\r?\n/).filter(Boolean);
638
- return lines.map((line, index) => {
639
- const seq = String(index + 1).padStart(3, '0');
640
- let event;
641
- try { event = JSON.parse(line); }
642
- catch { return `[${seq}] 无法解析事件\n${line}`; }
643
- return formatCodexEvent(seq, event);
644
- }).join('\n\n');
645
- }
646
-
647
- function formatCodexEvent(seq, event) {
648
- switch (event.type) {
649
- case 'thread.started':
650
- return `[${seq}] Codex 会话开始\n 会话ID: ${event.thread_id || '-'}`;
651
- case 'turn.started':
652
- return `[${seq}] 回合开始`;
653
- case 'turn.completed':
654
- return `[${seq}] 回合完成\n${formatKnownFields(event, ['status', 'error', 'usage'])}`.trimEnd();
655
- case 'item.started':
656
- return formatCodexItem(seq, '开始', event.item);
657
- case 'item.completed':
658
- return formatCodexItem(seq, '完成', event.item);
659
- case 'error':
660
- return `[${seq}] 错误\n${formatJson(event)}`;
661
- default:
662
- return `[${seq}] ${event.type || '未知事件'}\n${formatJson(event)}`;
663
- }
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 })) };
664
721
  }
665
722
 
666
- function formatCodexItem(seq, action, item = {}) {
667
- const type = item.type || 'unknown';
668
- const title = `[${seq}] ${action}: ${displayItemType(type)}`;
669
- if (type === 'command_execution') {
670
- const parts = [title];
671
- if (item.command) parts.push(` 命令: ${item.command}`);
672
- if (item.status) parts.push(` 状态: ${item.status}`);
673
- if (item.exit_code !== undefined && item.exit_code !== null) parts.push(` 退出码: ${item.exit_code}`);
674
- if (item.aggregated_output) parts.push(` 输出:\n${indentText(truncateText(item.aggregated_output))}`);
675
- return parts.join('\n');
676
- }
677
- if (type === 'agent_message' || type === 'agentMessage') {
678
- const text = item.text || item.message || item.content || '';
679
- return text ? `${title}\n 内容:\n${indentText(truncateText(String(text)))}` : title;
680
- }
681
- if (type === 'reasoning') {
682
- const summary = item.summary || item.content || '';
683
- return summary ? `${title}\n 摘要:\n${indentText(truncateText(Array.isArray(summary) ? summary.join('\n') : String(summary)))}` : title;
684
- }
685
- if (type === 'file_change' || type === 'fileChange') {
686
- return `${title}\n${formatKnownFields(item, ['status', 'path', 'changes'])}`.trimEnd();
687
- }
688
- return `${title}\n${formatJson(item)}`;
689
- }
690
-
691
- function displayItemType(type) {
692
- return {
693
- command_execution: '命令执行',
694
- agent_message: '模型回复',
695
- agentMessage: '模型回复',
696
- reasoning: '推理',
697
- file_change: '文件变更',
698
- fileChange: '文件变更',
699
- mcp_tool_call: 'MCP 工具调用',
700
- mcpToolCall: 'MCP 工具调用'
701
- }[type] || type;
702
- }
703
-
704
- function formatKnownFields(obj, fields) {
705
- return fields
706
- .filter(field => obj[field] !== undefined && obj[field] !== null)
707
- .map(field => ` ${field}: ${typeof obj[field] === 'string' ? obj[field] : JSON.stringify(obj[field], null, 2)}`)
708
- .join('\n');
709
- }
710
-
711
- function formatJson(value) { return indentText(JSON.stringify(value, null, 2)); }
712
- function indentText(text) { return String(text).split('\n').map(line => ` ${line}`).join('\n'); }
713
- function truncateText(text, max = 12000) { return text.length > max ? `${text.slice(0, max)}\n...<已截断 ${text.length - max} 字符>` : text; }
714
-
715
723
  export async function readRunTaskText(runId) {
716
724
  return await readTextMaybe(path.join(pathForRun(runId), 'task.md'), 1000000);
717
725
  }
718
726
 
719
727
  export async function readRunFile(runId, taskId, name) {
720
728
  const runDir = pathForRun(runId);
721
- const allowed = new Set(['prompt.md','events.jsonl','events.pretty','stderr.log','last_message.md','exit_code','result.json','evidence.json','verdict.json','judge_input.json','manual_completion.json']);
729
+ const allowed = new Set(['prompt.md','events.jsonl','events.pretty','stderr.log','last_message.md','exit_code','result.json','evidence.json','verdict.json','judge_input.json','manual_completion.json','run.sh','tmux.json']);
722
730
  if (!allowed.has(name)) throw new Error('file not allowed');
723
731
  let dir;
724
732
  if (taskId === 'planner') dir = roleDir(runDir, 'planner');
@@ -0,0 +1,51 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { CODEX_BIN } from '../utils.js';
5
+
6
+ function processKey(runId, taskId) {
7
+ return `${runId}:${taskId}`;
8
+ }
9
+
10
+ export function createHeadlessRunner({ codexBin = CODEX_BIN } = {}) {
11
+ const runningProcesses = new Map();
12
+
13
+ function startCodexTask({ runId, taskId, prompt, sandbox, cwd, outDir }) {
14
+ const events = path.join(outDir, 'events.jsonl');
15
+ const stderr = path.join(outDir, 'stderr.log');
16
+ const last = path.join(outDir, 'last_message.md');
17
+ fs.writeFileSync(path.join(outDir, 'prompt.md'), prompt);
18
+ const args = ['exec', '--json', '--sandbox', sandbox, '-C', cwd, '-o', last, prompt];
19
+ const child = spawn(codexBin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
20
+ child.stdout.pipe(fs.createWriteStream(events, { flags: 'a' }));
21
+ child.stderr.pipe(fs.createWriteStream(stderr, { flags: 'a' }));
22
+ const key = processKey(runId, taskId);
23
+ runningProcesses.set(key, child);
24
+ child.on('exit', code => {
25
+ try { fs.writeFileSync(path.join(outDir, 'exit_code'), String(code)); } catch {}
26
+ runningProcesses.delete(key);
27
+ });
28
+ return {
29
+ pid: child.pid,
30
+ onExit(listener) { child.on('exit', listener); },
31
+ stop(signal = 'TERM') { child.kill(signal); }
32
+ };
33
+ }
34
+
35
+ function stopRun(runId, signal = 'TERM') {
36
+ for (const [key, child] of runningProcesses.entries()) {
37
+ if (key.startsWith(`${runId}:`)) {
38
+ try { child.kill(signal); } catch {}
39
+ runningProcesses.delete(key);
40
+ }
41
+ }
42
+ }
43
+
44
+ function hasRunning(runId, taskId) {
45
+ return runningProcesses.has(processKey(runId, taskId));
46
+ }
47
+
48
+ return { kind: 'headless', startCodexTask, stopRun, hasRunning };
49
+ }
50
+
51
+ export const headlessRunner = createHeadlessRunner();
@@ -0,0 +1,13 @@
1
+ import { headlessRunner } from './headlessRunner.js';
2
+ import { createTmuxRunner } from './tmuxRunner.js';
3
+ import { RUNNER } from '../utils.js';
4
+
5
+ export { createHeadlessRunner, headlessRunner } from './headlessRunner.js';
6
+ export { createTmuxRunner } from './tmuxRunner.js';
7
+
8
+ export function createDefaultRunner(runnerMode = RUNNER) {
9
+ if (runnerMode === 'tmux') return createTmuxRunner();
10
+ return headlessRunner;
11
+ }
12
+
13
+ export const defaultRunner = createDefaultRunner();