input-kanban 0.0.2 → 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/ENVIRONMENT.md CHANGED
@@ -11,6 +11,7 @@ CLI options take precedence over environment variables. Environment variables ta
11
11
  - `KANBAN_DEFAULT_REPO`: Default target repository path for new runs. Default: the current working directory when `input-kanban` is launched. CLI option: `--repo`.
12
12
  - `KANBAN_RUNS_DIR`: Directory for run state, logs, and artifacts. Default: `.input-kanban/runs` under the user's home directory. CLI option: `--runs-dir`.
13
13
  - `KANBAN_CODEX_BIN`: Codex CLI executable name or path. Default: `codex`. CLI option: `--codex-bin`.
14
+ - `KANBAN_RUNNER`: Runner mode. Supported values: `headless`, `tmux`. Default: `headless`. CLI option: `--runner`.
14
15
 
15
16
  ## Environment Example
16
17
 
@@ -19,6 +20,7 @@ PORT=8787 \
19
20
  KANBAN_DEFAULT_REPO=/path/to/child-repo \
20
21
  KANBAN_RUNS_DIR=/path/to/kanban-runs \
21
22
  KANBAN_CODEX_BIN=codex \
23
+ KANBAN_RUNNER=headless \
22
24
  input-kanban
23
25
  ```
24
26
 
@@ -29,11 +31,15 @@ input-kanban \
29
31
  --port 8787 \
30
32
  --repo /path/to/child-repo \
31
33
  --runs-dir /path/to/kanban-runs \
32
- --codex-bin codex
34
+ --codex-bin codex \
35
+ --runner headless
33
36
  ```
34
37
 
35
38
  ## Notes
36
39
 
37
40
  - `KANBAN_DEFAULT_REPO` / `--repo` should point to the actual git repository where work should run.
41
+ - `KANBAN_RUNNER` / `--runner tmux` runs Codex tasks inside tmux windows while keeping scheduling and status tracking in the Node.js orchestrator.
42
+ - `KANBAN_RUNNER=tmux` is optional. Use it when you want live terminal visibility into planner, worker, and final judge sessions, or when you need to manually respond to Codex CLI approval prompts.
43
+ - tmux mode does not implement automatic approval. It does not bypass Codex CLI, repository, or system permission boundaries; any approval prompt must still be explicitly approved by the user in the relevant tmux window.
38
44
  - The runtime runs directory contains task text, logs, model output, artifacts, and possible audit information. It should not be committed to git.
39
45
  - Avoid writing machine-specific absolute paths into public or shared documentation.
package/PROJECT_GUIDE.md CHANGED
@@ -322,6 +322,30 @@ npm run check
322
322
 
323
323
  When editing `public/index.html`, also consider extracting the inline script and checking it with `node --check`.
324
324
 
325
+ ## Manual Smoke Checklist
326
+
327
+ Use this checklist before an npm release when runner behavior or package contents
328
+ change. Record the exact commands, run ids, and artifact paths in the release
329
+ notes or handoff.
330
+
331
+ 1. Headless runner:
332
+ - Start the app with `input-kanban --runner headless --runs-dir <tmp-runs-dir> --repo <target-repo> --port <free-port>`.
333
+ - Create a small run, plan it, dispatch at least one worker, and run the final judge if the plan requires it.
334
+ - Verify the run state reports `runner: headless`, no task exposes `tmux` metadata, and role directories contain the expected `prompt.md`, `events.jsonl`, `stderr.log`, `last_message.md`, and `exit_code` files.
335
+ - Stop the run and verify no unrelated local process is affected.
336
+
337
+ 2. tmux runner, only when `tmux -V` succeeds:
338
+ - Start the app with `input-kanban --runner tmux --runs-dir <tmp-runs-dir> --repo <target-repo> --port <free-port>`.
339
+ - Create a small run and click Plan. Verify a session named `input-kanban-<runId>` exists and has a planner window.
340
+ - Dispatch workers. Verify each worker gets its own window and each role directory writes `run.sh` and `tmux.json` with the expected `sessionName`, `windowName`, `target`, `attachCommand`, and `selectWindowCommand`.
341
+ - Complete or stop the run. Verify stop removes only the exact `input-kanban-<runId>` tmux session and leaves any other tmux session running.
342
+ - Do not mark this smoke as passed when tmux is unavailable or when these tmux checks were not run.
343
+
344
+ 3. Package dry run:
345
+ - Run `npm pack --dry-run`.
346
+ - Verify the package includes `bin/`, `src/`, `public/`, `README.md`, `README.en.md`, `PROJECT_GUIDE.md`, `ENVIRONMENT.md`, and `package.json`.
347
+ - Verify no runtime run directories, local logs, or unrelated temporary artifacts are included.
348
+
325
349
  ## Change Guidelines
