input-kanban 0.0.1 → 0.0.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/public/index.html CHANGED
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Codex 编排看板</title>
6
+ <title>Input 看板</title>
7
7
  <style>
8
8
  :root { --bg:#0b1220; --panel:#111827; --panel-2:#0f172a; --line:#334155; --line-strong:#64748b; --text:#e2e8f0; --muted:#94a3b8; --blue:#2563eb; --green:#166534; --red:#991b1b; --gray:#475569; --orange:#b45309; }
9
9
  body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: var(--bg); color: var(--text); }
@@ -32,11 +32,14 @@
32
32
  th:nth-child(4), td:nth-child(4) { width: 64px; white-space: nowrap; }
33
33
  th:nth-child(5), td:nth-child(5) { width: 70px; }
34
34
  th:nth-child(6), td:nth-child(6) { width: 58px; }
35
- th:nth-child(8), td:nth-child(8) { width: 66px; }
36
- th:nth-child(9), td:nth-child(9) { width: 94px; }
35
+ th:nth-child(8), td:nth-child(8) { width: 118px; }
36
+ th:nth-child(9), td:nth-child(9) { width: 66px; }
37
+ th:nth-child(10), td:nth-child(10) { width: 94px; }
37
38
  th { color: #cbd5e1; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
38
39
  tr:hover { background: #162033; cursor: pointer; }
39
40
  .pill { display: inline-block; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 800; background: var(--gray); line-height: 1.3; }
41
+ .attention-hint { display: block; margin-top: 6px; color: #fbbf24; font-size: 12px; font-weight: 700; white-space: normal; line-height: 1.35; }
42
+ .attention-hint code { color: #fde68a; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; word-break: break-all; }
40
43
  .completed, .judged, .workers_completed, .planned, .batches_completed { background: var(--green); }
41
44
  .running, .planning, .judging { background: var(--blue); }
42
45
  .failed, .workers_failed, .plan_failed, .judge_failed, .unknown, .batch_blocked { background: var(--red); }
@@ -75,6 +78,12 @@
75
78
  .execution-summary + pre { border-top-left-radius: 0; border-top-right-radius: 0; }
76
79
  .notice { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #1f2937; color: #cbd5e1; }
77
80
  .notice.warning { border-color: #92400e; background: rgba(180,83,9,.18); }
81
+ .tmux-box { margin: 8px 0 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #020617; color: #cbd5e1; font-size: 12px; }
82
+ .tmux-box.hidden { display: none; }
83
+ .tmux-box-title { font-weight: 800; color: var(--text); margin-bottom: 5px; }
84
+ .tmux-actions { margin-top: 7px; display: flex; flex-wrap: wrap; gap: 4px; }
85
+ .tmux-actions button { margin: 0; }
86
+ .tmux-inline { display: block; margin-top: 3px; word-break: break-all; }
78
87
  .file-content-wrap { position: relative; }
79
88
  .floating-copy-btn { position: absolute; top: 8px; left: 8px; z-index: 2; opacity: 0; pointer-events: none; transition: opacity .15s; padding: 5px 8px; background: rgba(71,85,105,.92); }
80
89
  .file-content-wrap:hover .floating-copy-btn:not(.hidden), .floating-copy-btn:focus { opacity: 1; pointer-events: auto; }
@@ -82,7 +91,7 @@
82
91
  </style>
83
92
  </head>
84
93
  <body>
85
- <header><h1>Codex 编排看板</h1></header>
94
+ <header><h1>Input 看板</h1></header>
86
95
  <main>
87
96
  <div class="sidebar">
88
97
  <section>
@@ -144,6 +153,7 @@
144
153
  <button class="secondary" onclick="loadFile('verdict.json')">验收结论</button>
145
154
  </div>
146
155
  <div id="executionSummary" class="execution-summary hidden"></div>
156
+ <div id="tmuxPanel" class="tmux-box hidden"></div>
147
157
  <div class="file-content-wrap">
148
158
  <button id="copyLastMessageBtn" class="secondary copy-btn floating-copy-btn hidden" title="复制最终回复内容" onclick="copyFileContent(event)">⧉</button>
149
159
  <pre id="fileContent"></pre>
@@ -192,6 +202,13 @@ function durationSeconds(start, end) {
192
202
  return Math.max(0, Math.round((endMs - new Date(start).getTime()) / 1000));
193
203
  }
194
204
  function runTimingText(s) { return `开始时刻 ${formatDateTime(s.createdAt)}|用时 ${durationSeconds(s.createdAt, s.updatedAt)} 秒`; }
205
+ function isTmuxMode() { return currentState?.runner === 'tmux'; }
206
+ function taskById(id) {
207
+ if (!currentState) return null;
208
+ if (id === 'planner') return currentState.planner;
209
+ if (id === 'judge') return currentState.judge;
210
+ return (currentState.tasks || []).find(t => t.id === id) || null;
211
+ }
195
212
 
196
213
  async function loadHealth() {
197
214
  const h = await api('/api/health');
@@ -292,7 +309,14 @@ function taskStatusCell(t) {
292
309
  const original = t.originalStatus || t.manualCompletion.originalStatus || t.manualCompletion.previousStatus || 'unknown';
293
310
  return `${pill(original)} <span class="pill completed">手动标记成功已完成</span>`;
294
311
  }
295
- return pill(t?.status);
312
+ const hint = attentionHintCell(t);
313
+ return `${pill(t?.status)}${hint}`;
314
+ }
315
+ function attentionHintCell(t) {
316
+ if (!t?.attentionHint) return '';
317
+ const command = t.attentionHint.attachCommand ? ` <code>${esc(t.attentionHint.attachCommand)}</code>` : '';
318
+ const reasons = Array.isArray(t.attentionHint.reasons) && t.attentionHint.reasons.length ? `|${esc(t.attentionHint.reasons.join(' / '))}` : '';
319
+ return `<span class="attention-hint" title="${esc(t.attentionHint.message || '')}">可能需要人工介入;请 attach tmux 检查。${command}${reasons}</span>`;
296
320
  }
297
321
  function taskActionCell(id, t) {
298
322
  if (!t || id === 'planner' || id === 'judge') return '-';
@@ -304,6 +328,13 @@ function sessionCell(thread) {
304
328
  if (!thread) return '-';
305
329
  return `<span class="session-cell">${esc(thread)}</span><button class="copy-btn" title="复制 Codex 会话ID" onclick="copySessionId(event, '${esc(thread)}')">⧉</button>`;
306
330
  }
331
+ function tmuxCell(t) {
332
+ if (!isTmuxMode()) return '-';
333
+ const tmux = t?.tmux;
334
+ if (!tmux) return '<span class="muted">未启动终端</span>';
335
+ const label = tmux.windowName || tmux.target || 'tmux';
336
+ return `<span class="session-cell" title="${esc(tmux.target || '')}">${esc(label)}</span>`;
337
+ }
307
338
  function taskStartedCell(t) {
308
339
  return t?.startedAt ? formatDateTime(t.startedAt) : '-';
309
340
  }
@@ -314,7 +345,7 @@ function taskDurationCell(t) {
314
345
  }
315
346
  function taskRow(id, role, t) {
316
347
  const thread = t?.codexThread?.id || '';
317
- return `<tr class="job-row ${selectedTask === id ? 'selected' : ''}" onclick="selectTask('${id}')"><td><b class="task-name" title="${esc(id)}">${esc(id)}</b><span class="muted task-role">${esc(displayRole(role))}</span></td><td>${taskStatusCell(t)}</td><td>${esc(taskStartedCell(t))}</td><td>${esc(taskDurationCell(t))}</td><td>${esc(t?.pid || '-')}</td><td>${esc(t?.exitCode ?? '-')}</td><td>${sessionCell(thread)}</td><td>${esc(t?.files?.lastMessage?.exists ? '有' : '-')}</td><td>${taskActionCell(id, t)}</td></tr>`;
348
+ return `<tr class="job-row ${selectedTask === id ? 'selected' : ''}" onclick="selectTask('${id}')"><td><b class="task-name" title="${esc(id)}">${esc(id)}</b><span class="muted task-role">${esc(displayRole(role))}</span></td><td>${taskStatusCell(t)}</td><td>${esc(taskStartedCell(t))}</td><td>${esc(taskDurationCell(t))}</td><td>${esc(t?.pid || '-')}</td><td>${esc(t?.exitCode ?? '-')}</td><td>${sessionCell(thread)}</td><td>${tmuxCell(t)}</td><td>${esc(t?.files?.lastMessage?.exists ? '有' : '-')}</td><td>${taskActionCell(id, t)}</td></tr>`;
318
349
  }
319
350
  function renderTasks() {
320
351
  const s = currentState;
@@ -322,12 +353,12 @@ function renderTasks() {
322
353
  if (Array.isArray(s.batches) && s.batches.length) {
323
354
  for (const b of s.batches) {
324
355
  const done = (b.tasks || []).filter(t => t.status === 'completed').length;
325
- rows.push(`<tr class="batch-row"><td colspan="9">${esc(b.name || b.id)} ${pill(b.status)} <span class="muted">${esc(b.id)}|最大并发 ${esc(b.maxParallel || '-')}|${done}/${(b.tasks || []).length}</span></td></tr>`);
356
+ rows.push(`<tr class="batch-row"><td colspan="10">${esc(b.name || b.id)} ${pill(b.status)} <span class="muted">${esc(b.id)}|最大并发 ${esc(b.maxParallel || '-')}|${done}/${(b.tasks || []).length}</span></td></tr>`);
326
357
  for (const t of b.tasks || []) rows.push(taskRow(t.id, t.name, t));
327
358
  }
328
359
  } else rows.push(...(s.tasks||[]).map(t => taskRow(t.id, t.name, t)));
329
360
  rows.push(taskRow('judge','最终验收',s.judge));
330
- document.getElementById('tasks').innerHTML = `<table><tr><th>任务</th><th>状态</th><th>发起时间</th><th>用时</th><th>进程号</th><th>退出码</th><th>Codex 会话ID</th><th>最终回复</th><th>操作</th></tr>${rows.join('')}</table>`;
361
+ document.getElementById('tasks').innerHTML = `<table><tr><th>任务</th><th>状态</th><th>发起时间</th><th>用时</th><th>进程号</th><th>退出码</th><th>Codex 会话ID</th><th>终端</th><th>最终回复</th><th>操作</th></tr>${rows.join('')}</table>`;
331
362
  }
332
363
  async function selectTask(id) {
333
364
  selectedTask = id;
@@ -347,12 +378,14 @@ async function loadFile(name, { preserveScroll = false } = {}) {
347
378
  else pre.scrollTop = 0;
348
379
  if (name === 'events.pretty') await renderExecutionSummary();
349
380
  else hideExecutionSummary();
381
+ renderTmuxPanel();
350
382
  updateCopyLastMessageButton();
351
383
  }
352
384
  function clearFileView() {
353
385
  document.getElementById('fileTitle').textContent = '点击任务后选择文件';
354
386
  document.getElementById('fileContent').textContent = '';
355
387
  hideExecutionSummary();
388
+ hideTmuxPanel();
356
389
  updateCopyLastMessageButton();
357
390
  }
358
391
  function updateCopyLastMessageButton() {
@@ -378,6 +411,50 @@ function hideExecutionSummary() {
378
411
  el.classList.add('hidden');
379
412
  el.innerHTML = '';
380
413
  }
414
+ function hideTmuxPanel() {
415
+ const el = document.getElementById('tmuxPanel');
416
+ el.classList.add('hidden');
417
+ el.innerHTML = '';
418
+ }
419
+ function renderTmuxPanel() {
420
+ const el = document.getElementById('tmuxPanel');
421
+ if (!el) return;
422
+ if (!selectedTask || !currentState) { hideTmuxPanel(); return; }
423
+ if (!isTmuxMode()) {
424
+ el.classList.remove('hidden');
425
+ el.innerHTML = '<div class="tmux-box-title">终端模式</div><span class="muted">当前 runner 为 headless,无需终端附加操作。</span>';
426
+ return;
427
+ }
428
+ const tmux = taskById(selectedTask)?.tmux;
429
+ if (!tmux) {
430
+ el.classList.remove('hidden');
431
+ el.innerHTML = '<div class="tmux-box-title">tmux 终端</div><span class="muted">该任务尚未生成 tmux window。</span>';
432
+ return;
433
+ }
434
+ el.classList.remove('hidden');
435
+ el.innerHTML = `
436
+ <div class="tmux-box-title">tmux 终端</div>
437
+ <span class="tmux-inline">session:${esc(tmux.sessionName || '-')}</span>
438
+ <span class="tmux-inline">window:${esc(tmux.windowName || '-')}</span>
439
+ <span class="tmux-inline">target:${esc(tmux.target || '-')}</span>
440
+ <div class="tmux-actions">
441
+ ${tmux.attachCommand ? `<button class="secondary" onclick="copyTmuxCommand(event, 'attach')">复制 attach</button>` : ''}
442
+ ${tmux.selectWindowCommand ? `<button class="secondary" onclick="copyTmuxCommand(event, 'select')">复制 select-window</button>` : ''}
443
+ </div>`;
444
+ }
445
+ async function copyTmuxCommand(event, kind) {
446
+ event.stopPropagation();
447
+ const tmux = taskById(selectedTask)?.tmux;
448
+ const command = kind === 'attach' ? tmux?.attachCommand : tmux?.selectWindowCommand;
449
+ if (!command) return;
450
+ try {
451
+ await navigator.clipboard.writeText(command);
452
+ event.currentTarget.textContent = '已复制';
453
+ setTimeout(() => { event.currentTarget.textContent = kind === 'attach' ? '复制 attach' : '复制 select-window'; }, 900);
454
+ } catch {
455
+ prompt(kind === 'attach' ? '复制 attachCommand' : '复制 selectWindowCommand', command);
456
+ }
457
+ }
381
458
  async function renderExecutionSummary() {
382
459
  const el = document.getElementById('executionSummary');
383
460
  let raw = '';
@@ -1,15 +1,27 @@
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';
11
-
12
- const runningChildren = new Map(); // key: `${runId}:${taskId}` -> child
10
+ import { defaultRunner } from './runners/index.js';
11
+
12
+ const runner = defaultRunner;
13
+ const ATTENTION_IDLE_MS = 5 * 60 * 1000;
14
+ const ATTENTION_MIN_RUNTIME_MS = 10 * 60 * 1000;
15
+ const ATTENTION_KEYWORDS = [
16
+ 'permission',
17
+ 'approval',
18
+ 'approve',
19
+ 'confirm',
20
+ 'continue',
21
+ 'password',
22
+ 'authentication',
23
+ 'authenticate'
24
+ ];
13
25
 
14
26
  function statePath(runDir) { return path.join(runDir, 'run_state.json'); }
15
27
  function planPath(runDir) { return path.join(runDir, 'plan.json'); }
@@ -21,6 +33,7 @@ export async function createRun({ label = 'task', taskText = '', repo = DEFAULT_
21
33
  await fsp.writeFile(path.join(runDir, 'task.md'), taskText || '');
22
34
  const state = {
23
35
  runId, label, repo: path.resolve(repo), maxParallel: Number(maxParallel) || 3,
36
+ runner: RUNNER,
24
37
  status: 'created', createdAt: nowIso(), updatedAt: nowIso(),
25
38
  planner: { status: 'pending' }, batches: [], tasks: [], judge: { status: 'pending' }
26
39
  };
@@ -132,24 +145,6 @@ Plan: ${path.join(pathForRun(state.runId), 'plan.json')}
132
145
  `;
133
146
  }
134
147
 
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
148
  export async function startPlanner(runId) {
154
149
  const state = await loadRun(runId);
155
150
  if (!state) throw new Error(`run not found: ${runId}`);
@@ -168,11 +163,11 @@ export async function startPlanner(runId) {
168
163
  await fsp.rm(planPath(runDir), { force: true });
169
164
  const taskText = await fsp.readFile(path.join(runDir, 'task.md'), 'utf8');
170
165
  const prompt = defaultPlannerPrompt(state, taskText);
171
- const child = spawnCodex({ state, taskId: 'planner', prompt, sandbox: 'read-only', cwd: state.repo, outDir });
166
+ const child = await runner.startCodexTask({ runId: state.runId, taskId: 'planner', prompt, sandbox: 'read-only', cwd: state.repo, outDir });
172
167
  state.status = 'planning';
173
168
  state.planner = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir, attempt: (state.plannerAttempts?.length || 0) + 1 };
174
169
  await saveRun(state);
175
- child.on('exit', async code => {
170
+ child.onExit(async code => {
176
171
  const s = await loadRun(runId); if (!s || s.status === 'stopped') return;
177
172
  s.planner.exitCode = code; s.planner.endedAt = nowIso(); s.planner.status = code === 0 ? 'completed' : 'failed';
178
173
  const planResult = await materializePlan(s);
@@ -304,7 +299,7 @@ ORCHESTRATOR_BATCH_ID: ${task.batchId || 'batch-1'}
304
299
 
305
300
  ${task.prompt}
306
301
  `;
307
- const child = spawnCodex({ state, taskId: task.id, prompt: fullPrompt, sandbox: task.sandbox || 'workspace-write', cwd: state.repo, outDir });
302
+ const child = await runner.startCodexTask({ runId: state.runId, taskId: task.id, prompt: fullPrompt, sandbox: task.sandbox || 'workspace-write', cwd: state.repo, outDir });
308
303
  Object.assign(task, { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir });
309
304
  }
310
305
 
@@ -312,12 +307,7 @@ export async function stopRun(runId, { reason = 'stopped by user' } = {}) {
312
307
  const state = await loadRun(runId);
313
308
  if (!state) throw new Error(`run not found: ${runId}`);
314
309
  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
- }
310
+ await runner.stopRun(runId);
321
311
  for (const roleState of [state.planner, state.judge]) {
322
312
  if (roleState?.status === 'running') Object.assign(roleState, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
323
313
  }
@@ -400,11 +390,11 @@ export async function startJudge(runId) {
400
390
  const judgeInput = await buildJudgeInput(state);
401
391
  await writeJsonAtomic(judgeInputPath, judgeInput);
402
392
  const prompt = defaultJudgePrompt(state, judgeInputPath);
403
- const child = spawnCodex({ state, taskId: 'judge', prompt, sandbox: 'read-only', cwd: state.repo, outDir });
393
+ const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', prompt, sandbox: 'read-only', cwd: state.repo, outDir });
404
394
  state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir };
405
395
  state.status = 'judging';
406
396
  await saveRun(state);
407
- child.on('exit', async code => {
397
+ child.onExit(async code => {
408
398
  const s = await loadRun(runId); if (!s || s.status === 'stopped') return;
409
399
  s.judge.exitCode = code; s.judge.endedAt = nowIso(); s.judge.status = code === 0 ? 'completed' : 'failed';
410
400
  const text = await readTextMaybe(path.join(outDir, 'last_message.md'), 1000000);
@@ -423,9 +413,12 @@ export async function refreshRun(runId, appClient = null) {
423
413
  async function loadAndRefreshRun(runId, appClient = null, { light = false } = {}) {
424
414
  const state = await loadRun(runId);
425
415
  if (!state) return null;
416
+ state.runner = state.runner || RUNNER;
426
417
  await refreshRole(state, state.planner, roleDir(pathForRun(runId), 'planner'));
418
+ await recoverCompletedPlanner(state);
427
419
  for (const task of state.tasks || []) await refreshTask(state, task);
428
420
  await refreshRole(state, state.judge, roleDir(pathForRun(runId), 'judge'));
421
+ await recoverCompletedJudge(state);
429
422
  recomputeRunStatus(state);
430
423
  await scheduleMoreWorkers(state);
431
424
  recomputeRunStatus(state);
@@ -434,19 +427,43 @@ async function loadAndRefreshRun(runId, appClient = null, { light = false } = {}
434
427
  return state;
435
428
  }
436
429
 
430
+ async function recoverCompletedPlanner(state) {
431
+ if (state.planner?.status !== 'completed' || state.tasks?.length || state.batches?.length) return;
432
+ const planResult = await materializePlan(state);
433
+ if (planResult.ok) state.status = 'planned';
434
+ else if (planResult.empty) state.status = 'plan_empty';
435
+ else state.status = 'plan_failed';
436
+ }
437
+
438
+ async function recoverCompletedJudge(state) {
439
+ if (!['completed', 'failed'].includes(state.judge?.status)) return;
440
+ if (state.judge.status === 'completed' && !state.judge.verdict) {
441
+ const outDir = roleDir(pathForRun(state.runId), 'judge');
442
+ const text = await readTextMaybe(path.join(outDir, 'last_message.md'), 1000000);
443
+ const verdict = extractFirstJsonObject(text);
444
+ if (verdict) {
445
+ state.judge.verdict = verdict;
446
+ await writeJsonAtomic(path.join(outDir, 'verdict.json'), verdict);
447
+ }
448
+ }
449
+ state.status = state.judge.status === 'completed' ? 'judged' : 'judge_failed';
450
+ }
451
+
437
452
  async function refreshRole(state, roleState, dir) {
438
453
  if (!roleState) return;
439
454
  const exitPath = path.join(dir, 'exit_code');
440
455
  const exit = await readTextMaybe(exitPath, 1000);
441
456
  const exitInfo = await fileInfo(exitPath);
442
- const key = `${state.runId}:${roleState === state.judge ? 'judge' : 'planner'}`;
457
+ const key = roleState === state.judge ? 'judge' : 'planner';
443
458
  if (exit !== '') {
444
459
  roleState.exitCode = Number(exit.trim());
445
460
  if (!roleState.endedAt && exitInfo.exists) roleState.endedAt = exitInfo.mtime;
446
461
  if (roleState.status === 'running') roleState.status = roleState.exitCode === 0 ? 'completed' : 'failed';
447
462
  }
448
- else if (roleState.status === 'running' && !runningChildren.has(key)) roleState.status = 'unknown';
463
+ else if (roleState.status === 'running' && !runner.hasRunning(state.runId, key)) roleState.status = 'unknown';
449
464
  roleState.files = await standardFiles(dir);
465
+ await attachTmuxMetadata(roleState, dir);
466
+ roleState.attentionHint = await buildAttentionHint({ state, target: roleState, dir });
450
467
  }
451
468
 
452
469
  async function refreshTask(state, task) {
@@ -454,13 +471,14 @@ async function refreshTask(state, task) {
454
471
  const exitPath = path.join(dir, 'exit_code');
455
472
  const exit = await readTextMaybe(exitPath, 1000);
456
473
  const exitInfo = await fileInfo(exitPath);
457
- const key = `${state.runId}:${task.id}`;
458
474
  if (exit !== '') {
459
475
  task.exitCode = Number(exit.trim());
460
476
  if (!task.endedAt && exitInfo.exists) task.endedAt = exitInfo.mtime;
461
477
  if (task.status === 'running') task.status = task.exitCode === 0 ? 'completed' : 'failed';
462
- } else if (task.status === 'running' && !runningChildren.has(key)) task.status = 'unknown';
478
+ } else if (task.status === 'running' && !runner.hasRunning(state.runId, task.id)) task.status = 'unknown';
463
479
  task.files = await standardFiles(dir);
480
+ await attachTmuxMetadata(task, dir);
481
+ task.attentionHint = await buildAttentionHint({ state, target: task, dir });
464
482
  task.artifacts = [];
465
483
  for (const rel of task.expectedArtifacts || []) task.artifacts.push({ path: rel, ...(await fileInfo(path.isAbsolute(rel) ? rel : path.join(state.repo, rel))) });
466
484
  const batch = (state.batches || []).find(b => b.id === task.batchId);
@@ -470,13 +488,77 @@ async function refreshTask(state, task) {
470
488
  }
471
489
  }
472
490
 
491
+ async function attachTmuxMetadata(target, dir) {
492
+ const raw = await readJson(path.join(dir, 'tmux.json'), null);
493
+ if (!raw || raw.runner !== 'tmux') {
494
+ delete target.tmux;
495
+ return;
496
+ }
497
+ const selectWindowCommand = raw.selectWindowCommand || raw.selectCommand || '';
498
+ target.tmux = {
499
+ runner: 'tmux',
500
+ sessionName: raw.sessionName || '',
501
+ windowName: raw.windowName || '',
502
+ target: raw.target || '',
503
+ attachCommand: raw.attachCommand || '',
504
+ selectWindowCommand,
505
+ runScript: raw.runScript || '',
506
+ startedAt: raw.startedAt || ''
507
+ };
508
+ }
509
+
510
+ async function buildAttentionHint({ state, target, dir }) {
511
+ if (!['running', 'unknown'].includes(target?.status) || state?.status === 'stopped') return null;
512
+ if ((state?.runner || RUNNER) !== 'tmux' && !target.tmux) return null;
513
+ const reasons = [];
514
+ const textTail = [
515
+ await readTextMaybe(path.join(dir, 'stderr.log'), 20000),
516
+ await readTextMaybe(path.join(dir, 'events.jsonl'), 20000)
517
+ ].join('\n');
518
+ const keyword = findAttentionKeyword(textTail);
519
+ if (keyword) reasons.push(`log tail contains "${keyword}"`);
520
+ const idle = taskIdleSnapshot(target);
521
+ if (idle.isLongIdle || (target.status === 'unknown' && idle.isIdle)) reasons.push(`no recent log updates for ${Math.round(idle.idleMs / 1000)}s`);
522
+ if (!reasons.length) return null;
523
+ return {
524
+ message: 'This task may need manual intervention; attach to tmux to inspect.',
525
+ reasons,
526
+ attachCommand: target.tmux?.attachCommand || '',
527
+ detectedAt: nowIso()
528
+ };
529
+ }
530
+
531
+ function findAttentionKeyword(text) {
532
+ const lower = String(text || '').toLowerCase();
533
+ return ATTENTION_KEYWORDS.find(keyword => lower.includes(keyword)) || null;
534
+ }
535
+
536
+ function taskIdleSnapshot(target) {
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
550
+ };
551
+ }
552
+
473
553
  async function standardFiles(dir) {
474
554
  return {
475
555
  prompt: await fileInfo(path.join(dir, 'prompt.md')),
476
556
  events: await fileInfo(path.join(dir, 'events.jsonl')),
477
557
  stderr: await fileInfo(path.join(dir, 'stderr.log')),
478
558
  lastMessage: await fileInfo(path.join(dir, 'last_message.md')),
479
- exitCode: await fileInfo(path.join(dir, 'exit_code'))
559
+ exitCode: await fileInfo(path.join(dir, 'exit_code')),
560
+ runScript: await fileInfo(path.join(dir, 'run.sh')),
561
+ tmux: await fileInfo(path.join(dir, 'tmux.json'))
480
562
  };
481
563
  }
482
564
 
@@ -567,6 +649,7 @@ async function buildJudgeInput(state) {
567
649
  resultJson: await readJson(path.join(dir, 'result.json'), null),
568
650
  evidenceJson: await readJson(path.join(dir, 'evidence.json'), null),
569
651
  manualCompletion: task.manualCompletion || await readJson(path.join(dir, 'manual_completion.json'), null),
652
+ tmux: task.tmux || null,
570
653
  stderrTail: await readTextMaybe(path.join(dir, 'stderr.log'), 20000)
571
654
  });
572
655
  }
@@ -579,6 +662,7 @@ async function buildJudgeInput(state) {
579
662
  label: state.label,
580
663
  repo: state.repo,
581
664
  status: state.status,
665
+ runner: state.runner || RUNNER,
582
666
  createdAt: state.createdAt,
583
667
  updatedAt: state.updatedAt,
584
668
  maxParallel: state.maxParallel
@@ -597,6 +681,7 @@ async function buildJudgeInput(state) {
597
681
  exitCode: state.planner?.exitCode ?? null,
598
682
  planParseError: state.planner?.planParseError,
599
683
  planEmpty: !!state.planner?.planEmpty,
684
+ tmux: state.planner?.tmux || null,
600
685
  lastMessage: await readTextMaybe(path.join(roleDir(runDir, 'planner'), 'last_message.md'), 200000)
601
686
  },
602
687
  tasks
@@ -629,7 +714,7 @@ async function enrichFromAppServer(state, appClient) {
629
714
 
630
715
  function summaryOfRun(s) {
631
716
  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 })) };
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 })) };
633
718
  }
634
719
 
635
720
  function formatCodexEventsJsonl(text) {
@@ -718,7 +803,7 @@ export async function readRunTaskText(runId) {
718
803
 
719
804
  export async function readRunFile(runId, taskId, name) {
720
805
  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']);
806
+ 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
807
  if (!allowed.has(name)) throw new Error('file not allowed');
723
808
  let dir;
724
809
  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();