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 +16 -0
- package/package.json +1 -1
- package/public/index.html +289 -28
- package/src/server.js +157 -0
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
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:
|
|
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(
|
|
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="批次列表尚未加载"
|
|
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"
|
|
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
|
|
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
|
|
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)}
|
|
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
|
|
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
|
|
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()"
|
|
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 =
|
|
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
|
|
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="
|
|
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: '
|
|
580
|
-
dispatch: { label: '
|
|
581
|
-
judge: { label: '
|
|
582
|
-
stop: { label: '
|
|
583
|
-
archive: { label: '
|
|
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
|
|
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
|
|
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 = '
|
|
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 ?
|
|
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 ? '
|
|
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)}"
|
|
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('
|
|
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('
|
|
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('
|
|
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') {
|