326
350
 
327
351
  - Do not add automatic worker retry unless there is a verified rollback or idempotency mechanism.
package/README.en.md CHANGED
@@ -50,6 +50,7 @@ input-kanban --port 8787
50
50
  input-kanban --host 127.0.0.1
51
51
  input-kanban --runs-dir ~/.input-kanban/runs
52
52
  input-kanban --codex-bin codex
53
+ input-kanban --runner headless
53
54
  input-kanban --open
54
55
  ```
55
56
 
@@ -60,6 +61,13 @@ Defaults:
60
61
  - port: `8787`
61
62
  - runs directory: `~/.input-kanban/runs`
62
63
  - Codex command: `codex`
64
+ - runner: `headless`
65
+
66
+ `--runner` currently supports `headless` and `tmux`. The default behavior remains `headless`; `tmux` creates one `input-kanban-<runId>` session per run and one window for the planner, each worker, and the final judge.
67
+
68
+ tmux mode still leaves batch barriers, `maxParallel`, final judge sequencing, and `judge_input.json` generation in Node.js. Each role output directory gets `run.sh` and `tmux.json`; status continues to be driven by `events.jsonl`, `stderr.log`, `last_message.md`, `exit_code`, and existing artifact files.
69
+
70
+ tmux mode is optional. It is intended for live terminal viewing of each Codex role and for cases where the Codex CLI asks the user for manual approval. It does not implement automatic approval and does not bypass Codex CLI, repository, or system permission boundaries; any approval prompt still has to be explicitly approved by the user in the relevant tmux window.
63
71
 
64
72
  ## Using the Dashboard
65
73
 
package/README.md CHANGED
@@ -50,6 +50,7 @@ input-kanban --port 8787
50
50
  input-kanban --host 127.0.0.1
51
51
  input-kanban --runs-dir ~/.input-kanban/runs
52
52
  input-kanban --codex-bin codex
53
+ input-kanban --runner headless
53
54
  input-kanban --open
54
55
  ```
55
56
 
@@ -60,6 +61,13 @@ input-kanban --open
60
61
  - port:`8787`
61
62
  - runs 目录:`~/.input-kanban/runs`
62
63
  - Codex 命令:`codex`
64
+ - runner:`headless`
65
+
66
+ `--runner` 当前支持 `headless` 和 `tmux`。默认行为保持 `headless`;`tmux` 会为每个 run 创建一个 `input-kanban-<runId>` session,并为 planner、每个 worker、final judge 创建独立 window。
67
+
68
+ tmux 模式仍由 Node.js 负责 batch barrier、`maxParallel`、final judge 顺序和 `judge_input.json` 生成。每个角色输出目录会写入 `run.sh` 和 `tmux.json`,状态继续由 `events.jsonl`、`stderr.log`、`last_message.md`、`exit_code` 和既有 artifact 文件驱动。
69
+
70
+ tmux 模式是可选能力,主要用于在终端里实时查看每个 Codex 角色的执行过程,并在 Codex CLI 需要人工确认时让用户手动处理。它不会实现自动审批,也不会绕过 Codex CLI、仓库权限或系统权限的安全边界;所有需要确认的操作仍必须由用户在对应 tmux window 中明确批准。
63
71
 
64
72
  ## 在看板里如何使用
65
73
 
@@ -2,8 +2,15 @@
2
2
  import path from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
 
