input-kanban 0.0.15 → 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,21 @@
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
+
3
19
  ## v0.0.15
4
20
 
5
21
  ### Highlights
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "input-kanban",
3
- "version": "0.0.15",
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
@@ -141,11 +141,47 @@
141
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); }
142
142
  .file-content-wrap:hover .floating-copy-btn:not(.hidden), .floating-copy-btn:focus { opacity: 1; pointer-events: auto; }
143
143
  .floating-copy-btn:not(.hidden) + pre { padding-top: 42px; }
144
- .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; }
145
145
  .modal-backdrop.hidden { display: none; }
146
- .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; }
147
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; }
148
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; }
149
185
  </style>
150
186
  </head>
151
187
  <body>
@@ -159,7 +195,7 @@
159
195
  </div>
160
196
  <div class="workspace-filter-panel">
161
197
  <select id="workspaceFilterSelect" class="workspace-filter-select" onchange="setWorkspaceFilter(this.value)" title="未筛选工作区"></select>
162
- <span id="runsLoadHint" class="runs-load-icon" title="批次列表尚未加载" aria-label="批次列表尚未加载">ⓘ</span>
198
+ <span id="runsLoadHint" class="runs-load-icon" title="批次列表尚未加载" aria-label="批次列表尚未加载">i</span>
163
199
  </div>
164
200
  <div id="runs" class="run-list"><div class="empty">批次列表加载中...</div></div>
165
201
  </section>
@@ -190,7 +226,7 @@
190
226
  <h2>批次详情</h2>
191
227
  <div class="build-header">
192
228
  <div id="selected" class="muted">未选择任务批次</div>
193
- <div id="autoRefreshHint" class="muted hidden">自动刷新:未启动</div>
229
+ <div id="autoRefreshHint" class="muted hidden">自动刷新:未启动</div>
194
230
  <div id="runNotice" class="notice warning hidden"></div>
195
231
  <div id="actionToolbar" class="toolbar"></div>
196
232
  </div>
@@ -202,7 +238,7 @@
202
238
  <section id="filePanel" class="log-panel">
203
239
  <div class="section-header">
204
240
  <h2>任务详情</h2>
205
- <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>
206
242
  </div>
207
243
  <div id="fileTitle" class="muted">点击任务后查看详情</div>
208
244
  <div id="fileTabs" class="toolbar file-tabs"></div>
@@ -214,14 +250,34 @@
214
250
  </section>
215
251
  </div>
216
252
  </main>
217
- <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>
218
274
  <div id="manualCompleteModal" class="modal-backdrop hidden">
219
275
  <div class="modal-card">
220
276
  <h2>手动标记成功</h2>
221
277
  <div id="manualCompleteTitle" class="muted"></div>
222
278
  <label>人工成功执行结果</label>
223
279
  <textarea id="manualCompleteResult" placeholder="粘贴 codex resume 后的最终回复、验证结果、关键证据或 artifact 路径"></textarea>
224
- <div class="muted">该内容会保存为 manual_result.md,显示在“结果”中,并作为 final judge 的人工成功证据。</div>
280
+ <div class="muted">该内容会保存为 manual_result.md,显示在"结果"中,并作为 final judge 的人工成功证据。</div>
225
281
  <div class="toolbar">
226
282
  <button onclick="submitManualComplete()">确认标记成功</button>
227
283
  <button class="secondary" onclick="closeManualCompleteModal()">取消</button>
@@ -237,6 +293,11 @@ let pendingArchiveRunId = null;
237
293
  let pendingAction = null;
238
294
  let currentState = null;
239
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: '' };
240
301
  let runListVisibleCount = 10;
241
302
  let latestRuns = [];
242
303
  let workspaceCatalogRuns = [];
@@ -314,7 +375,7 @@ function runDurationEnd(s) {
314
375
  ...(s.tasks || []).flatMap(task => [task.endedAt, task.completedAt, task.stoppedAt])
315
376
  ]) || s.updatedAt;
316
377
  }
