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 +7 -1
- package/PROJECT_GUIDE.md +24 -0
- package/README.en.md +8 -0
- package/README.md +8 -0
- package/bin/input-kanban.js +12 -1
- package/package.json +2 -2
- package/public/index.html +83 -6
- package/src/orchestrator.js +126 -41
- package/src/runners/headlessRunner.js +51 -0
- package/src/runners/index.js +13 -0
- package/src/runners/tmuxRunner.js +170 -0
- package/src/runners/tmuxUtils.js +15 -0
- package/src/server.js +3 -3
- package/src/tmux.js +139 -0
- package/src/utils.js +9 -0
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
|
|
package/bin/input-kanban.js
CHANGED
|
@@ -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.
|
|
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:
|
|
36
|
-
th:nth-child(9), td:nth-child(9) { width:
|
|
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
|
-
|
|
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="
|
|
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 = '';
|
package/src/orchestrator.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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 =
|
|
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' && !
|
|
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' && !
|
|
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(); }
|