5
+ const VALID_RUNNERS = ['headless', 'tmux'];
6
+
7
+ function validateRunner(value, source) {
8
+ if (VALID_RUNNERS.includes(value)) return value;
9
+ throw new Error(`invalid ${source}: ${value}; expected one of: ${VALID_RUNNERS.join(', ')}`);
10
+ }
11
+
5
12
  function parseArgs(argv) {
6
- const args = { host: '127.0.0.1', port: undefined, repo: undefined, runsDir: undefined, codexBin: undefined, open: false, help: false };
13
+ const args = { host: '127.0.0.1', port: undefined, repo: undefined, runsDir: undefined, codexBin: undefined, runner: undefined, open: false, help: false };
7
14
  for (let i = 0; i < argv.length; i++) {
8
15
  const arg = argv[i];
9
16
  const next = () => argv[++i];
@@ -15,6 +22,7 @@ function parseArgs(argv) {
15
22
  else if (arg === '--repo' || arg === '-r') args.repo = next();
16
23
  else if (arg === '--runs-dir') args.runsDir = next();
17
24
  else if (arg === '--codex-bin') args.codexBin = next();
25
+ else if (arg === '--runner') args.runner = validateRunner(next(), '--runner');
18
26
  else throw new Error(`unknown argument: ${arg}`);
19
27
  }
20
28
  return args;
@@ -32,6 +40,7 @@ Options:
32
40
  -r, --repo <path> Default target repository, default current directory
33
41
  --runs-dir <path> Runtime runs directory, default ~/.input-kanban/runs
34
42
  --codex-bin <path> Codex CLI executable, default codex
43
+ --runner <mode> Runner mode: headless or tmux, default headless
35
44
  --open Open browser after starting
36
45
  --no-open Do not open browser, default
37
46
  -h, --help Show help
@@ -54,6 +63,7 @@ try {
54
63
  else if (!process.env.KANBAN_DEFAULT_REPO) process.env.KANBAN_DEFAULT_REPO = process.cwd();
55
64
  if (args.runsDir) process.env.KANBAN_RUNS_DIR = path.resolve(args.runsDir);
56
65
  if (args.codexBin) process.env.KANBAN_CODEX_BIN = args.codexBin;
66
+ if (args.runner) process.env.KANBAN_RUNNER = args.runner;
57
67
 
58
68
  const { startServer } = await import('../src/server.js');
59
69
  const instance = await startServer({ host: process.env.HOST, port: Number(process.env.PORT || 8787), log: false });
@@ -61,6 +71,7 @@ try {
61
71
  console.log(`URL: ${instance.url}`);
62
72
  console.log(`Repo: ${instance.defaultRepo}`);
63
73
  console.log(`Runs: ${instance.runsDir}`);
74
+ console.log(`Runner: ${instance.runner}`);
64
75
  if (args.open) openBrowser(instance.url);
65
76
  const shutdown = () => { instance.stop().finally(() => process.exit(0)); };
66
77
  process.on('SIGINT', shutdown);
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "input-kanban",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "input-kanban": "bin/input-kanban.js"
7
7
  },
8
8
  "scripts": {
9
9
  "start": "node bin/input-kanban.js",
10
- "check": "node --check bin/input-kanban.js && node --check src/server.js && node --check src/orchestrator.js && node --check src/appServerClient.js && node --check src/utils.js"
10
+ "check": "node --check bin/input-kanban.js && node --check src/server.js && node --check src/orchestrator.js && node --check src/appServerClient.js && node --check src/utils.js && node --check src/runners/index.js && node --check src/runners/headlessRunner.js && node --check src/runners/tmuxRunner.js && node --check src/runners/tmuxUtils.js && node --check src/tmux.js && node --test"
11
11
  },
12
12
  "dependencies": {},
13
13
  "description": "A local Codex orchestration kanban dashboard",
package/public/index.html CHANGED
@@ -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; }
@@ -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();
@@ -0,0 +1,170 @@
1
+ import fsp from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {
4
+ CODEX_BIN,
5
+ ensureDir,
6
+ nowIso,
7
+ readTextMaybe,
8
+ writeJsonAtomic
9
+ } from '../utils.js';
10
+ import {
11
+ DEFAULT_TMUX_BIN,
12
+ sanitizeTmuxSessionName,
13
+ sanitizeTmuxWindowName,
14
+ tmuxHasSession,
15
+ tmuxKillSession,
16
+ tmuxNewSession,
17
+ tmuxNewWindow
18
+ } from '../tmux.js';
19
+
20
+ function processKey(runId, taskId) {
21
+ return `${runId}:${taskId}`;
22
+ }
23
+
24
+ function roleForTask(taskId) {
25
+ if (taskId === 'planner') return 'planner';
26
+ if (taskId === 'judge') return 'judge';
27
+ return 'worker';
28
+ }
29
+
30
+ function windowNameForTask(taskId) {
31
+ const role = roleForTask(taskId);
32
+ return sanitizeTmuxWindowName(role === 'worker' ? `worker-${taskId}` : role);
33
+ }
34
+
35
+ function sessionNameForRun(runId) {
36
+ return sanitizeTmuxSessionName(`input-kanban-${runId}`);
37
+ }
38
+
39
+ function shellQuote(value) {
40
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
41
+ }
42
+
43
+ function buildRunScript({ codexBin, sandbox, cwd, outDir }) {
44
+ return `#!/usr/bin/env bash
45
+ set -u
46
+
47
+ CODEX_BIN=${shellQuote(codexBin)}
48
+ SANDBOX=${shellQuote(sandbox)}
49
+ CWD=${shellQuote(cwd)}
50
+ OUT_DIR=${shellQuote(outDir)}
51
+ PROMPT_FILE="$OUT_DIR/prompt.md"
52
+ EVENTS="$OUT_DIR/events.jsonl"
53
+ STDERR_LOG="$OUT_DIR/stderr.log"
54
+ LAST_MESSAGE="$OUT_DIR/last_message.md"
55
+ EXIT_CODE="$OUT_DIR/exit_code"
56
+
57
+ cd "$CWD"
58
+ rm -f "$EXIT_CODE"
59
+ "$CODEX_BIN" exec --json --sandbox "$SANDBOX" -C "$CWD" -o "$LAST_MESSAGE" "$(<"$PROMPT_FILE")" >>"$EVENTS" 2>>"$STDERR_LOG"
60
+ code=$?
61
+ printf '%s' "$code" > "$EXIT_CODE"
62
+ exit "$code"
63
+ `;
64
+ }
65
+
66
+ export function createTmuxRunner({
67
+ codexBin = CODEX_BIN,
68
+ tmuxBin = DEFAULT_TMUX_BIN,
69
+ tmuxOptions = {},
70
+ pollMs = 1000
71
+ } = {}) {
72
+ const runningWindows = new Map();
73
+
74
+ async function startCodexTask({ runId, taskId, prompt, sandbox, cwd, outDir }) {
75
+ await ensureDir(outDir);
76
+ const sessionName = sessionNameForRun(runId);
77
+ const windowName = windowNameForTask(taskId);
78
+ const role = roleForTask(taskId);
79
+ const key = processKey(runId, taskId);
80
+ const promptFile = path.join(outDir, 'prompt.md');
81
+ const runScript = path.join(outDir, 'run.sh');
82
+ const exitFile = path.join(outDir, 'exit_code');
83
+ const metadataFile = path.join(outDir, 'tmux.json');
84
+ const startedAt = nowIso();
85
+
86
+ await fsp.writeFile(promptFile, prompt);
87
+ await fsp.writeFile(runScript, buildRunScript({ codexBin, sandbox, cwd, outDir }));
88
+ await fsp.chmod(runScript, 0o755);
89
+
90
+ const metadata = {
91
+ type: 'input_kanban_tmux_task',
92
+ version: 1,
93
+ runner: 'tmux',
94
+ runId,
95
+ taskId,
96
+ role,
97
+ sessionName,
98
+ windowName,
99
+ 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
+ runScript,
104
+ promptFile,
105
+ cwd,
106
+ sandbox,
107
+ startedAt
108
+ };
109
+ await writeJsonAtomic(metadataFile, metadata);
110
+
111
+ const tmuxCommandOptions = {
112
+ ...tmuxOptions,
113
+ tmuxBin,
114
+ cwd,
115
+ command: runScript
116
+ };
117
+ if (await tmuxHasSession(sessionName, tmuxCommandOptions)) {
118
+ await tmuxNewWindow(sessionName, windowName, tmuxCommandOptions);
119
+ } else {
120
+ await tmuxNewSession(sessionName, { ...tmuxCommandOptions, windowName });
121
+ }
122
+
123
+ const listeners = [];
124
+ let exited = false;
125
+ let exitCode = null;
126
+ const timer = setInterval(async () => {
127
+ const text = await readTextMaybe(exitFile, 1000);
128
+ if (text === '') return;
129
+ clearInterval(timer);
130
+ runningWindows.delete(key);
131
+ const code = Number(text.trim());
132
+ exited = true;
133
+ exitCode = Number.isNaN(code) ? null : code;
134
+ for (const listener of listeners) listener(exitCode);
135
+ }, Math.max(100, Number(pollMs) || 1000));
136
+
137
+ const handle = {
138
+ pid: null,
139
+ onExit(listener) {
140
+ if (exited) listener(exitCode);
141
+ else listeners.push(listener);
142
+ },
143
+ stop() {}
144
+ };
145
+ runningWindows.set(key, { sessionName, windowName, timer });
146
+ return handle;
147
+ }
148
+
149
+ async function stopRun(runId) {
150
+ for (const [key, value] of runningWindows.entries()) {
151
+ if (!key.startsWith(`${runId}:`)) continue;
152
+ clearInterval(value.timer);
153
+ runningWindows.delete(key);
154
+ }
155
+ const sessionName = sessionNameForRun(runId);
156
+ try {
157
+ if (await tmuxHasSession(sessionName, { ...tmuxOptions, tmuxBin })) {
158
+ await tmuxKillSession(sessionName, { ...tmuxOptions, tmuxBin });
159
+ }
160
+ } catch (error) {
161
+ if (!/no such session/i.test(error?.message || '')) throw error;
162
+ }
163
+ }
164
+
165
+ function hasRunning(runId, taskId) {
166
+ return runningWindows.has(processKey(runId, taskId));
167
+ }
168
+
169
+ return { kind: 'tmux', sessionNameForRun, startCodexTask, stopRun, hasRunning };
170
+ }
@@ -0,0 +1,15 @@
1
+ export {
2
+ DEFAULT_TMUX_BIN,
3
+ TmuxUnavailableError,
4
+ checkTmuxAvailable,
5
+ ensureTmuxAvailable,
6
+ runTmux,
7
+ sanitizeTmuxName,
8
+ sanitizeTmuxSessionName,
9
+ sanitizeTmuxWindowName,
10
+ tmuxHasSession,
11
+ tmuxKillSession,
12
+ tmuxKillWindow,
13
+ tmuxNewSession,
14
+ tmuxNewWindow
15
+ } from '../tmux.js';
package/src/server.js CHANGED
@@ -3,7 +3,7 @@ import fsp from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { CodexAppServerClient } from './appServerClient.js';
6
- import { APP_ROOT, DEFAULT_REPO, RUNS_DIR } from './utils.js';
6
+ import { APP_ROOT, DEFAULT_REPO, RUNNER, RUNS_DIR } from './utils.js';
7
7
  import { createRun, listRuns, startPlanner, dispatchRun, startJudge, refreshRun, readRunFile, readRunTaskText, markTaskCompleted, stopRun, archiveRun } from './orchestrator.js';
8
8
 
9
9
  const PUBLIC_DIR = path.join(APP_ROOT, 'public');
@@ -42,7 +42,7 @@ async function handleApi(req, res, url, appClient) {
42
42
  const parts = url.pathname.split('/').filter(Boolean);
43
43
  try {
44
44
  if (req.method === 'GET' && url.pathname === '/api/health') {
45
- return send(res, 200, { ok: true, appRoot: APP_ROOT, runsDir: RUNS_DIR, defaultRepo: DEFAULT_REPO });
45
+ return send(res, 200, { ok: true, appRoot: APP_ROOT, runsDir: RUNS_DIR, defaultRepo: DEFAULT_REPO, runner: RUNNER });
46
46
  }
47
47
  if (parts[1] === 'runs' && parts.length === 2) {
48
48
  if (req.method === 'GET') return send(res, 200, { runs: await listRuns({ includeArchived: url.searchParams.get('includeArchived') === '1' }) });
@@ -100,7 +100,7 @@ export async function startServer({ host = process.env.HOST || '127.0.0.1', port
100
100
  appClient.stop();
101
101
  await new Promise(resolve => server.close(resolve));
102
102
  };
103
- return { server, appClient, host, port, url, defaultRepo: DEFAULT_REPO, runsDir: RUNS_DIR, stop };
103
+ return { server, appClient, host, port, url, defaultRepo: DEFAULT_REPO, runsDir: RUNS_DIR, runner: RUNNER, stop };
104
104
  }
105
105
 
106
106
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
package/src/tmux.js ADDED
@@ -0,0 +1,139 @@
1
+ import { execFile } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import { promisify } from 'node:util';
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ export const DEFAULT_TMUX_BIN = process.env.KANBAN_TMUX_BIN || 'tmux';
8
+
9
+ export class TmuxUnavailableError extends Error {
10
+ constructor(tmuxBin, result) {
11
+ const detail = result?.stderr || result?.stdout || result?.error?.message || 'command failed';
12
+ super(`tmux is unavailable: failed to run "${tmuxBin} -V" (${detail.trim()})`);
13
+ this.name = 'TmuxUnavailableError';
14
+ this.tmuxBin = tmuxBin;
15
+ this.result = result;
16
+ }
17
+ }
18
+
19
+ function normalizeResult(result) {
20
+ return {
21
+ code: Number.isInteger(result?.code) ? result.code : 0,
22
+ stdout: result?.stdout || '',
23
+ stderr: result?.stderr || '',
24
+ error: result?.error
25
+ };
26
+ }
27
+
28
+ async function defaultRunner(command, args, { timeoutMs = 3000 } = {}) {
29
+ try {
30
+ const { stdout, stderr } = await execFileAsync(command, args, { timeout: timeoutMs, windowsHide: true });
31
+ return { code: 0, stdout, stderr };
32
+ } catch (error) {
33
+ const code = Number.isInteger(error?.code) ? error.code : error?.code === 'ENOENT' ? 127 : 1;
34
+ return { code, stdout: error?.stdout || '', stderr: error?.stderr || error?.message || '', error };
35
+ }
36
+ }
37
+
38
+ async function runCommand(command, args, options = {}) {
39
+ const runner = options.runner || defaultRunner;
40
+ return normalizeResult(await runner(command, args, { timeoutMs: options.timeoutMs || 3000 }));
41
+ }
42
+
43
+ function safeFallback(fallback) {
44
+ const value = String(fallback || 'tmux')
45
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
46
+ .replace(/-+/g, '-')
47
+ .replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$/g, '');
48
+ return value || 'tmux';
49
+ }
50
+
51
+ export function sanitizeTmuxName(value, { fallback = 'tmux', maxLength = 80 } = {}) {
52
+ const limit = Math.max(16, Number(maxLength) || 80);
53
+ const original = String(value || '');
54
+ let name = original
55
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
56
+ .replace(/-+/g, '-')
57
+ .replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$/g, '');
58
+
59
+ if (!name) name = safeFallback(fallback);
60
+ if (name.length <= limit) return name;
61
+
62
+ const hash = crypto.createHash('sha256').update(original || name).digest('hex').slice(0, 8);
63
+ const prefixLength = Math.max(1, limit - hash.length - 1);
64
+ const fallbackPrefix = safeFallback(fallback).slice(0, prefixLength);
65
+ const prefix = name.slice(0, prefixLength).replace(/[^a-zA-Z0-9]+$/g, '') || fallbackPrefix;
66
+ return `${prefix}-${hash}`.slice(0, limit);
67
+ }
68
+
69
+ export function sanitizeTmuxSessionName(value, options = {}) {
70
+ return sanitizeTmuxName(value, { fallback: 'input-kanban', ...options });
71
+ }
72
+
73
+ export function sanitizeTmuxWindowName(value, options = {}) {
74
+ return sanitizeTmuxName(value, { fallback: 'worker', ...options });
75
+ }
76
+
77
+ export async function checkTmuxAvailable(options = {}) {
78
+ const tmuxBin = options.tmuxBin || DEFAULT_TMUX_BIN;
79
+ const result = await runCommand(tmuxBin, ['-V'], options);
80
+ return {
81
+ available: result.code === 0,
82
+ tmuxBin,
83
+ version: result.code === 0 ? result.stdout.trim() : '',
84
+ result
85
+ };
86
+ }
87
+
88
+ export async function ensureTmuxAvailable(options = {}) {
89
+ const status = await checkTmuxAvailable(options);
90
+ if (!status.available) throw new TmuxUnavailableError(status.tmuxBin, status.result);
91
+ return status;
92
+ }
93
+
94
+ export async function runTmux(args, options = {}) {
95
+ const tmuxBin = options.tmuxBin || DEFAULT_TMUX_BIN;
96
+ await ensureTmuxAvailable({ ...options, tmuxBin });
97
+ const result = await runCommand(tmuxBin, args, options);
98
+ if (result.code !== 0) {
99
+ const detail = result.stderr || result.stdout || 'command failed';
100
+ throw new Error(`tmux command failed: ${tmuxBin} ${args.join(' ')} (${detail.trim()})`);
101
+ }
102
+ return result;
103
+ }
104
+
105
+ export async function tmuxHasSession(sessionName, options = {}) {
106
+ const tmuxBin = options.tmuxBin || DEFAULT_TMUX_BIN;
107
+ const session = sanitizeTmuxSessionName(sessionName);
108
+ await ensureTmuxAvailable({ ...options, tmuxBin });
109
+ const result = await runCommand(tmuxBin, ['has-session', '-t', session], options);
110
+ return result.code === 0;
111
+ }
112
+
113
+ export async function tmuxNewSession(sessionName, options = {}) {
114
+ const session = sanitizeTmuxSessionName(sessionName);
115
+ const args = ['new-session', '-d', '-s', session];
116
+ if (options.windowName) args.push('-n', sanitizeTmuxWindowName(options.windowName));
117
+ if (options.cwd) args.push('-c', options.cwd);
118
+ if (options.command) args.push(options.command);
119
+ return runTmux(args, options);
120
+ }
121
+
122
+ export async function tmuxNewWindow(sessionName, windowName, options = {}) {
123
+ const session = sanitizeTmuxSessionName(sessionName);
124
+ const window = sanitizeTmuxWindowName(windowName);
125
+ const args = ['new-window', '-t', session, '-n', window];
126
+ if (options.cwd) args.push('-c', options.cwd);
127
+ if (options.command) args.push(options.command);
128
+ return runTmux(args, options);
129
+ }
130
+
131
+ export async function tmuxKillSession(sessionName, options = {}) {
132
+ return runTmux(['kill-session', '-t', sanitizeTmuxSessionName(sessionName)], options);
133
+ }
134
+
135
+ export async function tmuxKillWindow(sessionName, windowName, options = {}) {
136
+ const session = sanitizeTmuxSessionName(sessionName);
137
+ const window = sanitizeTmuxWindowName(windowName);
138
+ return runTmux(['kill-window', '-t', `${session}:${window}`], options);
139
+ }
package/src/utils.js CHANGED
@@ -7,6 +7,15 @@ export const APP_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathn
7
7
  export const DEFAULT_REPO = path.resolve(process.env.KANBAN_DEFAULT_REPO || process.cwd());
8
8
  export const RUNS_DIR = path.resolve(process.env.KANBAN_RUNS_DIR || path.join(process.env.HOME || APP_ROOT, '.input-kanban', 'runs'));
9
9
  export const CODEX_BIN = process.env.KANBAN_CODEX_BIN || 'codex';
10
+ export const VALID_RUNNERS = ['headless', 'tmux'];
11
+
12
+ export function normalizeRunner(value = 'headless', source = 'KANBAN_RUNNER') {
13
+ const runner = String(value || '').trim();
14
+ if (VALID_RUNNERS.includes(runner)) return runner;
15
+ throw new Error(`invalid ${source}: ${value}; expected one of: ${VALID_RUNNERS.join(', ')}`);
16
+ }
17
+
18
+ export const RUNNER = normalizeRunner(process.env.KANBAN_RUNNER || 'headless');
10
19
 
11
20
  export async function ensureDir(dir) { await fsp.mkdir(dir, { recursive: true }); }
12
21
  export function nowIso() { return new Date().toISOString(); }