317
- 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)}`; }
318
379
  function basenamePath(value) {
319
380
  const parts = String(value || '').split(/[\\/]/).filter(Boolean);
320
381
  return parts.at(-1) || value || '-';
@@ -389,14 +450,14 @@ async function loadCodexStatus() {
389
450
  el.classList.remove('hidden', 'warning');
390
451
  if (!codex.installed) {
391
452
  el.classList.add('warning');
392
- 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>`;
393
454
  } else {
394
455
  el.innerHTML = `<code>${esc(codex.versionText || codex.installedVersion || 'codex')}</code>`;
395
456
  }
396
457
  } catch (error) {
397
458
  el.classList.remove('hidden');
398
459
  el.classList.add('warning');
399
- el.innerHTML = `Codex:检测失败|${esc(errorDetail(error))}`;
460
+ el.innerHTML = `Codex:检测失败|${esc(errorDetail(error))}`;
400
461
  }
401
462
  }
402
463
  function showCreateForm() {
@@ -474,7 +535,7 @@ function renderRunList() {
474
535
  </div>
475
536
  </div>`);
476
537
  if (latestRuns.length > runListVisibleCount) {
477
- 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>`);
478
539
  }
479
540
  document.getElementById('runs').innerHTML = latestRuns.length ? cards.join('') : '<div class="empty">暂无任务批次</div>';
480
541
  }
@@ -513,7 +574,7 @@ function updateWorkspaceFilterTitle() {
513
574
  if (selectedWorkspaceFilter === WORKSPACE_FILTER_ALL) {
514
575
  select.title = currentWorkspacePath ? `未筛选工作区|默认工作区:${currentWorkspacePath}` : '未筛选工作区';
515
576
  } else {
516
- select.title = `当前筛选:${selectedWorkspaceFilter}`;
577
+ select.title = `当前筛选:${selectedWorkspaceFilter}`;
517
578
  }
518
579
  }
