input-kanban 0.0.10 → 0.0.13

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
@@ -43,6 +43,7 @@ input-kanban \
43
43
  - `input-kanban serve` starts a lightweight background scheduler that uses the same orchestrator auto-advance path as CLI `submit --auto` / `input-kanban auto <runId>`. It advances planned runs, serial batches, final judge startup, and bounded automatic retries without relying on an open browser tab.
44
44
  - `KANBAN_RUNNER` / `--runner tmux` runs Codex tasks inside tmux windows while keeping scheduling and status tracking in the Node.js orchestrator.
45
45
  - `KANBAN_RUNNER=tmux` is optional. Use it when you want live terminal visibility into planner, worker, and final judge sessions.
46
+ - With `KANBAN_RUNNER=tmux`, stopping and restarting `input-kanban serve` does not interrupt already-running Codex sessions; tmux keeps them alive and the scheduler resumes after restart. Do not assume the same safety for `headless` runner child processes.
46
47
  - tmux mode uses one session per run and one window for planner, each batch, and judge. Batch windows contain an overview pane plus worker panes.
47
48
  - tmux role windows stay open after the Codex command exits. The runner writes `exit_code` before entering the keep-open shell so Node.js status refresh can continue to advance from filesystem state.
48
49
  - The dashboard exposes the run-level `tmux attach-session` copy action after tmux metadata is available. File viewer panels do not repeat tmux terminal details.
package/README.en.md CHANGED
@@ -117,6 +117,8 @@ Defaults:
117
117
 
118
118
  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. After a tmux role command finishes, it writes `exit_code` first and then keeps the window open for inspection; the user closes the window manually from tmux.
119
119
 
120
+ If you are using `--runner tmux`, stopping and restarting `input-kanban serve` does not interrupt Codex sessions that are already running; the tmux session keeps going, and the scheduler resumes orchestration after the server comes back. With the `headless` runner, do not assume that restarting the service is safe for in-flight child processes.
121
+
120
122
  tmux mode is optional and intended for live terminal viewing of each Codex role. `codex exec` is currently non-interactive and does not normally show manual approval prompts; if you select `danger-full-access` when creating a run, you explicitly relax the worker sandbox and should only do so in a controlled test workspace.
121
123
 
122
124
  After run-level tmux metadata is available, the dashboard shows `Copy tmux attach command`. The file viewer no longer repeats tmux terminal details; use the run detail header to copy the attach command and inspect the tmux session.
package/README.md CHANGED
@@ -117,6 +117,8 @@ input-kanban --open
117
117
 
118
118
  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 文件驱动。tmux 角色命令完成后会先写入 `exit_code`,再保留 window,方便查看现场;需要关闭时由用户在 tmux 里手动退出。
119
119
 
120
+ 如果当前使用的是 `--runner tmux`,中断并重新启动 `input-kanban serve` 不会中断正在执行中的 Codex 会话;tmux session 会继续运行,服务重启后 scheduler 会重新接管后续推进。若使用 `headless` runner,则不应假设服务重启对正在运行的子进程是安全的。
121
+
120
122
  tmux 模式是可选能力,主要用于在终端里实时查看每个 Codex 角色的执行过程。`codex exec` 当前属于非交互模式,默认不会弹出人工 approval;如果创建任务时选择 `danger-full-access`,表示显式放开 worker sandbox 限制,应只在受控测试工作区中使用。
121
123
 
122
124
  看板会在 run 生成 tmux 元数据后显示 `复制tmux attach指令`。文件查看区域不再重复展示 tmux 终端信息;如需查看现场,请从批次详情顶部复制 attach 指令进入 tmux session。
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Release Notes
2
2
 
3
+ ## v0.0.13
4
+
5
+ ### Highlights
6
+
7
+ - Harden Codex launching on Windows by resolving npm `codex.cmd` shims and explicit JavaScript launchers through a shared `resolveCodexLauncher()` adapter.
8
+ - Use the shared Codex launcher path from the app-server client, headless runner, tmux runner, and Web footer Codex detection.
9
+ - Add `/api/codex` and a compact Web footer Codex status that shows the backend-visible CLI version, for example `codex-cli 0.139.0`, without relying on npm registry `latest` by default.
10
+ - Improve Web action feedback by turning run action buttons into lightweight state indicators: pending actions disable immediately, active backend states pulse subtly, and retry/done states use concise labels.
11
+ - Keep `batch_blocked` runs discoverable via `input-kanban runs --active`, so agent/CLI auto loops can continue recoverable work instead of hiding blocked batches.
12
+ - Make retry preparation atomic when selected tasks include a live process: no worker attempt is archived until all selected tasks are confirmed safe to retry.
13
+ - Add Windows-focused regression coverage for Codex launcher resolution, app-server spawn failures, headless spawn failures, and tmux launcher quoting.
14
+
15
+ ### Verification
16
+
17
+ - `npm run check` passed locally with 76 tests.
18
+ - `npm run check` passed on the remote Windows validation host `zhangxing_win` with 76 tests after installing `@openai/codex` CLI.
19
+ - Windows backend Codex detection returned `codex-cli 0.139.0` through `detectCodexInfo()`.
20
+
21
+ ## v0.0.12
22
+
23
+ ### Highlights
24
+
25
+ - Fix Windows startup/static serving by resolving `APP_ROOT` with `fileURLToPath(import.meta.url)` instead of URL pathname parsing.
26
+ - Add a regression test for serving `/` and `/api/health` from the HTTP server.
27
+ - Add task-detail hover guidance for sandbox and network capability issues, clarifying that sandbox-denied errors are not necessarily task failures.
28
+ - Remember the last selected Web worker sandbox mode in browser local storage, so users do not need to reselect `danger-full-access` or other modes each time.
29
+ - Auto-scroll the execution process view to the end when opened, while preserving the user's scroll position during refresh if they have scrolled upward.
30
+
31
+ ### Verification
32
+
33
+ - `npm run check` passed with 64 tests.
34
+ - `npm pack --dry-run` passed before release prep.
35
+
36
+ ## v0.0.11
37
+
38
+ ### Highlights
39
+
40
+ - Simplify the Web sidebar header: show `任务批次` as the section title with a compact `新建` action on the right, removing repeated wording.
41
+ - Document safe `input-kanban serve` restarts for `tmux` runner: already-running Codex sessions in tmux continue while the server is down, and the scheduler resumes after restart.
42
+ - Clarify that `headless` runner does not provide the same safe-restart guarantee for in-flight child processes.
43
+
44
+ ### Verification
45
+
46
+ - `npm run check` passed with 63 tests.
47
+ - `npm pack --dry-run` passed before release prep.
48
+
3
49
  ## v0.0.10
4
50
 
5
51
  ### Highlights
