input-kanban 0.0.12 → 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/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,23 @@
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
+
3
21
  ## v0.0.12
4
22
 
5
23
  ### 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.12",
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%; }
@@ -117,6 +126,9 @@
117
126
  .execution-summary + pre { border-top-left-radius: 0; border-top-right-radius: 0; }
118
127
  .notice { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #1f2937; color: #cbd5e1; }
119
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; }
120
132
  .file-content-wrap { position: relative; }
121
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); }
122
134
  .file-content-wrap:hover .floating-copy-btn:not(.hidden), .floating-copy-btn:focus { opacity: 1; pointer-events: auto; }
@@ -170,14 +182,7 @@
170
182
  <div id="selected" class="muted">未选择任务批次</div>
171
183
  <div id="autoRefreshHint" class="muted hidden">自动刷新:未启动</div>
172
184
  <div id="runNotice" class="notice warning hidden"></div>
173
- <div class="toolbar">
174
- <button onclick="planRun()">拆分任务</button>
175
- <button onclick="dispatchRun()">派发执行</button>
176
- <button onclick="judgeRun()">汇总验收</button>
177
- <button class="secondary" onclick="refreshSelected()">刷新状态</button>
178
- <button class="secondary" onclick="stopSelectedRun()">停止</button>
179
- <button class="danger" onclick="archiveSelectedRun()">归档</button>
180
- </div>
185
+ <div id="actionToolbar" class="toolbar"></div>
181
186
  </div>
182
187
  <h3>任务说明</h3>
183
188
  <pre id="taskDescription" class="task-text">未选择任务批次</pre>
@@ -199,7 +204,7 @@
199
204
  </section>
200
205
  </div>
201
206
  </main>
202
- <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>
203
208
  <div id="manualCompleteModal" class="modal-backdrop hidden">
204
209
  <div class="modal-card">
205
210
  <h2>手动标记成功</h2>
@@ -219,6 +224,7 @@ let selectedTask = null;
219
224
  let selectedFileName = null;
220
225
  let manualCompleteTaskId = null;
221
226
  let pendingArchiveRunId = null;
227
+ let pendingAction = null;
222
228
  let currentState = null;
223
229
  let lastAutoRefreshAt = null;
224
230
  let runListVisibleCount = 10;
@@ -356,6 +362,25 @@ async function loadHealth() {
356
362
  renderWorkspaceFilterOptions();
357
363
  updateWorkspaceFilterTitle();
358
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
+ }
359
384
  function showCreateForm() {
360
385
  selectedRun = null; selectedTask = null; selectedFileName = null; currentState = null;
361
386
  clearFileView();
@@ -486,6 +511,7 @@ async function refreshSelected({auto=false} = {}) {
486
511
  statusByRunId.set(selectedRun, currentState);
487
512
  if (auto) lastAutoRefreshAt = new Date();
488
513
  document.getElementById('selected').innerHTML = renderSelectedHeader();
514
+ renderActionToolbar();
489
515
  if (auto) requestAnimationFrame(triggerRefreshPulse);
490
516
  updateAutoRefreshHint();
491
517
  updateRunNotice();
@@ -531,6 +557,97 @@ function refreshPulseChip() {
531
557
  const last = lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发';
532
558
  return `<span id="refreshPulse" class="refresh-pulse-chip" title="自动刷新:每 ${AUTO_REFRESH_MS / 1000} 秒;上次 ${esc(last)}"><span class="refresh-pulse-dot"></span></span>`;
533
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
+ }
534
651
  function triggerRefreshPulse() {
535
652
  const el = document.getElementById('refreshPulse');
536
653
  if (!el) return;
@@ -579,7 +696,8 @@ function taskActionCell(id, t) {
579
696
  if (!t || id === 'planner' || id === 'judge') return '-';
580
697
  if (t.manualCompletion) return '<span class="muted">已人工确认</span>';
581
698
  if (!['unknown', 'failed'].includes(t.status)) return '-';
582
- 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>`;
583
701
  }
584
702
  function shortSessionId(thread) {
585
703
  const text = String(thread || '');
@@ -905,25 +1023,20 @@ async function renameRunLabel(event, runId = selectedRun) {
905
1023
  else await refreshRuns();
906
1024
  });
907
1025
  }
908
- async function planRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/plan`, {method:'POST'}); await refreshSelected(); }); }
909
- async function dispatchRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/dispatch`, {method:'POST'}); await refreshSelected(); }); }
910
- async function judgeRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/judge`, {method:'POST'}); await refreshSelected(); }); }
911
- async function runAction(fn) {
912
- try { await fn(); }
913
- catch (error) {
914
- console.error('操作失败', error);
915
- alert(userFacingErrorMessage(error));
916
- }
917
- }
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(); }); }
918
1029
  async function stopSelectedRun() {
919
1030
  if (!selectedRun) return;
920
1031
  const ok = confirm('确认停止当前任务批次?\n\n停止会终止仍在运行的 codex exec 子进程,并冻结后续调度。');
921
1032
  if (!ok) return;
922
- await api(`/api/runs/${selectedRun}/stop`, {
923
- method: 'POST',
924
- 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();
925
1039
  });
926
- await refreshSelected();
927
1040
  }
