input-kanban 0.0.14 → 0.0.16

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,39 @@
1
1
  # Release Notes
2
2
 
3
+ ## v0.0.16
4
+
5
+ ### Highlights
6
+
7
+ - Add a low-profile footer entry for session management, covering both board-managed and local Codex sessions.
8
+ - Show board/local tabs in the session modal and classify sessions with lightweight board-managed metadata.
9
+ - Add local Codex process visibility in the session modal, including resumable sessions, other Codex-related processes, and total memory usage in the modal header.
10
+ - Simplify the session modal layout with a compact close control, adjustable height, wider default width, and reduced empty spacing.
11
+ - Keep only meaningful session status badges, hide `unknown` in session cards, and move session IDs/copy actions to the right side.
12
+
13
+ ### Verification
14
+
15
+ - `node --test test/server-static.test.js` passed locally.
16
+ - `node --test test/frontend-tmux-ui.test.js` passed locally.
17
+ - Windows-native validation passed on `zhangxing_win` with `npm run check` in the Windows release-candidate working tree.
18
+
19
+ ## v0.0.15
20
+
21
+ ### Highlights
22
+
23
+ - Absorb PR #3 recovery hardening without directly merging its older `v0.0.13` branch, preserving the `v0.0.14` Plan Approval Gate and Web layout changes.
24
+ - Route blocked-run dashboard execution actions to `/api/runs/:id/retry`, so `batch_blocked` runs retry failed/unknown tasks instead of hitting the dispatch endpoint.
25
+ - Remove stale Web `workers_completed` / `workers_failed` UI state handling now that backend run status uses `batches_completed` and `batch_blocked`.
26
+ - Harden final judge starts: reject archived/stopped runs, duplicate running judges, and completed judges; failed judges are archived to `judge_attempts/` before retrying.
27
+ - Add short `/api/codex` detection caching to avoid repeatedly spawning Codex detection during frequent dashboard refreshes.
28
+ - Keep task-table Codex session IDs and their copy buttons on one line by widening the session column and using a compact inline layout.
29
+
30
+ ### Verification
31
+
32
+ - `npm run check` passed locally with 84 tests.
33
+ - `npm pack --dry-run` passed for `input-kanban@0.0.15`.
34
+ - Windows release-candidate validation on `zhangxing_win` passed with 84 tests.
35
+ - Windows Web smoke confirmed `/api/health`, `/api/codex`, Plan Approval UI, compact header copy tools, and one-line Codex session copy layout.
36
+
3
37
  ## v0.0.14
4
38
 
5
39
  ### Highlights
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "input-kanban",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "input-kanban": "bin/input-kanban.js"
package/public/index.html CHANGED
@@ -41,20 +41,20 @@
41
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; } }
42
42
  table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
43
43
  th, td { border-bottom: 1px solid var(--line); padding: 9px 8px; text-align: left; vertical-align: top; }
44
- th:nth-child(1), td:nth-child(1) { width: 34%; }
44
+ th:nth-child(1), td:nth-child(1) { width: 32%; }
45
45
  th:nth-child(2), td:nth-child(2) { width: 92px; white-space: nowrap; }
46
46
  th:nth-child(3), td:nth-child(3) { width: 132px; }
47
47
  th:nth-child(4), td:nth-child(4) { width: 64px; white-space: nowrap; }
48
48
  th:nth-child(5), td:nth-child(5) { width: 96px; }
49
- th:nth-child(6), td:nth-child(6) { width: 92px; }
49
+ th:nth-child(6), td:nth-child(6) { width: 116px; }
50
50
  th:nth-child(7), td:nth-child(7) { width: 66px; }
51
51
  th:nth-child(8), td:nth-child(8) { width: 94px; }