@@ -509,7 +509,7 @@ function isFailureTerminal(state) {
509
509
  function isActiveRunSummary(run) {
510
510
  if (!run) return false;
511
511
  if (Number(run.running) > 0) return true;
512
- return !['judged', 'judge_failed', 'batch_blocked', 'plan_failed', 'plan_empty', 'stopped'].includes(run.status);
512
+ return !['judged', 'judge_failed', 'plan_failed', 'plan_empty', 'stopped'].includes(run.status);
513
513
  }
514
514
 
515
515
  function hasRecoverableUnknownTask(state) {
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "input-kanban",
3
- "version": "0.0.10",
3
+ "version": "0.0.13",
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 bin/input-kanban-format-events.js && node --check bin/input-kanban-timestamp-events.js && node --check bin/input-kanban-tmux-overview.js && node --check src/server.js && node --check src/scheduler.js && node --check src/orchestrator.js && node --check src/appServerClient.js && node --check src/utils.js && node --check src/eventFormatter.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"
10
+ "check": "node --check bin/input-kanban.js && node --check bin/input-kanban-format-events.js && node --check bin/input-kanban-timestamp-events.js && node --check bin/input-kanban-tmux-overview.js && node --check src/server.js && node --check src/scheduler.js && node --check src/orchestrator.js && node --check src/appServerClient.js && node --check src/codexLauncher.js && node --check src/utils.js && node --check src/eventFormatter.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 --check test/app-server-client.test.js && node --check test/app-server-client-stop.test.js && node --test"
11
11
  },
12
12
  "description": "A local Codex orchestration kanban dashboard",
13
13
  "license": "MIT",
package/public/index.html CHANGED
@@ -26,10 +26,19 @@
26
26
  textarea:focus, input:focus, select:focus { border-color: #60a5fa; box-shadow: 0 0 0 2px rgba(37,99,235,.25); }
27
27
  textarea { min-height: 240px; }
28
28
  label { display: block; margin-top: 10px; color: #cbd5e1; font-weight: 700; }
29
- button { background: var(--blue); color: white; border: 0; border-radius: 9px; padding: 8px 11px; margin: 4px 4px 4px 0; cursor: pointer; font-weight: 700; }
30
- button:hover { filter: brightness(1.08); }
29
+ button { background: var(--blue); color: white; border: 0; border-radius: 9px; padding: 8px 11px; margin: 4px 4px 4px 0; cursor: pointer; font-weight: 700; transition: filter .15s, opacity .15s, transform .15s, box-shadow .15s; }
30
+ button:hover:not(:disabled) { filter: brightness(1.08); }
31
+ button:disabled { cursor: default; opacity: .72; }
31
32
  button.secondary { background: var(--gray); }
32
33
  button.danger { background: #dc2626; }
34
+ button.state-pending { position: relative; opacity: .82; }
35
+ button.state-pending::after { content: ''; display: inline-block; width: 5px; height: 5px; margin-left: 7px; border-radius: 999px; background: currentColor; vertical-align: middle; animation: action-dot 1s ease-in-out infinite; }
36
+ button.state-active { animation: action-pulse 1.6s ease-in-out infinite; box-shadow: 0 0 0 1px rgba(96,165,250,.18), 0 0 18px rgba(37,99,235,.16); }
37
+ button.state-done { opacity: .58; filter: saturate(.75); }
38
+ button.state-retry { background: var(--orange); }
39
+ @keyframes action-pulse { 0%, 100% { filter: brightness(1); transform: translateY(0); } 50% { filter: brightness(1.12); transform: translateY(-1px); } }
40
+ @keyframes action-dot { 0%, 100% { opacity: .35; transform: scale(.75); } 50% { opacity: 1; transform: scale(1.15); } }
41
+ @media (prefers-reduced-motion: reduce) { button, button.state-active, button.state-pending::after, .refresh-pulse-chip.pulse .refresh-pulse-dot { animation: none !important; transition: none !important; } }
33
42
  table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
34
43
  th, td { border-bottom: 1px solid var(--line); padding: 9px 8px; text-align: left; vertical-align: top; }
35
44
  th:nth-child(1), td:nth-child(1) { width: 34%; }
@@ -60,12 +69,14 @@
60
69
  .muted { color: var(--muted); font-size: 12px; }
61
70
  .hidden { display: none; }
62
71
  .toolbar { margin: 8px 0 12px; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
72
+ .section-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 10px 0 8px; }
73
+ .section-header h2 { margin: 0; }
63
74
  .workspace-filter-panel { margin: 4px 0 10px; display: flex; align-items: center; gap: 6px; }
64
75
  .workspace-filter-select { flex: 1 1 auto; min-width: 0; font-size: 12px; padding: 7px 9px; color: #cbd5e1; }
65
76
  .task-text { max-height: 180px; color: #cbd5e1; }
66
77
  .empty { color: var(--muted); padding: 18px 0; }
67
- .runs-load-icon { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 10px; cursor: help; opacity: .72; }
68
- .runs-load-icon:hover { opacity: 1; color: #cbd5e1; border-color: var(--line-strong); }
78
+ .info-icon, .runs-load-icon { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 10px; cursor: help; opacity: .72; }
79
+ .info-icon:hover, .runs-load-icon:hover { opacity: 1; color: #cbd5e1; border-color: var(--line-strong); }
69
80
  .run-list { display: flex; flex-direction: column; gap: 10px; flex: 1; min-height: 0; overflow-y: auto; padding-right: 4px; }
70
81
  .run-list-more { width: 100%; margin-top: 4px; }
71
82
  .run-card { border: 1px solid var(--line); border-radius: 12px; padding: 12px; background: var(--panel-2); cursor: pointer; transition: border-color .15s, transform .15s, background .15s; }
@@ -115,6 +126,9 @@
115
126
  .execution-summary + pre { border-top-left-radius: 0; border-top-right-radius: 0; }
116
127
  .notice { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #1f2937; color: #cbd5e1; }
117
128
  .notice.warning { border-color: #92400e; background: rgba(180,83,9,.18); }
129
+ .codex-status { margin-top: 4px; color: var(--muted); font-size: 12px; }
130
+ .codex-status.warning { color: #fbbf24; }
131
+ .codex-status code { color: #bfdbfe; }
118
132
  .file-content-wrap { position: relative; }
119
133
  .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); }
120
134
  .file-content-wrap:hover .floating-copy-btn:not(.hidden), .floating-copy-btn:focus { opacity: 1; pointer-events: auto; }
@@ -131,10 +145,10 @@
131
145
  <main>
132
146
  <div class="sidebar">
133
147
  <section>
134
- <div class="toolbar">
135
- <button onclick="showCreateForm()">新建任务批次</button>
148
+ <div class="section-header">
149
+ <h2>任务批次</h2>
150
+ <button class="secondary" onclick="showCreateForm()">新建</button>
136
151
  </div>
137
- <h2>任务批次</h2>
138
152
  <div class="workspace-filter-panel">
139
153
  <select id="workspaceFilterSelect" class="workspace-filter-select" onchange="setWorkspaceFilter(this.value)" title="未筛选工作区"></select>
140
154
  <span id="runsLoadHint" class="runs-load-icon" title="批次列表尚未加载" aria-label="批次列表尚未加载">ⓘ</span>
@@ -154,7 +168,7 @@
154
168
  <option value="read-only">read-only(只读)</option>
155
169
  <option value="danger-full-access">danger-full-access(高风险,跳过沙箱限制)</option>
156
170
  </select>
157
- <div class="muted">仅影响 worker;任务拆分和汇总验收仍保持 read-only。</div>
171
+ <div class="muted">仅影响 worker;任务拆分和汇总验收仍保持 read-only。若执行过程提示 Permission denied / sandbox denied,这通常不是任务本身失败,而是当前沙箱能力不足;在可信工作区可改用 danger-full-access。DNS / 网络失败则通常需要检查代理、VPN 或本地 evidence。</div>
158
172
  <label>任务说明</label><textarea id="taskText" placeholder="粘贴任务说明"></textarea>
159
173
  <div class="toolbar">
160
174
  <button onclick="createRun()">创建批次</button>
@@ -168,14 +182,7 @@
168
182
  <div id="selected" class="muted">未选择任务批次</div>
169
183
  <div id="autoRefreshHint" class="muted hidden">自动刷新:未启动</div>
170
184
  <div id="runNotice" class="notice warning hidden"></div>
171
- <div class="toolbar">
172
- <button onclick="planRun()">拆分任务</button>
173
- <button onclick="dispatchRun()">派发执行</button>
174
- <button onclick="judgeRun()">汇总验收</button>
175
- <button class="secondary" onclick="refreshSelected()">刷新状态</button>
176
- <button class="secondary" onclick="stopSelectedRun()">停止</button>
177
- <button class="danger" onclick="archiveSelectedRun()">归档</button>
178
- </div>
185
+ <div id="actionToolbar" class="toolbar"></div>
179
186
  </div>
180
187
  <h3>任务说明</h3>
181
188
  <pre id="taskDescription" class="task-text">未选择任务批次</pre>
@@ -183,7 +190,10 @@
183
190
  </section>
184
191
 
185
192
  <section id="filePanel" class="log-panel">
186
- <h2>任务详情</h2>
193
+ <div class="section-header">
194
+ <h2>任务详情</h2>
195
+ <span class="info-icon" title="若执行过程提示 Permission denied / sandbox denied,这通常不是任务本身失败,而是当前 worker 沙箱能力不足;在可信工作区可改用 danger-full-access。DNS / 网络失败则通常需要检查代理、VPN 或本地 evidence。" aria-label="任务详情权限与网络提示">ⓘ</span>
196
+ </div>
187
197
  <div id="fileTitle" class="muted">点击任务后查看详情</div>
188
198
  <div id="fileTabs" class="toolbar file-tabs"></div>
189
199
  <div id="executionSummary" class="execution-summary hidden"></div>
@@ -194,7 +204,7 @@
194
204
  </section>
195
205
  </div>
196
206
  </main>
197
- <footer id="pageFooter" class="page-footer">版本:-</footer>
207
+ <footer class="page-footer"><div id="pageFooter">版本:-</div><div id="codexStatus" class="codex-status hidden"></div></footer>
198
208
  <div id="manualCompleteModal" class="modal-backdrop hidden">
199
209
  <div class="modal-card">
200
210
  <h2>手动标记成功</h2>
@@ -214,6 +224,7 @@ let selectedTask = null;
214
224
  let selectedFileName = null;
215
225
  let manualCompleteTaskId = null;
216
226
  let pendingArchiveRunId = null;
227
+ let pendingAction = null;
217
228
  let currentState = null;
218
229
  let lastAutoRefreshAt = null;
219
230
  let runListVisibleCount = 10;
@@ -223,6 +234,8 @@ const statusByRunId = new Map();
223
234
  const AUTO_REFRESH_MS = 3000;
224
235
  const RUN_LIST_PAGE_SIZE = 10;
225
236
  const WORKSPACE_FILTER_ALL = '';
237
+ const WORKER_SANDBOX_STORAGE_KEY = 'input-kanban.workerSandbox';
238
+ const VALID_WORKER_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
226
239
  let currentWorkspacePath = '';
227
240
  let selectedWorkspaceFilter = localStorage.getItem('input-kanban.workspaceFilter') || WORKSPACE_FILTER_ALL;
228
241
 
@@ -349,6 +362,25 @@ async function loadHealth() {
349
362
  renderWorkspaceFilterOptions();
350
363
  updateWorkspaceFilterTitle();
351
364
  }
365
+ async function loadCodexStatus() {
366
+ const el = document.getElementById('codexStatus');
367
+ if (!el) return;
368
+ try {
369
+ const data = await api('/api/codex');
370
+ const codex = data.codex || {};
371
+ el.classList.remove('hidden', 'warning');
372
+ if (!codex.installed) {
373
+ el.classList.add('warning');
374
+ el.innerHTML = `Codex 未安装|<code>${esc(codex.installCommand || 'npm install -g @openai/codex')}</code>`;
375
+ } else {
376
+ el.innerHTML = `<code>${esc(codex.versionText || codex.installedVersion || 'codex')}</code>`;
377
+ }
378
+ } catch (error) {
379
+ el.classList.remove('hidden');
380
+ el.classList.add('warning');
381
+ el.innerHTML = `Codex:检测失败|${esc(errorDetail(error))}`;
382
+ }
383
+ }
352
384
  function showCreateForm() {
353
385
  selectedRun = null; selectedTask = null; selectedFileName = null; currentState = null;
354
386
  clearFileView();
@@ -361,7 +393,20 @@ function hideCreateForm() {
361
393
  document.getElementById('detailPanel').classList.remove('hidden');
362
394
  document.getElementById('filePanel').classList.remove('hidden');
363
395
  }
396
+ function saveWorkerSandboxPreference() {
397
+ const select = document.getElementById('workerSandbox');
398
+ const value = select?.value || '';
399
+ if (VALID_WORKER_SANDBOXES.has(value)) localStorage.setItem(WORKER_SANDBOX_STORAGE_KEY, value);
400
+ }
401
+ function initializeWorkerSandboxPreference() {
402
+ const select = document.getElementById('workerSandbox');
403
+ if (!select) return;
404
+ const saved = localStorage.getItem(WORKER_SANDBOX_STORAGE_KEY);
405
+ if (VALID_WORKER_SANDBOXES.has(saved)) select.value = saved;
406
+ select.addEventListener('change', saveWorkerSandboxPreference);
407
+ }
364
408
  async function createRun() {
409
+ saveWorkerSandboxPreference();
365
410
  const body = { label: label.value, workspace: repo.value, repo: repo.value, maxParallel: maxParallel.value, workerSandbox: workerSandbox.value, taskText: taskText.value };
366
411
  const r = await api('/api/runs', { method: 'POST', body: JSON.stringify(body) });
367
412
  selectedRun = r.runId; selectedTask = null; selectedFileName = null;
@@ -466,6 +511,7 @@ async function refreshSelected({auto=false} = {}) {
466
511
  statusByRunId.set(selectedRun, currentState);
467
512
  if (auto) lastAutoRefreshAt = new Date();
468
513
  document.getElementById('selected').innerHTML = renderSelectedHeader();
514
+ renderActionToolbar();
469
515
  if (auto) requestAnimationFrame(triggerRefreshPulse);
470
516
  updateAutoRefreshHint();
471
517
  updateRunNotice();
@@ -511,6 +557,97 @@ function refreshPulseChip() {
511
557
  const last = lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发';
512
558
  return `<span id="refreshPulse" class="refresh-pulse-chip" title="自动刷新:每 ${AUTO_REFRESH_MS / 1000} 秒;上次 ${esc(last)}"><span class="refresh-pulse-dot"></span></span>`;
513
559
  }
560
+ function actionButton({ key, label, onclick, variant = '', state = '', disabled = false, title = '' }) {
561
+ const classes = [variant, state ? `state-${state}` : ''].filter(Boolean).join(' ');
562
+ return `<button${classes ? ` class="${classes}"` : ''}${disabled ? ' disabled' : ''}${title ? ` title="${esc(title)}"` : ''} onclick="${onclick}">${esc(label)}</button>`;
563
+ }
564
+ function runActionState(key) {
565
+ if (!selectedRun || !currentState) return { label: '-', disabled: true, state: '' };
566
+ if (pendingAction === key) {
567
+ return {
568
+ plan: { label: '拆分中…', disabled: true, state: 'pending' },
569
+ dispatch: { label: '启动中…', disabled: true, state: 'pending' },
570
+ judge: { label: '验收中…', disabled: true, state: 'pending' },
571
+ stop: { label: '停止中…', disabled: true, state: 'pending' },
572
+ archive: { label: '归档中…', disabled: true, state: 'pending' }
573
+ }[key];
574
+ }
575
+ const status = currentState.status;
576
+ const anyWorkerRunning = (currentState.tasks || []).some(t => t.status === 'running');
577
+ if (key === 'plan') {
578
+ if (status === 'planning' || currentState.planner?.status === 'running') return { label: '拆分中', disabled: true, state: 'active' };
579
+ if (status === 'planned' || status === 'running' || status === 'workers_completed' || status === 'batches_completed' || status === 'judging' || status === 'judged') return { label: '已拆分', disabled: true, state: 'done' };
580
+ if (status === 'plan_failed' || status === 'plan_empty') return { label: '重试拆分', disabled: false, state: 'retry' };
581
+ return { label: '拆分', disabled: false, state: '' };
582
+ }
583
+ if (key === 'dispatch') {
584
+ if (status === 'running' || anyWorkerRunning) return { label: '执行中', disabled: true, state: 'active' };
585
+ if (status === 'planned' || status === 'batch_blocked') return { label: '执行', disabled: false, state: status === 'batch_blocked' ? 'retry' : '' };
586
+ if (status === 'workers_failed') return { label: '重试执行', disabled: false, state: 'retry' };
587
+ if (['workers_completed','batches_completed','judging','judged'].includes(status)) return { label: '已完成', disabled: true, state: 'done' };
588
+ return { label: '执行', disabled: true, state: 'done' };
589
+ }
590
+ if (key === 'judge') {
591
+ if (status === 'judging' || currentState.judge?.status === 'running') return { label: '验收中', disabled: true, state: 'active' };
592
+ if (status === 'judged') return { label: '已验收', disabled: true, state: 'done' };
593
+ if (status === 'judge_failed') return { label: '重试验收', disabled: false, state: 'retry' };
594
+ if (status === 'batches_completed' || status === 'workers_completed') return { label: '验收', disabled: false, state: '' };
595
+ return { label: '验收', disabled: true, state: 'done' };
596
+ }
597
+ if (key === 'stop') {
598
+ if (status === 'stopped') return { label: '已停止', disabled: true, state: 'done' };
599
+ const stoppable = ['planning','running','judging','planned','batch_blocked'].includes(status) || anyWorkerRunning;
600
+ return { label: '停止', disabled: !stoppable, state: stoppable ? '' : 'done' };
601
+ }
602
+ if (key === 'archive') return { label: '归档', disabled: false, state: '' };
603
+ return { label: key, disabled: false, state: '' };
604
+ }
605
+ function renderActionToolbar() {
606
+ const el = document.getElementById('actionToolbar');
607
+ if (!el) return;
608
+ if (!selectedRun || !currentState) {
609
+ el.innerHTML = actionButton({ key: 'refresh', label: '刷新状态', onclick: 'refreshSelected()', variant: 'secondary', disabled: true });
610
+ return;
611
+ }
612
+ const plan = runActionState('plan');
613
+ const dispatch = runActionState('dispatch');
614
+ const judge = runActionState('judge');
615
+ const stop = runActionState('stop');
616
+ const archive = runActionState('archive');
617
+ el.innerHTML = [
618
+ actionButton({ key: 'plan', label: plan.label, onclick: 'planRun()', state: plan.state, disabled: plan.disabled, title: '拆分任务' }),
619
+ actionButton({ key: 'dispatch', label: dispatch.label, onclick: 'dispatchRun()', state: dispatch.state, disabled: dispatch.disabled, title: '派发执行' }),
620
+ actionButton({ key: 'judge', label: judge.label, onclick: 'judgeRun()', state: judge.state, disabled: judge.disabled, title: '汇总验收' }),
621
+ actionButton({ key: 'refresh', label: '刷新', onclick: 'refreshSelected()', variant: 'secondary' }),
622
+ actionButton({ key: 'stop', label: stop.label, onclick: 'stopSelectedRun()', variant: 'secondary', state: stop.state, disabled: stop.disabled, title: '停止当前批次' }),
623
+ actionButton({ key: 'archive', label: archive.label, onclick: 'archiveSelectedRun()', variant: 'danger', state: archive.state, disabled: archive.disabled, title: '归档当前批次' })
624
+ ].join('');
625
+ }
626
+ async function runAction(fn) {
627
+ try { await fn(); }
628
+ catch (error) {
629
+ console.error('操作失败', error);
630
+ alert(userFacingErrorMessage(error));
631
+ }
632
+ }
633
+ function renderPendingActionState() {
634
+ renderActionToolbar();
635
+ if (currentState) renderTasks();
636
+ }
637
+ async function runActionWithPending(actionKey, fn) {
638
+ if (pendingAction === actionKey) return;
639
+ pendingAction = actionKey;
640
+ renderPendingActionState();
641
+ try {
642
+ await fn();
643
+ } catch (error) {
644
+ console.error('操作失败', error);
645
+ alert(userFacingErrorMessage(error));
646
+ } finally {
647
+ pendingAction = null;
648
+ renderPendingActionState();
649
+ }
650
+ }
514
651
  function triggerRefreshPulse() {
515
652
  const el = document.getElementById('refreshPulse');
516
653
  if (!el) return;
@@ -559,7 +696,8 @@ function taskActionCell(id, t) {
559
696
  if (!t || id === 'planner' || id === 'judge') return '-';
560
697
  if (t.manualCompletion) return '<span class="muted">已人工确认</span>';
561
698
  if (!['unknown', 'failed'].includes(t.status)) return '-';
562
- return `<button class="danger" onclick="markTaskCompleted(event, '${id}')">手动标记成功</button>`;
699
+ const pending = pendingAction === `manual:${id}`;
700
+ return `<button class="danger ${pending ? 'state-pending' : ''}"${pending ? ' disabled' : ''} onclick="markTaskCompleted(event, '${id}')">${pending ? '标记中…' : '手动标记成功'}</button>`;
563
701
  }
564
702
  function shortSessionId(thread) {
565
703
  const text = String(thread || '');
@@ -656,6 +794,7 @@ async function loadFile(name, { preserveScroll = false } = {}) {
656
794
  selectedFileName = name;
657
795
  const pre = document.getElementById('fileContent');
658
796
  const previousScrollTop = pre.scrollTop;
797
+ const wasAtBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 24;
659
798
  let text;
660
799
  const selected = taskById(selectedTask);
661
800
  if (name === 'result.json' && selected?.manualCompletion?.hasManualResult) {
@@ -665,7 +804,8 @@ async function loadFile(name, { preserveScroll = false } = {}) {
665
804
  text = await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=${encodeURIComponent(name)}`);
666
805
  }
667
806
  pre.textContent = text;
668
- if (preserveScroll) pre.scrollTop = previousScrollTop;
807
+ if (name === 'events.pretty' && (!preserveScroll || wasAtBottom)) pre.scrollTop = pre.scrollHeight;
808
+ else if (preserveScroll) pre.scrollTop = previousScrollTop;
669
809
  else pre.scrollTop = 0;
670
810
  if (name === 'events.pretty') await renderExecutionSummary();
671
811
  else hideExecutionSummary();
@@ -883,25 +1023,20 @@ async function renameRunLabel(event, runId = selectedRun) {
883
1023
  else await refreshRuns();
884
1024
  });
885
1025
  }
886
- async function planRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/plan`, {method:'POST'}); await refreshSelected(); }); }
887
- async function dispatchRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/dispatch`, {method:'POST'}); await refreshSelected(); }); }
888
- async function judgeRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/judge`, {method:'POST'}); await refreshSelected(); }); }
889
- async function runAction(fn) {
890
- try { await fn(); }
891
- catch (error) {
892
- console.error('操作失败', error);
893
- alert(userFacingErrorMessage(error));
894
- }
895
- }
1026
+ async function planRun() { if (selectedRun) await runActionWithPending('plan', async () => { await api(`/api/runs/${selectedRun}/plan`, {method:'POST'}); await refreshSelected(); }); }
1027
+ async function dispatchRun() { if (selectedRun) await runActionWithPending('dispatch', async () => { await api(`/api/runs/${selectedRun}/dispatch`, {method:'POST'}); await refreshSelected(); }); }
1028
+ async function judgeRun() { if (selectedRun) await runActionWithPending('judge', async () => { await api(`/api/runs/${selectedRun}/judge`, {method:'POST'}); await refreshSelected(); }); }
896
1029
  async function stopSelectedRun() {
897
1030
  if (!selectedRun) return;
898
1031
  const ok = confirm('确认停止当前任务批次?\n\n停止会终止仍在运行的 codex exec 子进程,并冻结后续调度。');
899
1032
  if (!ok) return;
900
- await api(`/api/runs/${selectedRun}/stop`, {
901
- method: 'POST',
902
- body: JSON.stringify({ reason: 'stopped from dashboard' })
1033
+ await runActionWithPending('stop', async () => {
1034
+ await api(`/api/runs/${selectedRun}/stop`, {
1035
+ method: 'POST',
1036
+ body: JSON.stringify({ reason: 'stopped from dashboard' })
1037
+ });
1038
+ await refreshSelected();
903
1039
  });
904
- await refreshSelected();
905
1040
  }
906
1041
  function clearArchiveConfirm(runId) {
907
1042
  if (pendingArchiveRunId !== runId) return;
@@ -924,7 +1059,7 @@ async function archiveRunById(runId, { confirmFirst = true } = {}) {
924
1059
  const ok = confirm('确认归档当前任务批次?\n\n归档后会从默认任务批次列表隐藏。若仍有任务运行,请先停止。');
925
1060
  if (!ok) return;
926
1061
  }
927
- await runAction(async () => {
1062
+ await runActionWithPending('archive', async () => {
928
1063
  await api(`/api/runs/${runId}/archive`, {
929
1064
  method: 'POST',
930
1065
  body: JSON.stringify({ reason: 'archived from dashboard' })
@@ -964,16 +1099,21 @@ async function submitManualComplete() {
964
1099
  if (!selectedRun || !taskId) return;
965
1100
  const resultText = document.getElementById('manualCompleteResult').value.trim();
966
1101
  if (!resultText) { alert('请粘贴人工成功执行结果。'); return; }
967
- await api(`/api/runs/${selectedRun}/tasks/${taskId}/mark-completed`, {
968
- method: 'POST',
969
- body: JSON.stringify({ reason: 'manual success confirmed from dashboard', resultText })
1102
+ await runActionWithPending(`manual:${taskId}`, async () => {
1103
+ await api(`/api/runs/${selectedRun}/tasks/${taskId}/mark-completed`, {
1104
+ method: 'POST',
1105
+ body: JSON.stringify({ reason: 'manual success confirmed from dashboard', resultText })
1106
+ });
1107
+ closeManualCompleteModal();
1108
+ selectedTask = taskId;
1109
+ await refreshSelected();
1110
+ await loadFile('result.json');
970
1111
  });
971
- closeManualCompleteModal();
972
- selectedTask = taskId;
973
- await refreshSelected();
974
- await loadFile('result.json');
975
1112
  }
976
1113
 
1114
+ initializeWorkerSandboxPreference();
1115
+ renderActionToolbar();
1116
+ loadCodexStatus().catch(console.error);
977
1117
  loadHealth().then(refreshRuns);
978
1118
  setInterval(() => { if (selectedRun) refreshSelected({auto:true}).catch(console.error); else refreshRuns().catch(console.error); }, AUTO_REFRESH_MS);
979
1119
  </script>
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import readline from 'node:readline';
3
3
  import { CODEX_BIN } from './utils.js';
4
+ import { resolveCodexLauncher } from './codexLauncher.js';
4
5
 
5
6
  export class CodexAppServerClient {
6
7
  constructor() {
@@ -9,20 +10,49 @@ export class CodexAppServerClient {
9
10
  this.pending = new Map();
10
11
  this.initialized = false;
11
12
  this.stderrTail = [];
13
+ this.rl = null;
12
14
  }
13
15
 
14
16
  start() {
15
- if (this.proc) return;
16
- this.proc = spawn(CODEX_BIN, ['app-server', '--stdio'], { stdio: ['pipe', 'pipe', 'pipe'] });
17
+ if (this.proc) return this.proc;
18
+ const { command, argsPrefix } = resolveCodexLauncher(CODEX_BIN);
19
+ this.proc = spawn(command, [...argsPrefix, 'app-server', '--stdio'], { stdio: ['pipe', 'pipe', 'pipe'] });
20
+ const proc = this.proc;
17
21
  const rl = readline.createInterface({ input: this.proc.stdout });
22
+ this.rl = rl;
18
23
  rl.on('line', line => this.#handleLine(line));
19
- this.proc.stderr.on('data', d => this.#pushStderr(String(d)));
20
- this.proc.on('exit', code => {
21
- for (const { reject } of this.pending.values()) reject(new Error(`app-server exited: ${code}`));
22
- this.pending.clear();
24
+ proc.stderr.on('data', d => this.#pushStderr(String(d)));
25
+ proc.on('error', error => {
26
+ this.#rejectPendingFor(proc, error);
27
+ this.#clearProcess(proc, rl);
28
+ });
29
+ proc.on('exit', code => {
30
+ this.#rejectPendingFor(proc, new Error(`app-server exited: ${code}`));
31
+ this.#clearProcess(proc, rl);
32
+ });
33
+ return proc;
34
+ }
35
+
36
+ #rejectPendingFor(proc, error) {
37
+ for (const [id, pending] of this.pending.entries()) {
38
+ if (pending.proc !== proc) continue;
39
+ this.pending.delete(id);
40
+ pending.reject(error);
41
+ }
42
+ }
43
+
44
+ #rejectAllPending(error) {
45
+ for (const { reject } of this.pending.values()) reject(error);
46
+ this.pending.clear();
47
+ }
48
+
49
+ #clearProcess(proc = this.proc, rl = this.rl) {
50
+ rl?.close();
51
+ if (this.rl === rl) this.rl = null;
52
+ if (this.proc === proc) {
23
53
  this.proc = null;
24
54
  this.initialized = false;
25
- });
55
+ }
26
56
  }
27
57
 
28
58
  #pushStderr(s) {
@@ -42,17 +72,36 @@ export class CodexAppServerClient {
42
72
  }
43
73
 
44
74
  async request(method, params = null, timeoutMs = 15000) {
45
- this.start();
75
+ const proc = this.start();
46
76
  const id = this.nextId++;
47
77
  const msg = { id, method };
48
78
  if (params !== null) msg.params = params;
49
79
  return await new Promise((resolve, reject) => {
50
- const timer = setTimeout(() => {
80
+ let timer;
81
+ let pending;
82
+ const fail = error => {
83
+ if (this.pending.get(id) !== pending) return;
51
84
  this.pending.delete(id);
52
- reject(new Error(`app-server request timeout: ${method}`));
53
- }, timeoutMs);
54
- this.pending.set(id, { resolve: v => { clearTimeout(timer); resolve(v); }, reject: e => { clearTimeout(timer); reject(e); } });
55
- this.proc.stdin.write(JSON.stringify(msg) + '\n');
85
+ pending.reject(error);
86
+ };
87
+ pending = {
88
+ proc,
89
+ resolve: v => { clearTimeout(timer); resolve(v); },
90
+ reject: e => { clearTimeout(timer); reject(e); }
91
+ };
92
+ timer = setTimeout(() => fail(new Error(`app-server request timeout: ${method}`)), timeoutMs);
93
+ this.pending.set(id, pending);
94
+ if (this.proc !== proc || !proc?.stdin?.writable || proc.stdin.destroyed) {
95
+ fail(new Error(`app-server unavailable: ${method}`));
96
+ return;
97
+ }
98
+ try {
99
+ proc.stdin.write(JSON.stringify(msg) + '\n', error => {
100
+ if (error) fail(error);
101
+ });
102
+ } catch (error) {
103
+ fail(error);
104
+ }
56
105
  });
57
106
  }
58
107
 
@@ -79,8 +128,11 @@ export class CodexAppServerClient {
79
128
 
80
129
  stop() {
81
130
  if (!this.proc) return;
82
- this.proc.kill('TERM');
83
- this.proc = null;
131
+ const proc = this.proc;
132
+ const rl = this.rl;
133
+ this.proc.kill();
134
+ this.#rejectAllPending(new Error('app-server stopped'));
135
+ this.#clearProcess(proc, rl);
84
136
  }
85
137
  }
86
138
 
@@ -0,0 +1,88 @@
1
+ import fs from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+
5
+ const MAX_SHIM_BYTES = 64 * 1024;
6
+ const WHERE_TIMEOUT_MS = 5000;
7
+
8
+ function existingPath(filePath) {
9
+ return fs.existsSync(filePath) ? filePath : null;
10
+ }
11
+
12
+ function codexJsCandidatesFromShim(shimPath) {
13
+ const dir = path.dirname(shimPath);
14
+ return [
15
+ path.join(dir, 'node_modules', '@openai', 'codex', 'bin', 'codex.js'),
16
+ path.join(dir, '..', '@openai', 'codex', 'bin', 'codex.js')
17
+ ];
18
+ }
19
+
20
+ function codexJsFromShim(shimPath) {
21
+ for (const candidate of codexJsCandidatesFromShim(shimPath)) {
22
+ const found = existingPath(candidate);
23
+ if (found) return found;
24
+ }
25
+ return null;
26
+ }
27
+
28
+ function readShimTarget(filePath) {
29
+ try {
30
+ if (/codex\.js$/i.test(filePath)) return filePath;
31
+ const stat = fs.statSync(filePath);
32
+ if (!stat.isFile() || stat.size > MAX_SHIM_BYTES) return null;
33
+ const text = fs.readFileSync(filePath, 'utf8');
34
+ const shimJs = codexJsFromShim(filePath);
35
+ if (shimJs && /(?:@openai[\\/]+codex|node_modules[\\/]+@openai[\\/]+codex)[\\/]+bin[\\/]+codex\.js/i.test(text)) return shimJs;
36
+ return null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function hasPathSeparator(value) {
43
+ return path.isAbsolute(value) || value.includes(path.sep) || value.includes('/') || value.includes('\\');
44
+ }
45
+
46
+ function resolvePathCandidate(value) {
47
+ if (/\.(?:c?js|mjs)$/i.test(value)) return { command: process.execPath, argsPrefix: [value] };
48
+ const shimJs = readShimTarget(value);
49
+ if (shimJs) return { command: process.execPath, argsPrefix: [shimJs] };
50
+ return { command: value, argsPrefix: [] };
51
+ }
52
+
53
+ function whereCandidates(value) {
54
+ const where = spawnSync('where.exe', [value], {
55
+ encoding: 'utf8',
56
+ timeout: WHERE_TIMEOUT_MS,
57
+ windowsHide: true
58
+ });
59
+ return String(where.stdout || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
60
+ }
61
+
62
+ function resolveFromPath(value) {
63
+ const seen = new Set();
64
+ for (const candidate of whereCandidates(value)) {
65
+ if (seen.has(candidate)) continue;
66
+ seen.add(candidate);
67
+ const resolved = resolvePathCandidate(candidate);
68
+ if (resolved.command !== candidate || fs.existsSync(candidate)) return resolved;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ function resolveWindowsCodexLauncher(spec) {
74
+ const value = String(spec || '').trim() || 'codex';
75
+ if (hasPathSeparator(value)) return resolvePathCandidate(value);
76
+
77
+ const pathResolved = resolveFromPath(value);
78
+ if (pathResolved) return pathResolved;
79
+
80
+ return resolvePathCandidate(value);
81
+ }
82
+
83
+ export function resolveCodexLauncher(spec = 'codex') {
84
+ const value = String(spec || '').trim() || 'codex';
85
+ if (/\.(?:c?js|mjs)$/i.test(value)) return { command: process.execPath, argsPrefix: [value] };
86
+ if (process.platform === 'win32') return resolveWindowsCodexLauncher(value);
87
+ return { command: value, argsPrefix: [] };
88
+ }
@@ -1048,6 +1048,8 @@ async function retryTasksInState(state, taskIds = null, { auto = false, maxRetri
1048
1048
  if (!tasksToRetry.length) return { retried: [], state };
1049
1049
  for (const task of tasksToRetry) {
1050
1050
  if (hasLiveRunnerProcess(state, task.id, task)) throw new Error(`task still has a live process: ${task.id}`);
1051
+ }
1052
+ for (const task of tasksToRetry) {
1051
1053
  const batch = (state.batches || []).find(item => item.id === task.batchId);
1052
1054
  task.retryReason = reason;
1053
1055
  await rotateWorkerAttempt(state, task);
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { CODEX_BIN } from '../utils.js';
5
+ import { resolveCodexLauncher } from '../codexLauncher.js';
5
6
 
6
7
  function processKey(runId, taskId) {
7
8
  return `${runId}:${taskId}`;
@@ -47,18 +48,34 @@ export function createHeadlessRunner({ codexBin = CODEX_BIN } = {}) {
47
48
  const last = path.join(outDir, 'last_message.md');
48
49
  fs.writeFileSync(path.join(outDir, 'prompt.md'), prompt);
49
50
  const args = ['exec', '--json', '--sandbox', sandbox, '-C', cwd, '-o', last, prompt];
50
- const child = spawn(codexBin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
51
+ const { command, argsPrefix } = resolveCodexLauncher(codexBin);
52
+ const child = spawn(command, [...argsPrefix, ...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
51
53
  captureEventsWithTimestamps(child.stdout, events, timedEvents);
52
54
  child.stderr.pipe(fs.createWriteStream(stderr, { flags: 'a' }));
53
55
  const key = processKey(runId, taskId);
54
- runningProcesses.set(key, child);
55
- child.on('exit', code => {
56
+ const listeners = [];
57
+ let exited = false;
58
+ let exitCode = null;
59
+ const finish = code => {
60
+ if (exited) return;
61
+ exited = true;
62
+ exitCode = code;
56
63
  try { fs.writeFileSync(path.join(outDir, 'exit_code'), String(code)); } catch {}
57
64
  runningProcesses.delete(key);
65
+ for (const listener of listeners) listener(code);
66
+ };
67
+ runningProcesses.set(key, child);
68
+ child.on('error', error => {
69
+ try { fs.appendFileSync(stderr, `${error.message || String(error)}\n`); } catch {}
70
+ finish(error?.code === 'ENOENT' ? 127 : 1);
58
71
  });
72
+ child.on('exit', code => finish(code));
59
73
  return {
60
- pid: child.pid,
61
- onExit(listener) { child.on('exit', listener); },
74
+ pid: child.pid ?? null,
75
+ onExit(listener) {
76
+ if (exited) listener(exitCode);
77
+ else listeners.push(listener);
78
+ },
62
79
  stop(signal = 'TERM') { child.kill(signal); }
63
80
  };
64
81
  }
@@ -8,6 +8,7 @@ import {
8
8
  readTextMaybe,
9
9
  writeJsonAtomic
10
10
  } from '../utils.js';
11
+ import { resolveCodexLauncher } from '../codexLauncher.js';
11
12
  import {
12
13
  DEFAULT_TMUX_BIN,
13
14
  sanitizeTmuxSessionName,
@@ -44,6 +45,10 @@ function shellQuote(value) {
44
45
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
45
46
  }
46
47
 
48
+ function bashArrayAssignment(name, values) {
49
+ return `${name}=(${values.map(value => shellQuote(value)).join(' ')})`;
50
+ }
51
+
47
52
  const BIN_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../bin');
48
53
  const FORMATTER_BIN = path.join(BIN_DIR, 'input-kanban-format-events.js');
49
54
  const TIMESTAMP_BIN = path.join(BIN_DIR, 'input-kanban-timestamp-events.js');
@@ -55,11 +60,12 @@ function buildOverviewCommand(runStatePath) {
55
60
  return `while true; do clear; node ${quotedOverviewBin} ${quotedStatePath}; sleep 2; done`;
56
61
  }
57
62
 
58
- function buildRunScript({ codexBin, formatterBin = FORMATTER_BIN, timestampBin = TIMESTAMP_BIN, sandbox, cwd, outDir, runId, taskId, role }) {
63
+ function buildRunScript({ codexCommand, codexArgsPrefix = [], formatterBin = FORMATTER_BIN, timestampBin = TIMESTAMP_BIN, sandbox, cwd, outDir, runId, taskId, role }) {
64
+ const codexLauncher = bashArrayAssignment('CODEX_LAUNCHER', [codexCommand, ...codexArgsPrefix]);
59
65
  return `#!/usr/bin/env bash
60
66
  set -u
61
67
 
62
- CODEX_BIN=${shellQuote(codexBin)}
68
+ ${codexLauncher}
63
69
  SANDBOX=${shellQuote(sandbox)}
64
70
  CWD=${shellQuote(cwd)}
65
71
  OUT_DIR=${shellQuote(outDir)}
@@ -78,7 +84,7 @@ EXIT_CODE="$OUT_DIR/exit_code"
78
84
  cd "$CWD"
79
85
  rm -f "$EXIT_CODE"
80
86
  touch "$EVENTS" "$TIMED_EVENTS" "$STDERR_LOG"
81
- "$CODEX_BIN" exec --json --sandbox "$SANDBOX" -C "$CWD" -o "$LAST_MESSAGE" "$(<"$PROMPT_FILE")" > >(node "$TIMESTAMP_BIN" "$EVENTS" "$TIMED_EVENTS" | node "$FORMATTER_BIN") 2> >(tee -a "$STDERR_LOG" >&2)
87
+ "\${CODEX_LAUNCHER[@]}" exec --json --sandbox "$SANDBOX" -C "$CWD" -o "$LAST_MESSAGE" "$(<"$PROMPT_FILE")" > >(node "$TIMESTAMP_BIN" "$EVENTS" "$TIMED_EVENTS" | node "$FORMATTER_BIN") 2> >(tee -a "$STDERR_LOG" >&2)
82
88
  code=$?
83
89
  printf '%s' "$code" > "$EXIT_CODE"
84
90
  printf '\\nInput Kanban tmux task completed.\\n'
@@ -113,7 +119,8 @@ export function createTmuxRunner({
113
119
  const startedAt = nowIso();
114
120
 
115
121
  await fsp.writeFile(promptFile, prompt);
116
- await fsp.writeFile(runScript, buildRunScript({ codexBin, sandbox, cwd, outDir, runId, taskId, role }));
122
+ const { command: codexCommand, argsPrefix: codexArgsPrefix } = resolveCodexLauncher(codexBin);
123
+ await fsp.writeFile(runScript, buildRunScript({ codexCommand, codexArgsPrefix, sandbox, cwd, outDir, runId, taskId, role }));
117
124
  await fsp.chmod(runScript, 0o755);
118
125
 
119
126
  const metadata = {
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_WORKSPACE, DEFAULT_REPO, PACKAGE_VERSION, RUNNER, RUNS_DIR } from './utils.js';
6
+ import { APP_ROOT, CODEX_BIN, DEFAULT_WORKSPACE, DEFAULT_REPO, PACKAGE_VERSION, RUNNER, RUNS_DIR, detectCodexInfo } from './utils.js';
7
7
  import { createRun, listRuns, startPlanner, dispatchRun, startJudge, refreshRun, readRunFile, readRunTaskText, markTaskCompleted, stopRun, archiveRun, renameRun, retryRun } from './orchestrator.js';
8
8
  import { startAutoScheduler } from './scheduler.js';
9
9
 
@@ -43,7 +43,10 @@ async function handleApi(req, res, url, appClient) {
43
43
  const parts = url.pathname.split('/').filter(Boolean);
44
44
  try {
45
45
  if (req.method === 'GET' && url.pathname === '/api/health') {
46
- return send(res, 200, { ok: true, version: PACKAGE_VERSION, appRoot: APP_ROOT, runsDir: RUNS_DIR, defaultWorkspace: DEFAULT_WORKSPACE, defaultRepo: DEFAULT_REPO, runner: RUNNER });
46
+ return send(res, 200, { ok: true, version: PACKAGE_VERSION, appRoot: APP_ROOT, runsDir: RUNS_DIR, defaultWorkspace: DEFAULT_WORKSPACE, defaultRepo: DEFAULT_REPO, runner: RUNNER, codexBin: CODEX_BIN });
47
+ }
48
+ if (req.method === 'GET' && url.pathname === '/api/codex') {
49
+ return send(res, 200, { ok: true, codex: await detectCodexInfo() });
47
50
  }
48
51
  if (parts[1] === 'runs' && parts.length === 2) {
49
52
  if (req.method === 'GET') return send(res, 200, { runs: await listRuns({ includeArchived: url.searchParams.get('includeArchived') === '1', workspace: url.searchParams.get('workspace') || '' }) });
package/src/utils.js CHANGED
@@ -3,16 +3,23 @@ import fsp from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
5
  import { createRequire } from 'node:module';
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { resolveCodexLauncher } from './codexLauncher.js';
6
10
 
7
11
  const require = createRequire(import.meta.url);
12
+ const execFileAsync = promisify(execFile);
8
13
  const { version: PACKAGE_VERSION } = require('../package.json');
9
14
 
10
- export const APP_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
15
+ export const APP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11
16
  export { PACKAGE_VERSION };
12
17
  export const DEFAULT_WORKSPACE = path.resolve(process.env.KANBAN_DEFAULT_WORKSPACE || process.env.KANBAN_DEFAULT_REPO || process.cwd());
13
18
  export const DEFAULT_REPO = DEFAULT_WORKSPACE;
14
19
  export const RUNS_DIR = path.resolve(process.env.KANBAN_RUNS_DIR || path.join(process.env.HOME || APP_ROOT, '.input-kanban', 'runs'));
15
20
  export const CODEX_BIN = process.env.KANBAN_CODEX_BIN || 'codex';
21
+ export const CODEX_NPM_PACKAGE = '@openai/codex';
22
+ export const CODEX_CHECK_LATEST = process.env.KANBAN_CODEX_CHECK_LATEST === '1';
16
23
  export const VALID_RUNNERS = ['headless', 'tmux'];
17
24
 
18
25
  export function normalizeRunner(value = 'headless', source = 'KANBAN_RUNNER') {
@@ -43,6 +50,48 @@ export async function fileInfo(file) {
43
50
  try { const st = await fsp.stat(file); return { exists: true, size: st.size, mtimeMs: st.mtimeMs, mtime: st.mtime.toISOString() }; }
44
51
  catch { return { exists: false }; }
45
52
  }
53
+
54
+ function parseCodexVersion(output) {
55
+ const text = String(output || '').trim();
56
+ const match = text.match(/(\d+\.\d+\.\d+)/);
57
+ return match ? match[1] : text || null;
58
+ }
59
+
60
+ export async function detectCodexInfo(codexBin = CODEX_BIN, { checkLatest = CODEX_CHECK_LATEST } = {}) {
61
+ const info = {
62
+ command: codexBin,
63
+ packageName: CODEX_NPM_PACKAGE,
64
+ installCommand: `npm install -g ${CODEX_NPM_PACKAGE}`,
65
+ updateCommand: `npm install -g ${CODEX_NPM_PACKAGE}`,
66
+ installed: false,
67
+ installedVersion: null,
68
+ latestVersion: null,
69
+ updateAvailable: false,
70
+ versionText: '',
71
+ installHint: '',
72
+ latestCheckEnabled: !!checkLatest
73
+ };
74
+ try {
75
+ const { command, argsPrefix } = resolveCodexLauncher(codexBin);
76
+ const { stdout } = await execFileAsync(command, [...argsPrefix, '--version'], { timeout: 5000, windowsHide: true });
77
+ const text = String(stdout || '').trim();
78
+ info.installed = true;
79
+ info.versionText = text;
80
+ info.installedVersion = parseCodexVersion(text);
81
+ } catch (error) {
82
+ info.installHint = error?.code === 'ENOENT' ? 'codex command not found' : (error?.message || String(error));
83
+ }
84
+ if (checkLatest) {
85
+ try {
86
+ const { stdout } = await execFileAsync('npm', ['view', CODEX_NPM_PACKAGE, 'version', '--json'], { timeout: 5000, windowsHide: true });
87
+ const parsed = JSON.parse(String(stdout || '').trim());
88
+ const latest = Array.isArray(parsed) ? parsed.at(-1) : parsed;
89
+ if (typeof latest === 'string' && latest.trim()) info.latestVersion = latest.trim();
90
+ } catch {}
91
+ }
92
+ info.updateAvailable = !!(info.installedVersion && info.latestVersion && info.installedVersion !== info.latestVersion);
93
+ return info;
94
+ }
46
95
  export async function readTextMaybe(file, maxBytes=200000) {
47
96
  try {
48
97
  const st = await fsp.stat(file);