519
580
  function setWorkspaceFilter(value) {
@@ -536,11 +597,12 @@ async function refreshSelected({auto=false} = {}) {
536
597
  await loadTaskDescription();
537
598
  renderTasks(); await refreshRuns();
538
599
  if (selectedTask && selectedFileName) await loadFile(selectedFileName, { preserveScroll: true });
600
+ if (sessionManagementOpen) await refreshSessionManagement();
539
601
  }
540
602
  function renderSelectedHeader() {
541
603
  if (!currentState) return '<div class="muted">未选择任务批次</div>';
542
604
  const sandbox = currentState.workerSandbox || 'workspace-write';
543
- 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>`];
544
606
  if (currentState.runner === 'tmux' && hasRunTmuxMetadata(currentState)) {
545
607
  titleActions.push(`<button class="secondary copy-btn title-copy-btn" title="复制 tmux attach 指令" data-copy-kind="tmux" onclick="copyTmuxRunCommand(event)">${titleCopyLabel('tmux')}</button>`);
546
608
  }
@@ -566,7 +628,7 @@ async function loadTaskDescription() {
566
628
  }
567
629
  function refreshPulseChip() {
568
630
  const last = lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发';
569
- 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>`;
570
632
  }
571
633
  function actionButton({ key, label, onclick, variant = '', state = '', disabled = false, title = '' }) {
572
634
  const classes = [variant, state ? `state-${state}` : ''].filter(Boolean).join(' ');
@@ -576,11 +638,11 @@ function runActionState(key) {
576
638
  if (!selectedRun || !currentState) return { label: '-', disabled: true, state: '' };
577
639
  if (pendingAction === key) {
578
640
  return {
579
- plan: { label: '拆分中…', disabled: true, state: 'pending' },
580
- dispatch: { label: '启动中…', disabled: true, state: 'pending' },
581
- judge: { label: '验收中…', disabled: true, state: 'pending' },
582
- stop: { label: '停止中…', disabled: true, state: 'pending' },
583
- 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' }
584
646
  }[key];
585
647
  }
586
648
  const status = currentState.status;
@@ -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-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>`;
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) : '-';
@@ -1057,7 +1317,7 @@ async function dispatchRun() {
1057
1317
  async function judgeRun() { if (selectedRun) await runActionWithPending('judge', async () => { await api(`/api/runs/${selectedRun}/judge`, {method:'POST'}); await refreshSelected(); }); }
1058
1318
  async function stopSelectedRun() {
1059
1319
  if (!selectedRun) return;
1060
- const ok = confirm('确认停止当前任务批次?\n\n停止会终止仍在运行的 codex exec 子进程,并冻结后续调度。');
1320
+ const ok = confirm('确认停止当前任务批次?\n\n停止会终止仍在运行的 codex exec 子进程,并冻结后续调度。');
1061
1321
  if (!ok) return;
1062
1322
  await runActionWithPending('stop', async () => {
1063
1323
  await api(`/api/runs/${selectedRun}/stop`, {
@@ -1085,7 +1345,7 @@ async function archiveRunFromCard(event, runId) {
1085
1345
  async function archiveRunById(runId, { confirmFirst = true } = {}) {
1086
1346
  if (!runId) return;
1087
1347
  if (confirmFirst) {
1088
- const ok = confirm('确认归档当前任务批次?\n\n归档后会从默认任务批次列表隐藏。若仍有任务运行,请先停止。');
1348
+ const ok = confirm('确认归档当前任务批次?\n\n归档后会从默认任务批次列表隐藏。若仍有任务运行,请先停止。');
1089
1349
  if (!ok) return;
1090
1350
  }
1091
1351
  await runActionWithPending('archive', async () => {
@@ -1112,7 +1372,7 @@ async function markTaskCompleted(event, taskId) {
1112
1372
  event.stopPropagation();
1113
1373
  if (!selectedRun || taskId === 'planner' || taskId === 'judge') return;
1114
1374
  const task = (currentState?.tasks || []).find(t => t.id === taskId);
1115
- if (task?.status === 'running') { alert('任务仍在执行中,不能手动标记成功。'); return; }
1375
+ if (task?.status === 'running') { alert('任务仍在执行中,不能手动标记成功。'); return; }
1116
1376
  manualCompleteTaskId = taskId;
1117
1377
  document.getElementById('manualCompleteTitle').textContent = `${selectedRun} / ${taskId}`;
1118
1378
  document.getElementById('manualCompleteResult').value = '';
@@ -1141,6 +1401,7 @@ async function submitManualComplete() {
1141
1401
  }
1142
1402
 
1143
1403
  initializeWorkerSandboxPreference();
1404
+ initSessionManagementResize();
1144
1405
  renderActionToolbar();
1145
1406
  loadCodexStatus().catch(console.error);
1146
1407
  loadHealth().then(refreshRuns);
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';
@@ -9,6 +11,7 @@ import { startAutoScheduler } from './scheduler.js';
9
11
 
10
12
  const PUBLIC_DIR = path.join(APP_ROOT, 'public');
11
13
  const CODEX_INFO_TTL_MS = 30000;
14
+ const execFileAsync = promisify(execFile);
12
15
  let codexInfoCache = null;
13
16
 
14
17
  function send(res, status, body, type = 'application/json') {
@@ -35,6 +38,150 @@ async function cachedCodexInfo(nowMs = Date.now()) {
35
38
  return value;
36
39
  }
37
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
+
38
185
  async function serveStatic(req, res, pathname) {
39
186
  let file = pathname === '/' ? path.join(PUBLIC_DIR, 'index.html') : path.join(PUBLIC_DIR, pathname.replace(/^\/+/, ''));
40
187
  file = path.resolve(file);
@@ -57,6 +204,16 @@ async function handleApi(req, res, url, appClient) {
57
204
  if (req.method === 'GET' && url.pathname === '/api/codex') {
58
205
  return send(res, 200, { ok: true, codex: await cachedCodexInfo() });
59
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 });
216
+ }
60
217
  if (parts[1] === 'runs' && parts.length === 2) {
61
218
  if (req.method === 'GET') return send(res, 200, { runs: await listRuns({ includeArchived: url.searchParams.get('includeArchived') === '1', workspace: url.searchParams.get('workspace') || '' }) });
62
219
  if (req.method === 'POST') {