52
52
  th { color: #cbd5e1; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
53
53
  tr:hover { background: #162033; cursor: pointer; }
54
54
  .pill { display: inline-block; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 800; background: var(--gray); line-height: 1.3; }
55
- .completed, .judged, .workers_completed, .planned, .batches_completed { background: var(--green); }
55
+ .completed, .judged, .planned, .batches_completed { background: var(--green); }
56
56
  .running, .planning, .judging { background: var(--blue); }
57
- .failed, .workers_failed, .plan_failed, .judge_failed, .unknown, .batch_blocked { background: var(--red); }
57
+ .failed, .plan_failed, .judge_failed, .unknown, .batch_blocked { background: var(--red); }
58
58
  .plan_empty { background: var(--orange); }
59
59
  .pending, .created { background: var(--gray); }
60
60
  .batch-row td { border-top: 3px solid var(--line-strong); background: #101827; font-weight: 800; font-size: 14px; padding-top: 13px; }
@@ -117,7 +117,9 @@
117
117
  .run-card-title-actions { display: inline-flex; align-items: center; gap: 3px; }
118
118
  .archive-confirm-btn { min-width: 46px; padding: 4px 10px; border-color: rgba(248,113,113,.85); background: var(--red) !important; color: white; font-weight: 900; }
119
119
  .icon-svg { width: 14px; height: 14px; display: block; }
120
- .session-cell { word-break: break-all; }
120
+ .session-cell-wrap { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
121
+ .session-cell { min-width: 0; font-variant-numeric: tabular-nums; }
122
+ .session-cell-wrap .copy-btn { flex: 0 0 auto; margin-left: 0; }
121
123
  .row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
122
124
  .row-actions .danger, .row-actions .secondary { margin: 0; }
123
125
  .status-stack { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 5px; }
@@ -139,11 +141,47 @@
139
141
  .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); }
140
142
  .file-content-wrap:hover .floating-copy-btn:not(.hidden), .floating-copy-btn:focus { opacity: 1; pointer-events: auto; }
141
143
  .floating-copy-btn:not(.hidden) + pre { padding-top: 42px; }
142
- .modal-backdrop { position: fixed; inset: 0; z-index: 20; display: flex; align-items: center; justify-content: center; padding: 20px; background: rgba(2,6,23,.72); }
144
+ .modal-backdrop { position: fixed; inset: 0; z-index: 20; display: flex; align-items: flex-start; justify-content: center; padding: 12px 20px 20px; background: rgba(2,6,23,.72); overflow: auto; }
143
145
  .modal-backdrop.hidden { display: none; }
144
- .modal-card { width: min(760px, 100%); border: 1px solid var(--line); border-radius: 14px; background: var(--panel); box-shadow: 0 18px 60px rgba(0,0,0,.38); padding: 16px; }
146
+ .modal-card { width: min(960px, 100%); max-height: min(88vh, 860px); border: 1px solid var(--line); border-radius: 14px; background: var(--panel); box-shadow: 0 18px 60px rgba(0,0,0,.38); padding: 12px 16px 14px; overflow: hidden; display: flex; flex-direction: column; margin-top: 0; }
147
+ .modal-card.resizable { resize: vertical; }
145
148
  .modal-card textarea { min-height: 220px; }
149
+ .modal-title-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
150
+ .modal-title-row h2 { margin: 0; }
151
+ .modal-title-actions { display: inline-flex; align-items: center; gap: 10px; }
152
+ .modal-title-meta { color: var(--muted); font-size: 12px; white-space: nowrap; }
153
+ .modal-close-btn { padding: 3px 9px; font-size: 18px; line-height: 1; opacity: .72; }
154
+ .modal-close-btn:hover { opacity: 1; }
146
155
  .page-footer { padding: 0 18px 18px; color: var(--muted); text-align: center; font-size: 12px; }
156
+ .session-management-trigger { margin-top: 8px; padding: 3px 8px; font-size: 12px; opacity: .72; }
157
+ .session-management-trigger:hover { opacity: 1; }
158
+ .session-management-tabs { display: flex; gap: 6px; margin: 8px 0 4px; flex-wrap: wrap; align-items: center; }
159
+ .session-management-tab { padding: 5px 9px; font-size: 12px; background: var(--gray); }
160
+ .session-management-tab.active { background: var(--blue); }
161
+ .session-management-load { margin-top: 10px; color: var(--muted); font-size: 12px; }
162
+ .session-management-body { display: flex; flex: 1; min-height: 0; flex-direction: column; margin-top: 2px; overflow: auto; }
163
+ .session-management-list { margin-top: 8px; display: flex; flex-direction: column; gap: 10px; min-height: 0; }
164
+ .session-management-item { padding: 10px 12px; border: 1px solid var(--line); border-radius: 12px; background: #0b1324; }
165
+ .session-management-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: flex-start; }
166
+ .session-management-main { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
167
+ .session-management-title { min-width: 0; font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: min(640px, 66vw); }
168
+ .session-management-side { display: inline-flex; align-items: center; gap: 8px; min-width: 0; justify-self: end; }
169
+ .session-management-id { min-width: 0; font-variant-numeric: tabular-nums; word-break: break-all; color: var(--muted); }
170
+ .session-management-status { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
171
+ .session-management-status .pill { padding: 2px 7px; font-size: 11px; }
172
+ .session-management-meta { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; }
173
+ .session-management-empty { padding: 12px 0; color: var(--muted); }
174
+ .process-management { margin-top: 8px; border-top: 1px solid var(--line); padding-top: 8px; }
175
+ .process-management.hidden { display: none; }
176
+ .process-management-title { display: flex; justify-content: space-between; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; }
177
+ .process-management-group { margin-top: 4px; }
178
+ .process-management-group-title { display: flex; justify-content: space-between; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; margin: 4px 0; }
179
+ .process-management-list { display: flex; flex-direction: column; gap: 8px; }
180
+ .process-management-item { padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: #081120; }
181
+ .process-management-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
182
+ .process-management-resume { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; }
183
+ .process-management-resume .pill { padding: 2px 7px; font-size: 11px; }
184
+ .modal-card h2:first-child { margin-top: 0; }
147
185
  </style>
148
186
  </head>
149
187
  <body>
@@ -157,7 +195,7 @@
157
195
  </div>
158
196
  <div class="workspace-filter-panel">
159
197
  <select id="workspaceFilterSelect" class="workspace-filter-select" onchange="setWorkspaceFilter(this.value)" title="未筛选工作区"></select>
160
- <span id="runsLoadHint" class="runs-load-icon" title="批次列表尚未加载" aria-label="批次列表尚未加载">ⓘ</span>
198
+ <span id="runsLoadHint" class="runs-load-icon" title="批次列表尚未加载" aria-label="批次列表尚未加载">i</span>
161
199
  </div>
162
200
  <div id="runs" class="run-list"><div class="empty">批次列表加载中...</div></div>
163
201
  </section>
@@ -188,7 +226,7 @@
188
226
  <h2>批次详情</h2>
189
227
  <div class="build-header">
190
228
  <div id="selected" class="muted">未选择任务批次</div>
191
- <div id="autoRefreshHint" class="muted hidden">自动刷新:未启动</div>
229
+ <div id="autoRefreshHint" class="muted hidden">自动刷新:未启动</div>
192
230
  <div id="runNotice" class="notice warning hidden"></div>
193
231
  <div id="actionToolbar" class="toolbar"></div>
194
232
  </div>
@@ -200,7 +238,7 @@
200
238
  <section id="filePanel" class="log-panel">
201
239
  <div class="section-header">
202
240
  <h2>任务详情</h2>
203
- <span class="info-icon" title="若执行过程提示 Permission denied / sandbox denied,这通常不是任务本身失败,而是当前 worker 沙箱能力不足;在可信工作区可改用 danger-full-access。DNS / 网络失败则通常需要检查代理、VPN 或本地 evidence。" aria-label="任务详情权限与网络提示">ⓘ</span>
241
+ <span class="info-icon" title="若执行过程提示 Permission denied / sandbox denied,这通常不是任务本身失败,而是当前 worker 沙箱能力不足;在可信工作区可改用 danger-full-access。DNS / 网络失败则通常需要检查代理、VPN 或本地 evidence。" aria-label="任务详情权限与网络提示">i</span>
204
242
  </div>
205
243
  <div id="fileTitle" class="muted">点击任务后查看详情</div>
206
244
  <div id="fileTabs" class="toolbar file-tabs"></div>
@@ -212,14 +250,34 @@
212
250
  </section>
213
251
  </div>
214
252
  </main>
215
- <footer class="page-footer"><div id="pageFooter">版本:-</div><div id="codexStatus" class="codex-status hidden"></div></footer>
253
+ <footer class="page-footer"><div id="pageFooter">版本:-</div><div id="codexStatus" class="codex-status hidden"></div><button id="sessionManagementTrigger" class="secondary session-management-trigger" onclick="openSessionManagement()">会话管理</button></footer>
254
+ <div id="sessionManagementModal" class="modal-backdrop hidden" onclick="closeSessionManagement(event)">
255
+ <div id="sessionManagementModalCard" class="modal-card" onclick="event.stopPropagation()">
256
+ <div class="modal-title-row">
257
+ <h2>会话管理</h2>
258
+ <div class="modal-title-actions"><span id="sessionManagementTotalMemory" class="modal-title-meta"></span><button class="secondary modal-close-btn" title="关闭" aria-label="关闭" onclick="closeSessionManagement()">×</button></div>
259
+ </div>
260
+ <div class="session-management-tabs">
261
+ <button id="sessionManagementTabBoard" class="secondary session-management-tab active" onclick="setSessionManagementTab('board')">看板</button>
262
+ <button id="sessionManagementTabLocal" class="secondary session-management-tab" onclick="setSessionManagementTab('local')">本机</button>
263
+ </div>
264
+ <div class="session-management-body">
265
+ <div id="sessionManagementLoad" class="session-management-load">尚未加载</div>
266
+ <div id="sessionManagementList" class="session-management-list"></div>
267
+ <div id="processManagementPanel" class="process-management hidden">
268
+ <div class="process-management-title"><span>本机 Codex 进程</span><span id="processManagementLoad">尚未加载</span></div>
269
+ <div id="processManagementList" class="process-management-list"></div>
270
+ </div>
271
+ </div>
272
+ </div>
273
+ </div>
216
274
  <div id="manualCompleteModal" class="modal-backdrop hidden">
217
275
  <div class="modal-card">
218
276
  <h2>手动标记成功</h2>
219
277
  <div id="manualCompleteTitle" class="muted"></div>
220
278
  <label>人工成功执行结果</label>
221
279
  <textarea id="manualCompleteResult" placeholder="粘贴 codex resume 后的最终回复、验证结果、关键证据或 artifact 路径"></textarea>
222
- <div class="muted">该内容会保存为 manual_result.md,显示在“结果”中,并作为 final judge 的人工成功证据。</div>
280
+ <div class="muted">该内容会保存为 manual_result.md,显示在"结果"中,并作为 final judge 的人工成功证据。</div>
223
281
  <div class="toolbar">
224
282
  <button onclick="submitManualComplete()">确认标记成功</button>
225
283
  <button class="secondary" onclick="closeManualCompleteModal()">取消</button>
@@ -235,6 +293,11 @@ let pendingArchiveRunId = null;
235
293
  let pendingAction = null;
236
294
  let currentState = null;
237
295
  let lastAutoRefreshAt = null;
296
+ let sessionManagementOpen = false;
297
+ let sessionManagementTab = 'board';
298
+ const SESSION_MODAL_HEIGHT_STORAGE_KEY = 'input-kanban.sessionManagementHeight';
299
+ let sessionManagementState = { loading: false, threads: [], total: 0, error: '' };
300
+ let processManagementState = { loading: false, processes: [], total: 0, error: '' };
238
301
  let runListVisibleCount = 10;
239
302
  let latestRuns = [];
240
303
  let workspaceCatalogRuns = [];
@@ -273,7 +336,6 @@ function userFacingErrorMessage(error) {
273
336
  const statusText = {
274
337
  created: '已创建', pending: '等待中', planning: '拆分中', planned: '已拆分',
275
338
  running: '执行中', completed: '已完成', failed: '失败', unknown: '未知',
276
- workers_completed: '子任务完成', workers_failed: '子任务失败',
277
339
  batches_completed: '批次完成', batch_blocked: '批次阻塞', plan_empty: '拆分为空', stopped: '已停止',
278
340
  judging: '验收中', judged: '已验收', plan_failed: '拆分失败', judge_failed: '验收失败'
279
341
  };
@@ -313,7 +375,7 @@ function runDurationEnd(s) {
313
375
  ...(s.tasks || []).flatMap(task => [task.endedAt, task.completedAt, task.stoppedAt])
314
376
  ]) || s.updatedAt;
315
377
  }
316
- function runTimingText(s) { return `开始时刻 ${formatDateTime(s.createdAt)}|用时 ${formatDurationMs(durationSeconds(s.createdAt, runDurationEnd(s)) * 1000)}`; }
378
+ function runTimingText(s) { return `开始时刻 ${formatDateTime(s.createdAt)}|用时 ${formatDurationMs(durationSeconds(s.createdAt, runDurationEnd(s)) * 1000)}`; }
317
379
  function basenamePath(value) {
318
380
  const parts = String(value || '').split(/[\\/]/).filter(Boolean);
319
381
  return parts.at(-1) || value || '-';
@@ -388,14 +450,14 @@ async function loadCodexStatus() {
388
450
  el.classList.remove('hidden', 'warning');
389
451
  if (!codex.installed) {
390
452
  el.classList.add('warning');
391
- el.innerHTML = `Codex 未安装|<code>${esc(codex.installCommand || 'npm install -g @openai/codex')}</code>`;
453
+ el.innerHTML = `Codex 未安装|<code>${esc(codex.installCommand || 'npm install -g @openai/codex')}</code>`;
392
454
  } else {
393
455
  el.innerHTML = `<code>${esc(codex.versionText || codex.installedVersion || 'codex')}</code>`;
394
456
  }
395
457
  } catch (error) {
396
458
  el.classList.remove('hidden');
397
459
  el.classList.add('warning');
398
- el.innerHTML = `Codex:检测失败|${esc(errorDetail(error))}`;
460
+ el.innerHTML = `Codex:检测失败|${esc(errorDetail(error))}`;
399
461
  }
400
462
  }
401
463
  function showCreateForm() {
@@ -473,7 +535,7 @@ function renderRunList() {
473
535
  </div>
474
536
  </div>`);
475
537
  if (latestRuns.length > runListVisibleCount) {
476
- cards.push(`<button class="secondary run-list-more" onclick="showMoreRuns()">查看更多(${runListVisibleCount}/${latestRuns.length})</button>`);
538
+ cards.push(`<button class="secondary run-list-more" onclick="showMoreRuns()">查看更多(${runListVisibleCount}/${latestRuns.length})</button>`);
477
539
  }
478
540
  document.getElementById('runs').innerHTML = latestRuns.length ? cards.join('') : '<div class="empty">暂无任务批次</div>';
479
541
  }
@@ -512,7 +574,7 @@ function updateWorkspaceFilterTitle() {
512
574
  if (selectedWorkspaceFilter === WORKSPACE_FILTER_ALL) {
513
575
  select.title = currentWorkspacePath ? `未筛选工作区|默认工作区:${currentWorkspacePath}` : '未筛选工作区';
514
576
  } else {
515
- select.title = `当前筛选:${selectedWorkspaceFilter}`;
577
+ select.title = `当前筛选:${selectedWorkspaceFilter}`;
516
578
  }
517
579
  }
518
580
  function setWorkspaceFilter(value) {
@@ -535,11 +597,12 @@ async function refreshSelected({auto=false} = {}) {
535
597
  await loadTaskDescription();
536
598
  renderTasks(); await refreshRuns();
537
599
  if (selectedTask && selectedFileName) await loadFile(selectedFileName, { preserveScroll: true });
600
+ if (sessionManagementOpen) await refreshSessionManagement();
538
601
  }
539
602
  function renderSelectedHeader() {
540
603
  if (!currentState) return '<div class="muted">未选择任务批次</div>';
541
604
  const sandbox = currentState.workerSandbox || 'workspace-write';
542
- const titleActions = [`<button class="secondary copy-btn title-copy-btn" title="复制 Run ID:${esc(currentState.runId)}" data-copy-kind="run-id" onclick="copyRunId(event)">${titleCopyLabel('run-id')}</button>`];
605
+ const titleActions = [`<button class="secondary copy-btn title-copy-btn" title="复制 Run ID:${esc(currentState.runId)}" data-copy-kind="run-id" onclick="copyRunId(event)">${titleCopyLabel('run-id')}</button>`];
543
606
  if (currentState.runner === 'tmux' && hasRunTmuxMetadata(currentState)) {
544
607
  titleActions.push(`<button class="secondary copy-btn title-copy-btn" title="复制 tmux attach 指令" data-copy-kind="tmux" onclick="copyTmuxRunCommand(event)">${titleCopyLabel('tmux')}</button>`);
545
608
  }
@@ -565,7 +628,7 @@ async function loadTaskDescription() {
565
628
  }
566
629
  function refreshPulseChip() {
567
630
  const last = lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发';
568
- return `<span id="refreshPulse" class="refresh-pulse-chip" title="自动刷新:每 ${AUTO_REFRESH_MS / 1000} 秒;上次 ${esc(last)}"><span class="refresh-pulse-dot"></span></span>`;
631
+ return `<span id="refreshPulse" class="refresh-pulse-chip" title="自动刷新:每 ${AUTO_REFRESH_MS / 1000} 秒;上次 ${esc(last)}"><span class="refresh-pulse-dot"></span></span>`;
569
632
  }
570
633
  function actionButton({ key, label, onclick, variant = '', state = '', disabled = false, title = '' }) {
571
634
  const classes = [variant, state ? `state-${state}` : ''].filter(Boolean).join(' ');
@@ -575,34 +638,33 @@ function runActionState(key) {
575
638
  if (!selectedRun || !currentState) return { label: '-', disabled: true, state: '' };
576
639
  if (pendingAction === key) {
577
640
  return {
578
- plan: { label: '拆分中…', disabled: true, state: 'pending' },
579
- dispatch: { label: '启动中…', disabled: true, state: 'pending' },
580
- judge: { label: '验收中…', disabled: true, state: 'pending' },
581
- stop: { label: '停止中…', disabled: true, state: 'pending' },
582
- archive: { label: '归档中…', disabled: true, state: 'pending' }
641
+ plan: { label: '拆分中...', disabled: true, state: 'pending' },
642
+ dispatch: { label: '启动中...', disabled: true, state: 'pending' },
643
+ judge: { label: '验收中...', disabled: true, state: 'pending' },
644
+ stop: { label: '停止中...', disabled: true, state: 'pending' },
645
+ archive: { label: '归档中...', disabled: true, state: 'pending' }
583
646
  }[key];
584
647
  }
585
648
  const status = currentState.status;
586
649
  const anyWorkerRunning = (currentState.tasks || []).some(t => t.status === 'running');
587
650
  if (key === 'plan') {
588
651
  if (status === 'planning' || currentState.planner?.status === 'running') return { label: '拆分中', disabled: true, state: 'active' };
589
- if (status === 'planned' || status === 'running' || status === 'workers_completed' || status === 'batches_completed' || status === 'judging' || status === 'judged') return { label: '已拆分', disabled: true, state: 'done' };
652
+ if (status === 'planned' || status === 'running' || status === 'batches_completed' || status === 'judging' || status === 'judged') return { label: '已拆分', disabled: true, state: 'done' };
590
653
  if (status === 'plan_failed' || status === 'plan_empty') return { label: '重试拆分', disabled: false, state: 'retry' };
591
654
  return { label: '拆分', disabled: false, state: '' };
592
655
  }
593
656
  if (key === 'dispatch') {
594
657
  if (status === 'running' || anyWorkerRunning) return { label: '执行中', disabled: true, state: 'active' };
595
658
  if (status === 'planned') return { label: planApprovalPending() ? '开始执行' : '执行', disabled: false, state: '' };
596
- if (status === 'batch_blocked') return { label: '执行', disabled: false, state: 'retry' };
597
- if (status === 'workers_failed') return { label: '重试执行', disabled: false, state: 'retry' };
598
- if (['workers_completed','batches_completed','judging','judged'].includes(status)) return { label: '已完成', disabled: true, state: 'done' };
659
+ if (status === 'batch_blocked') return { label: '重试执行', disabled: false, state: 'retry' };
660
+ if (['batches_completed','judging','judged'].includes(status)) return { label: '已完成', disabled: true, state: 'done' };
599
661
  return { label: '执行', disabled: true, state: 'done' };
600
662
  }
601
663
  if (key === 'judge') {
602
664
  if (status === 'judging' || currentState.judge?.status === 'running') return { label: '验收中', disabled: true, state: 'active' };
603
665
  if (status === 'judged') return { label: '已验收', disabled: true, state: 'done' };
604
666
  if (status === 'judge_failed') return { label: '重试验收', disabled: false, state: 'retry' };
605
- if (status === 'batches_completed' || status === 'workers_completed') return { label: '验收', disabled: false, state: '' };
667
+ if (status === 'batches_completed') return { label: '验收', disabled: false, state: '' };
606
668
  return { label: '验收', disabled: true, state: 'done' };
607
669
  }
608
670
  if (key === 'stop') {
@@ -666,6 +728,204 @@ function triggerRefreshPulse() {
666
728
  void el.offsetWidth;
667
729
  el.classList.add('pulse');
668
730
  }
731
+ function sessionManagementStateLabel(thread) {
732
+ const started = formatDateTime(thread?.startedAt);
733
+ const updated = formatDateTime(thread?.updatedAt);
734
+ return `创建 ${started}|最后更新 ${updated}`;
735
+ }
736
+ function sessionManagementShortId(sessionId) {
737
+ const text = String(sessionId || '');
738
+ return text.length > 10 ? `…${text.slice(-10)}` : text || '-';
739
+ }
740
+ function sessionManagementThreadTitle(thread) {
741
+ const title = typeof thread?.title === 'string' ? thread.title.trim() : '';
742
+ const previewLine = String(thread?.preview || '').split(/\r?\n/).map(line => line.trim()).find(line => line && !/^ORCHESTRATOR_[A-Z_]+:\s*/.test(line)) || '';
743
+ return title || previewLine || String(thread?.sessionId || '').trim() || '-';
744
+ }
745
+ function sessionManagementTabLabel(tab) {
746
+ return tab === 'board' ? '看板' : '本机';
747
+ }
748
+ function formatProcessMemory(value) {
749
+ const number = Number(value);
750
+ return Number.isFinite(number) ? `${number.toFixed(number >= 10 ? 0 : 1)} MB` : '-';
751
+ }
752
+ function formatProcessElapsed(processInfo) {
753
+ return processInfo?.elapsed || '-';
754
+ }
755
+ function setSessionManagementTab(tab) {
756
+ sessionManagementTab = tab === 'local' ? 'local' : 'board';
757
+ renderSessionManagementList();
758
+ renderProcessManagementList();
759
+ }
760
+ function restoreSessionManagementHeight() {
761
+ const modalCard = document.getElementById('sessionManagementModalCard');
762
+ const saved = localStorage.getItem(SESSION_MODAL_HEIGHT_STORAGE_KEY);
763
+ if (!modalCard?.style) return;
764
+ modalCard.style.height = saved || '78vh';
765
+ }
766
+ function saveSessionManagementHeight() {
767
+ const modalCard = document.getElementById('sessionManagementModalCard');
768
+ if (!modalCard?.style || typeof getComputedStyle !== 'function') return;
769
+ const height = getComputedStyle(modalCard).height;
770
+ if (height) localStorage.setItem(SESSION_MODAL_HEIGHT_STORAGE_KEY, height);
771
+ }
772
+ function renderSessionManagementList() {
773
+ const list = document.getElementById('sessionManagementList');
774
+ const hint = document.getElementById('sessionManagementLoad');
775
+ const boardTab = document.getElementById('sessionManagementTabBoard');
776
+ const localTab = document.getElementById('sessionManagementTabLocal');
777
+ if (!list || !hint || !boardTab || !localTab) return;
778
+ boardTab.classList.toggle('active', sessionManagementTab === 'board');
779
+ localTab.classList.toggle('active', sessionManagementTab === 'local');
780
+ if (!sessionManagementOpen) return;
781
+ if (sessionManagementState.loading) {
782
+ hint.textContent = '正在加载…';
783
+ list.innerHTML = '<div class="session-management-empty">正在加载会话列表…</div>';
784
+ return;
785
+ }
786
+ if (sessionManagementState.error) {
787
+ hint.textContent = `加载失败:${sessionManagementState.error}`;
788
+ list.innerHTML = '<div class="session-management-empty">会话列表加载失败</div>';
789
+ return;
790
+ }
791
+ hint.textContent = '';
792
+ const threads = (sessionManagementState.threads || []).filter(thread => sessionManagementTab === 'board' ? !!thread.boardManaged : !thread.boardManaged);
793
+ if (!threads.length) {
794
+ list.innerHTML = '';
795
+ return;
796
+ }
797
+ list.innerHTML = threads.map(thread => `
798
+ <div class="session-management-item">
799
+ <div class="session-management-row">
800
+ <div class="session-management-main">
801
+ <span class="session-management-title" title="${esc(sessionManagementThreadTitle(thread))}">${esc(sessionManagementThreadTitle(thread))}</span>
802
+ </div>
803
+ <div class="session-management-side">
804
+ <span class="session-management-id" title="${esc(thread.sessionId)}">${esc(sessionManagementShortId(thread.sessionId))}</span>
805
+ <button class="copy-btn" title="复制完整会话ID" onclick='copySessionManagementId(event, ${JSON.stringify(thread.sessionId)})'>⧉</button>
806
+ ${thread.status && thread.status !== 'unknown' ? `<span class="session-management-status"><span class="pill ${esc(thread.status || '')}">${esc(thread.status || '-')}</span></span>` : ''}
807
+ </div>
808
+ </div>
809
+ <div class="session-management-meta">
810
+ ${metaChip('创建', formatDateTime(thread.startedAt))}
811
+ ${metaChip('最后更新', formatDateTime(thread.updatedAt))}
812
+ ${metaChip('来源', thread.source || '-')}
813
+ </div>
814
+ </div>
815
+ `).join('');
816
+ }
817
+ function renderProcessManagementList() {
818
+ const panel = document.getElementById('processManagementPanel');
819
+ const list = document.getElementById('processManagementList');
820
+ const load = document.getElementById('processManagementLoad');
821
+ if (!panel || !list || !load) return;
822
+ panel.classList.toggle('hidden', sessionManagementTab !== 'local');
823
+ if (sessionManagementTab !== 'local') return;
824
+ if (processManagementState.loading) {
825
+ load.textContent = '正在加载…';
826
+ list.innerHTML = '<div class="session-management-empty">正在扫描本机 Codex 进程…</div>';
827
+ return;
828
+ }
829
+ if (processManagementState.error) {
830
+ load.textContent = `加载失败:${processManagementState.error}`;
831
+ list.innerHTML = '<div class="session-management-empty">进程列表加载失败</div>';
832
+ return;
833
+ }
834
+ const processes = processManagementState.processes || [];
835
+ const resumableProcesses = processes.filter(processInfo => processInfo.sessionId);
836
+ const otherProcesses = processes.filter(processInfo => !processInfo.sessionId);
837
+ const totalMemoryMb = processes.reduce((sum, processInfo) => sum + (Number(processInfo.rssMb) || 0), 0);
838
+ const totalMemory = document.getElementById('sessionManagementTotalMemory');
839
+ if (totalMemory) totalMemory.textContent = `总占用内存 ${formatProcessMemory(totalMemoryMb)}`;
840
+ load.textContent = `${resumableProcesses.length || 0} 个可 resume 进程|${otherProcesses.length || 0} 个其他进程`;
841
+ if (!processes.length) {
842
+ list.innerHTML = '';
843
+ return;
844
+ }
845
+ const renderProcessCard = processInfo => `
846
+ <div class="process-management-item">
847
+ <div class="process-management-row">
848
+ <div class="session-management-main">
849
+ ${metaChip('PID', processInfo.pid, { title: processInfo.command || '' })}
850
+ ${metaChip('父进程', processInfo.ppid)}
851
+ ${metaChip('内存', formatProcessMemory(processInfo.rssMb))}
852
+ ${metaChip('CPU', `${processInfo.cpuPercent || 0}%`)}
853
+ ${metaChip('运行', formatProcessElapsed(processInfo))}
854
+ </div>
855
+ ${processInfo.sessionId ? `<span class="process-management-resume"><span class="pill">resume</span><button class="secondary copy-btn" title="复制会话ID" onclick='copySessionId(event, ${JSON.stringify(processInfo.sessionId)})'>⧉</button></span>` : ''}
856
+ </div>
857
+ </div>
858
+ `;
859
+ list.innerHTML = `
860
+ ${resumableProcesses.length ? `<div class="process-management-group"><div class="process-management-group-title"><span>可 resume</span><span>${resumableProcesses.length}</span></div><div class="process-management-list">${resumableProcesses.map(renderProcessCard).join('')}</div></div>` : ''}
861
+ ${otherProcesses.length ? `<div class="process-management-group"><div class="process-management-group-title"><span>其他 Codex 进程</span><span>${otherProcesses.length}</span></div><div class="process-management-list">${otherProcesses.map(renderProcessCard).join('')}</div></div>` : ''}
862
+ `;
863
+ }
864
+ async function loadProcessManagement() {
865
+ processManagementState.loading = true;
866
+ processManagementState.error = '';
867
+ renderProcessManagementList();
868
+ try {
869
+ const data = await api('/api/session-management/processes');
870
+ processManagementState = { loading: false, processes: data.processes || [], total: data.total || 0, error: '' };
871
+ } catch (error) {
872
+ processManagementState = { loading: false, processes: [], total: 0, error: errorDetail(error) };
873
+ }
874
+ renderProcessManagementList();
875
+ }
876
+ async function loadSessionManagement() {
877
+ const load = document.getElementById('sessionManagementLoad');
878
+ if (!load) return;
879
+ sessionManagementState.loading = true;
880
+ sessionManagementState.error = '';
881
+ renderSessionManagementList();
882
+ try {
883
+ const data = await api('/api/session-management?limit=100');
884
+ sessionManagementState = { loading: false, threads: data.threads || [], total: data.total || 0, error: '' };
885
+ } catch (error) {
886
+ sessionManagementState = { loading: false, threads: [], total: 0, error: errorDetail(error) };
887
+ }
888
+ renderSessionManagementList();
889
+ }
890
+ async function openSessionManagement() {
891
+ sessionManagementOpen = true;
892
+ const modal = document.getElementById('sessionManagementModal');
893
+ modal.classList.remove('hidden');
894
+ restoreSessionManagementHeight();
895
+ await Promise.all([loadSessionManagement(), loadProcessManagement()]);
896
+ }
897
+ function closeSessionManagement(event) {
898
+ if (event?.target && event.target !== event.currentTarget) return;
899
+ sessionManagementOpen = false;
900
+ saveSessionManagementHeight();
901
+ document.getElementById('sessionManagementModal').classList.add('hidden');
902
+ }
903
+ async function refreshSessionManagement() {
904
+ if (!sessionManagementOpen) return;
905
+ await Promise.all([loadSessionManagement(), loadProcessManagement()]);
906
+ }
907
+ async function copySessionId(event, sessionId) {
908
+ event.stopPropagation();
909
+ try {
910
+ await navigator.clipboard.writeText(sessionId);
911
+ event.currentTarget.textContent = '✓';
912
+ setTimeout(() => { event.currentTarget.textContent = '⧉'; }, 900);
913
+ } catch {
914
+ prompt('复制 Codex 会话ID', sessionId);
915
+ }
916
+ }
917
+ function initSessionManagementResize() {
918
+ const card = document.getElementById('sessionManagementModalCard');
919
+ if (!card) return;
920
+ card.classList.add('resizable');
921
+ if (card.style) {
922
+ card.style.height = localStorage.getItem(SESSION_MODAL_HEIGHT_STORAGE_KEY) || '78vh';
923
+ card.style.minHeight = '380px';
924
+ }
925
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
926
+ window.addEventListener('mouseup', saveSessionManagementHeight);
927
+ }
928
+ }
669
929
  function updateRunNotice() {
670
930
  const el = document.getElementById('runNotice');
671
931
  if (!el) return;
@@ -677,12 +937,12 @@ function updateRunNotice() {
677
937
  const error = currentState.planner?.planParseError;
678
938
  if (currentState.status === 'plan_empty') {
679
939
  el.classList.remove('hidden');
680
- el.textContent = `Planner 已完成,但没有拆出任何任务。可以调整任务说明或直接再次点击“拆分任务”重试。${error ? `原因:${error}` : ''}`;
940
+ el.textContent = `Planner 已完成,但没有拆出任何任务。可以调整任务说明或直接再次点击"拆分任务"重试。${error ? `原因:${error}` : ''}`;
681
941
  return;
682
942
  }
683
943
  if (currentState.status === 'plan_failed' && error) {
684
944
  el.classList.remove('hidden');
685
- el.textContent = `Planner 拆分失败。可以再次点击“拆分任务”重试。原因:${error}`;
945
+ el.textContent = `Planner 拆分失败。可以再次点击"拆分任务"重试。原因:${error}`;
686
946
  return;
687
947
  }
688
948
  el.classList.add('hidden');
@@ -691,10 +951,10 @@ function updateRunNotice() {
691
951
  function updateAutoRefreshHint() {
692
952
  const el = document.getElementById('autoRefreshHint');
693
953
  if (!el) return;
694
- if (!selectedRun || !currentState) { el.textContent = '自动刷新:未启动'; return; }
954
+ if (!selectedRun || !currentState) { el.textContent = '自动刷新:未启动'; return; }
695
955
  const active = ['planning','running','judging','planned','batches_completed','batch_blocked'].includes(currentState.status) || (currentState.tasks || []).some(t => t.status === 'running') || currentState.planner?.status === 'running' || currentState.judge?.status === 'running';
696
956
  const last = lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发';
697
- el.textContent = active ? `自动模式中:每 ${AUTO_REFRESH_MS / 1000} 秒刷新并推进一次|上次刷新 ${last}` : `自动模式待命:每 ${AUTO_REFRESH_MS / 1000} 秒检查一次|上次刷新 ${last}`;
957
+ el.textContent = active ? `自动模式中:每 ${AUTO_REFRESH_MS / 1000} 秒刷新并推进一次|上次刷新 ${last}` : `自动模式待命:每 ${AUTO_REFRESH_MS / 1000} 秒检查一次|上次刷新 ${last}`;
698
958
  }
699
959
  function taskStatusCell(t) {
700
960
  if (t?.manualCompletion) {
@@ -708,7 +968,7 @@ function taskActionCell(id, t) {
708
968
  if (t.manualCompletion) return '<span class="muted">已人工确认</span>';
709
969
  if (!['unknown', 'failed'].includes(t.status)) return '-';
710
970
  const pending = pendingAction === `manual:${id}`;
711
- return `<button class="danger ${pending ? 'state-pending' : ''}"${pending ? ' disabled' : ''} onclick="markTaskCompleted(event, '${id}')">${pending ? '标记中…' : '手动标记成功'}</button>`;
971
+ return `<button class="danger ${pending ? 'state-pending' : ''}"${pending ? ' disabled' : ''} onclick="markTaskCompleted(event, '${id}')">${pending ? '标记中...' : '手动标记成功'}</button>`;
712
972
  }
713
973
  function shortSessionId(thread) {
714
974
  const text = String(thread || '');
@@ -716,7 +976,7 @@ function shortSessionId(thread) {
716
976
  }
717
977
  function sessionCell(thread) {
718
978
  if (!thread) return '-';
719
- return `<span class="session-cell" title="${esc(thread)}">…${esc(shortSessionId(thread))}</span><button class="copy-btn" title="复制完整 Codex 会话ID" onclick="copySessionId(event, '${esc(thread)}')">⧉</button>`;
979
+ return `<span class="session-cell-wrap"><span class="session-cell" title="${esc(thread)}">...${esc(shortSessionId(thread))}</span><button class="copy-btn" title="复制完整 Codex 会话ID" onclick="copySessionId(event, '${esc(thread)}')">⧉</button></span>`;
720
980
  }
721
981
  function taskStartedCell(t) {
722
982
  return t?.startedAt ? formatDateTime(t.startedAt) : '-';
@@ -1049,11 +1309,15 @@ async function renameRunLabel(event, runId = selectedRun) {
1049
1309
  });
1050
1310
  }
1051
1311
  async function planRun() { if (selectedRun) await runActionWithPending('plan', async () => { await api(`/api/runs/${selectedRun}/plan`, {method:'POST'}); await refreshSelected(); }); }
1052
- async function dispatchRun() { if (selectedRun) await runActionWithPending('dispatch', async () => { await api(`/api/runs/${selectedRun}/dispatch`, {method:'POST'}); await refreshSelected(); }); }
1312
+ async function dispatchRun() {
1313
+ if (!selectedRun) return;
1314
+ const endpoint = currentState?.status === 'batch_blocked' ? 'retry' : 'dispatch';
1315
+ await runActionWithPending('dispatch', async () => { await api(`/api/runs/${selectedRun}/${endpoint}`, {method:'POST'}); await refreshSelected(); });
1316
+ }
1053
1317
  async function judgeRun() { if (selectedRun) await runActionWithPending('judge', async () => { await api(`/api/runs/${selectedRun}/judge`, {method:'POST'}); await refreshSelected(); }); }
1054
1318
  async function stopSelectedRun() {
1055
1319
  if (!selectedRun) return;
1056
- const ok = confirm('确认停止当前任务批次?\n\n停止会终止仍在运行的 codex exec 子进程,并冻结后续调度。');
1320
+ const ok = confirm('确认停止当前任务批次?\n\n停止会终止仍在运行的 codex exec 子进程,并冻结后续调度。');
1057
1321
  if (!ok) return;
1058
1322
  await runActionWithPending('stop', async () => {
1059
1323
  await api(`/api/runs/${selectedRun}/stop`, {
@@ -1081,7 +1345,7 @@ async function archiveRunFromCard(event, runId) {
1081
1345
  async function archiveRunById(runId, { confirmFirst = true } = {}) {
1082
1346
  if (!runId) return;
1083
1347
  if (confirmFirst) {
1084
- const ok = confirm('确认归档当前任务批次?\n\n归档后会从默认任务批次列表隐藏。若仍有任务运行,请先停止。');
1348
+ const ok = confirm('确认归档当前任务批次?\n\n归档后会从默认任务批次列表隐藏。若仍有任务运行,请先停止。');
1085
1349
  if (!ok) return;
1086
1350
  }
1087
1351
  await runActionWithPending('archive', async () => {
@@ -1108,7 +1372,7 @@ async function markTaskCompleted(event, taskId) {
1108
1372
  event.stopPropagation();
1109
1373
  if (!selectedRun || taskId === 'planner' || taskId === 'judge') return;
1110
1374
  const task = (currentState?.tasks || []).find(t => t.id === taskId);
1111
- if (task?.status === 'running') { alert('任务仍在执行中,不能手动标记成功。'); return; }
1375
+ if (task?.status === 'running') { alert('任务仍在执行中,不能手动标记成功。'); return; }
1112
1376
  manualCompleteTaskId = taskId;
1113
1377
  document.getElementById('manualCompleteTitle').textContent = `${selectedRun} / ${taskId}`;
1114
1378
  document.getElementById('manualCompleteResult').value = '';
@@ -1137,6 +1401,7 @@ async function submitManualComplete() {
1137
1401
  }
1138
1402
 
1139
1403
  initializeWorkerSandboxPreference();
1404
+ initSessionManagementResize();
1140
1405
  renderActionToolbar();
1141
1406
  loadCodexStatus().catch(console.error);
1142
1407
  loadHealth().then(refreshRuns);
@@ -500,6 +500,27 @@ async function rotatePlannerAttempt(state, runDir) {
500
500
  }];
501
501
  }
502
502
 
503
+ async function rotateJudgeAttempt(state, runDir) {
504
+ const judgeDir = roleDir(runDir, 'judge');
505
+ if (!fs.existsSync(judgeDir)) return;
506
+ const attemptsDir = path.join(runDir, 'judge_attempts');
507
+ await ensureDir(attemptsDir);
508
+ const previousAttempts = (state.judgeAttempts || []).map(item => Number(item.attempt || 0)).filter(Number.isFinite);
509
+ const attempt = Number(state.judge?.attempt || 0) || Math.max(1, 1 + Math.max(0, ...previousAttempts));
510
+ const archivedDir = path.join(attemptsDir, `attempt-${String(attempt).padStart(2, '0')}`);
511
+ await fsp.rm(archivedDir, { recursive: true, force: true });
512
+ await fsp.rename(judgeDir, archivedDir);
513
+ state.judgeAttempts = [...(state.judgeAttempts || []), {
514
+ attempt,
515
+ status: state.judge?.status,
516
+ exitCode: state.judge?.exitCode ?? null,
517
+ startedAt: state.judge?.startedAt,
518
+ endedAt: state.judge?.endedAt,
519
+ archivedDir,
520
+ archivedAt: nowIso()
521
+ }];
522
+ }
523
+
503
524
  async function rotateWorkerAttempt(state, task) {
504
525
  const runDir = pathForRun(state.runId);
505
526
  const workerDir = roleDir(runDir, 'worker', task.id);
@@ -792,16 +813,26 @@ export async function startJudge(runId) {
792
813
  return await withRunStateLock(runId, async () => {
793
814
  const state = await loadRun(runId);
794
815
  if (!state) throw new Error(`run not found: ${runId}`);
816
+ if (state.archived) throw new Error('archived run cannot be judged');
817
+ if (state.status === 'stopped') throw new Error('stopped run cannot be judged');
795
818
  recomputeRunStatus(state);
819
+ if (state.judge?.status === 'running') throw new Error('judge already running');
820
+ if (state.judge?.status === 'completed') throw new Error('judge already completed');
796
821
  if (!allBatchesCompleted(state) && state.tasks?.length) throw new Error('final judge is allowed only after all batches completed');
797
- const outDir = roleDir(pathForRun(runId), 'judge');
822
+ const runDir = pathForRun(runId);
823
+ if (state.judge?.status === 'failed') await rotateJudgeAttempt(state, runDir);
824
+ const outDir = roleDir(runDir, 'judge');
798
825
  await ensureDir(outDir);
799
826
  const judgeInputPath = path.join(outDir, 'judge_input.json');
800
827
  const judgeInput = await buildJudgeInput(state);
801
828
  await writeJsonAtomic(judgeInputPath, judgeInput);
802
829
  const prompt = defaultJudgePrompt(state, judgeInputPath);
803
- const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(pathForRun(runId)), prompt, sandbox: 'read-only', cwd: workspacePathOf(state), outDir });
804
- state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir };
830
+ const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(runDir), prompt, sandbox: 'read-only', cwd: workspacePathOf(state), outDir });
831
+ const previousJudgeAttempts = [
832
+ ...(state.judgeAttempts || []).map(item => Number(item.attempt || 0)),
833
+ Number(state.judge?.attempt || 0)
834
+ ].filter(Number.isFinite);
835
+ state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir, attempt: 1 + Math.max(0, ...previousJudgeAttempts) };
805
836
  state.status = 'judging';
806
837
  await saveRun(state);
807
838
  child.onExit(async code => {
package/src/server.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import http from 'node:http';
2
+ import { execFile } from 'node:child_process';
2
3
  import fsp from 'node:fs/promises';
3
4
  import path from 'node:path';
5
+ import { promisify } from 'node:util';
4
6
  import { fileURLToPath } from 'node:url';
5
7
  import { CodexAppServerClient } from './appServerClient.js';
6
8
  import { APP_ROOT, CODEX_BIN, DEFAULT_WORKSPACE, DEFAULT_REPO, PACKAGE_VERSION, RUNNER, RUNS_DIR, detectCodexInfo } from './utils.js';
@@ -8,6 +10,9 @@ import { createRun, listRuns, startPlanner, dispatchRun, startJudge, refreshRun,
8
10
  import { startAutoScheduler } from './scheduler.js';
9
11
 
10
12
  const PUBLIC_DIR = path.join(APP_ROOT, 'public');
13
+ const CODEX_INFO_TTL_MS = 30000;
14
+ const execFileAsync = promisify(execFile);
15
+ let codexInfoCache = null;
11
16
 
12
17
  function send(res, status, body, type = 'application/json') {
13
18
  const data = type === 'application/json' ? JSON.stringify(body, null, 2) : body;
@@ -26,6 +31,157 @@ async function readBody(req) {
26
31
  function notFound(res) { send(res, 404, { error: 'not found' }); }
27
32
  function methodNotAllowed(res) { send(res, 405, { error: 'method not allowed' }); }
28
33
 
34
+ async function cachedCodexInfo(nowMs = Date.now()) {
35
+ if (codexInfoCache && codexInfoCache.expiresAt > nowMs) return codexInfoCache.value;
36
+ const value = await detectCodexInfo();
37
+ codexInfoCache = { value, expiresAt: nowMs + CODEX_INFO_TTL_MS };
38
+ return value;
39
+ }
40
+
41
+ function threadTimeValue(thread, keys) {
42
+ for (const key of keys) {
43
+ const value = thread?.[key];
44
+ if (typeof value === 'string' && value.trim()) return value;
45
+ }
46
+ return '';
47
+ }
48
+
49
+ function hasAppServerThreadMarkers(thread) {
50
+ const preview = String(thread?.preview || '');
51
+ return preview.includes('ORCHESTRATOR_RUN_ID: ') && preview.includes('ORCHESTRATOR_TASK_ID: ');
52
+ }
53
+
54
+ function previewThreadTitle(preview) {
55
+ const lines = String(preview || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
56
+ for (const line of lines) {
57
+ if (/^ORCHESTRATOR_[A-Z_]+:\s*/.test(line)) continue;
58
+ if (/^[A-Z0-9_]+:\s*/.test(line)) continue;
59
+ if (line === '{' || line === '}' || line === '[' || line === ']') continue;
60
+ return line;
61
+ }
62
+ return '';
63
+ }
64
+
65
+ function normalizeStringField(value) {
66
+ return typeof value === 'string' && value.trim() ? value.trim() : '';
67
+ }
68
+
69
+ function normalizeThreadStatus(thread) {
70
+ const rawStatus = thread?.status;
71
+ if (typeof rawStatus === 'string' && rawStatus.trim()) return rawStatus.trim();
72
+ if (rawStatus && typeof rawStatus === 'object') {
73
+ const candidate = rawStatus.label || rawStatus.name || rawStatus.state || rawStatus.status || rawStatus.text;
74
+ if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
75
+ }
76
+ const fallback = normalizeStringField(thread?.state);
77
+ return fallback || 'unknown';
78
+ }
79
+
80
+ function normalizeThreadSource(thread) {
81
+ const rawSource = thread?.source;
82
+ if (typeof rawSource === 'string' && rawSource.trim()) return rawSource.trim();
83
+ if (rawSource && typeof rawSource === 'object') {
84
+ const candidate = rawSource.label || rawSource.name || rawSource.kind || rawSource.source;
85
+ if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
86
+ }
87
+ return normalizeStringField(thread?.sourceKind) || normalizeStringField(thread?.source_kind) || '';
88
+ }
89
+
90
+ function normalizeAppServerThread(thread) {
91
+ const sessionId = String(thread?.sessionId || thread?.session_id || thread?.id || thread?.threadId || '').trim();
92
+ const startedAt = threadTimeValue(thread, ['startedAt', 'started_at', 'createdAt', 'created_at', 'createdTime', 'created_time']);
93
+ const updatedAt = threadTimeValue(thread, ['updatedAt', 'updated_at', 'modifiedAt', 'modified_at', 'lastActiveAt', 'last_active_at']) || startedAt;
94
+ const previewTitle = previewThreadTitle(thread?.preview);
95
+ const rawTitle = normalizeStringField(thread?.title) || normalizeStringField(thread?.name) || normalizeStringField(thread?.subject);
96
+ const title = rawTitle || previewTitle || '会话';
97
+ return {
98
+ id: String(thread?.id || sessionId || '').trim(),
99
+ title,
100
+ sessionId: sessionId || String(thread?.id || '').trim(),
101
+ status: normalizeThreadStatus(thread),
102
+ source: normalizeThreadSource(thread),
103
+ startedAt,
104
+ updatedAt,
105
+ boardManaged: hasAppServerThreadMarkers(thread)
106
+ };
107
+ }
108
+
109
+ function sortThreadListDesc(left, right) {
110
+ const leftMs = Date.parse(left.updatedAt || left.startedAt || '') || 0;
111
+ const rightMs = Date.parse(right.updatedAt || right.startedAt || '') || 0;
112
+ return rightMs - leftMs || String(right.sessionId || '').localeCompare(String(left.sessionId || ''));
113
+ }
114
+
115
+ function parsePsElapsedSeconds(value) {
116
+ const text = String(value || '').trim();
117
+ if (!text) return null;
118
+ const dayParts = text.split('-');
119
+ const days = dayParts.length === 2 ? Number(dayParts[0]) || 0 : 0;
120
+ const timeText = dayParts.at(-1) || '';
121
+ const parts = timeText.split(':').map(part => Number(part) || 0);
122
+ if (parts.length === 3) return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2];
123
+ if (parts.length === 2) return days * 86400 + parts[0] * 60 + parts[1];
124
+ return days * 86400 + (parts[0] || 0);
125
+ }
126
+
127
+ function isCodexRelatedCommand(command) {
128
+ const text = String(command || '').toLowerCase();
129
+ if (!text.includes('codex')) return false;
130
+ return /(^|[\s/.-])codex([\s/.-]|$)/.test(text) || text.includes('@openai/codex') || text.includes('codex-cli') || text.includes('app-server');
131
+ }
132
+
133
+ async function listCodexProcesses() {
134
+ if (process.platform === 'win32') {
135
+ const { stdout } = await execFileAsync('powershell', ['-NoProfile', '-Command', `Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine,WorkingSetSize,CreationDate | ConvertTo-Json -Compress`], { maxBuffer: 1024 * 1024 });
136
+ const parsed = stdout ? JSON.parse(stdout) : [];
137
+ const processes = (Array.isArray(parsed) ? parsed : [parsed])
138
+ .map(item => {
139
+ const command = String(item?.CommandLine || '').trim();
140
+ if (!isCodexRelatedCommand(command)) return null;
141
+ const sessionMatch = command.match(/\bcodex\s+resume\s+([A-Za-z0-9._:-]+)\b/i);
142
+ return {
143
+ pid: Number(item?.ProcessId) || 0,
144
+ ppid: Number(item?.ParentProcessId) || 0,
145
+ cpuPercent: 0,
146
+ memoryPercent: 0,
147
+ rssMb: Math.round(((Number(item?.WorkingSetSize) || 0) / 1024 / 1024) * 10) / 10,
148
+ elapsed: '',
149
+ elapsedSeconds: null,
150
+ command,
151
+ sessionId: sessionMatch?.[1] || '',
152
+ isSelf: Number(item?.ProcessId) === process.pid
153
+ };
154
+ })
155
+ .filter(Boolean)
156
+ .sort((left, right) => right.rssMb - left.rssMb || left.pid - right.pid);
157
+ return processes;
158
+ }
159
+ const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,ppid=,pcpu=,pmem=,rss=,etime=,command='], { maxBuffer: 1024 * 1024 });
160
+ const processes = stdout.split(/\r?\n/)
161
+ .map(line => {
162
+ const match = String(line || '').match(/^\s*(\d+)\s+(\d+)\s+([0-9.]+)\s+([0-9.]+)\s+(\d+)\s+(\S+)\s+(.+)$/);
163
+ if (!match) return null;
164
+ const [, pid, ppid, cpu, mem, rssKb, elapsed, command] = match;
165
+ const sessionMatch = command.match(/\bcodex\s+resume\s+([A-Za-z0-9._:-]+)\b/i);
166
+ return {
167
+ pid: Number(pid),
168
+ ppid: Number(ppid),
169
+ cpuPercent: Number(cpu) || 0,
170
+ memoryPercent: Number(mem) || 0,
171
+ rssMb: Math.round(((Number(rssKb) || 0) / 1024) * 10) / 10,
172
+ elapsed,
173
+ elapsedSeconds: parsePsElapsedSeconds(elapsed),
174
+ command: command.trim(),
175
+ sessionId: sessionMatch?.[1] || '',
176
+ isSelf: Number(pid) === process.pid
177
+ };
178
+ })
179
+ .filter(Boolean)
180
+ .filter(processInfo => isCodexRelatedCommand(processInfo.command))
181
+ .sort((left, right) => right.rssMb - left.rssMb || left.pid - right.pid);
182
+ return processes;
183
+ }
184
+
29
185
  async function serveStatic(req, res, pathname) {
30
186
  let file = pathname === '/' ? path.join(PUBLIC_DIR, 'index.html') : path.join(PUBLIC_DIR, pathname.replace(/^\/+/, ''));
31
187
  file = path.resolve(file);
@@ -46,7 +202,17 @@ async function handleApi(req, res, url, appClient) {
46
202
  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
203
  }
48
204
  if (req.method === 'GET' && url.pathname === '/api/codex') {
49
- return send(res, 200, { ok: true, codex: await detectCodexInfo() });
205
+ return send(res, 200, { ok: true, codex: await cachedCodexInfo() });
206
+ }
207
+ if (req.method === 'GET' && url.pathname === '/api/session-management') {
208
+ const limit = Math.min(200, Math.max(1, Number(url.searchParams.get('limit') || 100) || 100));
209
+ const response = await appClient.listThreads({ limit });
210
+ const threads = (response?.data || []).map(normalizeAppServerThread).filter(thread => thread.sessionId).sort(sortThreadListDesc);
211
+ return send(res, 200, { ok: true, threads, total: threads.length });
212
+ }
213
+ if (req.method === 'GET' && url.pathname === '/api/session-management/processes') {
214
+ const processes = await listCodexProcesses();
215
+ return send(res, 200, { ok: true, processes, total: processes.length });
50
216
  }
51
217
  if (parts[1] === 'runs' && parts.length === 2) {
52
218
  if (req.method === 'GET') return send(res, 200, { runs: await listRuns({ includeArchived: url.searchParams.get('includeArchived') === '1', workspace: url.searchParams.get('workspace') || '' }) });