928
1041
  function clearArchiveConfirm(runId) {
929
1042
  if (pendingArchiveRunId !== runId) return;
@@ -946,7 +1059,7 @@ async function archiveRunById(runId, { confirmFirst = true } = {}) {
946
1059
  const ok = confirm('确认归档当前任务批次?\n\n归档后会从默认任务批次列表隐藏。若仍有任务运行,请先停止。');
947
1060
  if (!ok) return;
948
1061
  }
949
- await runAction(async () => {
1062
+ await runActionWithPending('archive', async () => {
950
1063
  await api(`/api/runs/${runId}/archive`, {
951
1064
  method: 'POST',
952
1065
  body: JSON.stringify({ reason: 'archived from dashboard' })
@@ -986,17 +1099,21 @@ async function submitManualComplete() {
986
1099
  if (!selectedRun || !taskId) return;
987
1100
  const resultText = document.getElementById('manualCompleteResult').value.trim();
988
1101
  if (!resultText) { alert('请粘贴人工成功执行结果。'); return; }
989
- await api(`/api/runs/${selectedRun}/tasks/${taskId}/mark-completed`, {
990
- method: 'POST',
991
- 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');
992
1111
  });
993
- closeManualCompleteModal();
994
- selectedTask = taskId;
995
- await refreshSelected();
996
- await loadFile('result.json');
997
1112
  }
998
1113
 
999
1114
  initializeWorkerSandboxPreference();
1115
+ renderActionToolbar();
1116
+ loadCodexStatus().catch(console.error);
1000
1117
  loadHealth().then(refreshRuns);
1001
1118
  setInterval(() => { if (selectedRun) refreshSelected({auto:true}).catch(console.error); else refreshRuns().catch(console.error); }, AUTO_REFRESH_MS);
1002
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,9 +3,13 @@ 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';
6
8
  import { fileURLToPath } from 'node:url';
9
+ import { resolveCodexLauncher } from './codexLauncher.js';
7
10
 
8
11
  const require = createRequire(import.meta.url);
12
+ const execFileAsync = promisify(execFile);
9
13
  const { version: PACKAGE_VERSION } = require('../package.json');
10
14
 
11
15
  export const APP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -14,6 +18,8 @@ export const DEFAULT_WORKSPACE = path.resolve(process.env.KANBAN_DEFAULT_WORKSPA
14
18
  export const DEFAULT_REPO = DEFAULT_WORKSPACE;
15
19
  export const RUNS_DIR = path.resolve(process.env.KANBAN_RUNS_DIR || path.join(process.env.HOME || APP_ROOT, '.input-kanban', 'runs'));
16
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';
17
23
  export const VALID_RUNNERS = ['headless', 'tmux'];
18
24
 
19
25
  export function normalizeRunner(value = 'headless', source = 'KANBAN_RUNNER') {
@@ -44,6 +50,48 @@ export async function fileInfo(file) {
44
50
  try { const st = await fsp.stat(file); return { exists: true, size: st.size, mtimeMs: st.mtimeMs, mtime: st.mtime.toISOString() }; }
45
51
  catch { return { exists: false }; }
46
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
+ }
47
95
  export async function readTextMaybe(file, maxBytes=200000) {
48
96
  try {
49
97
  const st = await fsp.stat(file);