input-kanban 0.0.3 → 0.0.4

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/public/index.html CHANGED
@@ -30,16 +30,13 @@
30
30
  th:nth-child(2), td:nth-child(2) { width: 92px; white-space: nowrap; }
31
31
  th:nth-child(3), td:nth-child(3) { width: 132px; }
32
32
  th:nth-child(4), td:nth-child(4) { width: 64px; white-space: nowrap; }
33
- th:nth-child(5), td:nth-child(5) { width: 70px; }
34
- th:nth-child(6), td:nth-child(6) { width: 58px; }
35
- th:nth-child(8), td:nth-child(8) { width: 118px; }
36
- th:nth-child(9), td:nth-child(9) { width: 66px; }
37
- th:nth-child(10), td:nth-child(10) { width: 94px; }
33
+ th:nth-child(5), td:nth-child(5) { width: 96px; }
34
+ th:nth-child(6), td:nth-child(6) { width: 92px; }
35
+ th:nth-child(7), td:nth-child(7) { width: 66px; }
36
+ th:nth-child(8), td:nth-child(8) { width: 94px; }
38
37
  th { color: #cbd5e1; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
39
38
  tr:hover { background: #162033; cursor: pointer; }
40
39
  .pill { display: inline-block; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 800; background: var(--gray); line-height: 1.3; }
41
- .attention-hint { display: block; margin-top: 6px; color: #fbbf24; font-size: 12px; font-weight: 700; white-space: normal; line-height: 1.35; }
42
- .attention-hint code { color: #fde68a; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; word-break: break-all; }
43
40
  .completed, .judged, .workers_completed, .planned, .batches_completed { background: var(--green); }
44
41
  .running, .planning, .judging { background: var(--blue); }
45
42
  .failed, .workers_failed, .plan_failed, .judge_failed, .unknown, .batch_blocked { background: var(--red); }
@@ -68,22 +65,25 @@
68
65
  .run-card-progress { margin-top: 8px; display: flex; justify-content: space-between; color: var(--muted); font-size: 12px; }
69
66
  .build-header { border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: var(--panel-2); margin-bottom: 14px; }
70
67
  .build-title { display: flex; align-items: center; gap: 10px; font-size: 22px; font-weight: 900; }
71
- .build-meta { margin-top: 6px; color: var(--muted); word-break: break-all; }
68
+ .build-meta { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
69
+ .meta-chip { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: #020617; color: #cbd5e1; font-size: 12px; line-height: 1.2; }
70
+ .meta-chip.danger { border-color: #f97316; color: #fed7aa; background: rgba(180,83,9,.14); }
71
+ .meta-label { color: var(--muted); font-weight: 800; white-space: nowrap; }
72
+ .meta-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
73
+ .meta-chip.long .meta-value { max-width: min(680px, 72vw); }
74
+ .meta-chip .copy-btn { margin-left: 2px; }
72
75
  .log-panel { margin-top: 16px; }
73
76
  .file-tabs button { font-size: 13px; }
74
77
  .copy-btn { padding: 2px 6px; margin: 0 0 0 6px; border-radius: 6px; font-size: 12px; line-height: 1.2; background: var(--gray); vertical-align: middle; }
75
78
  .session-cell { word-break: break-all; }
79
+ .row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
80
+ .row-actions .danger, .row-actions .secondary { margin: 0; }
81
+ .status-stack { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 5px; }
76
82
  .execution-summary { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 8px 0 -2px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px 10px 0 0; background: #020617; color: var(--muted); font-size: 12px; }
77
83
  .execution-summary.hidden { display: none; }
78
84
  .execution-summary + pre { border-top-left-radius: 0; border-top-right-radius: 0; }
79
85
  .notice { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #1f2937; color: #cbd5e1; }
80
86
  .notice.warning { border-color: #92400e; background: rgba(180,83,9,.18); }
81
- .tmux-box { margin: 8px 0 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #020617; color: #cbd5e1; font-size: 12px; }
82
- .tmux-box.hidden { display: none; }
83
- .tmux-box-title { font-weight: 800; color: var(--text); margin-bottom: 5px; }
84
- .tmux-actions { margin-top: 7px; display: flex; flex-wrap: wrap; gap: 4px; }
85
- .tmux-actions button { margin: 0; }
86
- .tmux-inline { display: block; margin-top: 3px; word-break: break-all; }
87
87
  .file-content-wrap { position: relative; }
88
88
  .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); }
89
89
  .file-content-wrap:hover .floating-copy-btn:not(.hidden), .floating-copy-btn:focus { opacity: 1; pointer-events: auto; }
@@ -110,6 +110,12 @@
110
110
  <label>目标仓库</label><input id="repo" />
111
111
  <label>运行目录</label><input id="runsDir" readonly />
112
112
  <label>最大并发数</label><input id="maxParallel" type="number" value="3" min="1" max="16" />
113
+ <label>Worker 沙箱</label><select id="workerSandbox">
114
+ <option value="workspace-write" selected>workspace-write(默认,允许写当前仓库)</option>
115
+ <option value="read-only">read-only(只读)</option>
116
+ <option value="danger-full-access">danger-full-access(高风险,跳过沙箱限制)</option>
117
+ </select>
118
+ <div class="muted">仅影响 worker;任务拆分和汇总验收仍保持 read-only。</div>
113
119
  <label>任务说明</label><textarea id="taskText" placeholder="粘贴任务说明"></textarea>
114
120
  <div class="toolbar">
115
121
  <button onclick="createRun()">创建批次</button>
@@ -121,7 +127,7 @@
121
127
  <h2>批次详情</h2>
122
128
  <div class="build-header">
123
129
  <div id="selected" class="muted">未选择任务批次</div>
124
- <div id="autoRefreshHint" class="muted">自动刷新:未启动</div>
130
+ <div id="autoRefreshHint" class="muted hidden">自动刷新:未启动</div>
125
131
  <div id="runNotice" class="notice warning hidden"></div>
126
132
  <div class="toolbar">
127
133
  <button onclick="planRun()">拆分任务</button>
@@ -140,20 +146,8 @@
140
146
  <section id="filePanel" class="log-panel">
141
147
  <h2>文件查看</h2>
142
148
  <div id="fileTitle" class="muted">点击任务后选择文件</div>
143
- <div class="toolbar file-tabs">
144
- <button class="secondary" onclick="loadFile('prompt.md')">提示词</button>
145
- <button class="secondary" onclick="loadFile('events.jsonl')">事件日志</button>
146
- <button class="secondary" onclick="loadFile('events.pretty')">执行过程</button>
147
- <button class="secondary" onclick="loadFile('stderr.log')">错误日志</button>
148
- <button class="secondary" onclick="loadFile('last_message.md')">最终回复</button>
149
- <button class="secondary" onclick="loadFile('exit_code')">退出码</button>
150
- <button class="secondary" onclick="loadFile('result.json')">结果</button>
151
- <button class="secondary" onclick="loadFile('evidence.json')">证据</button>
152
- <button class="secondary" onclick="loadFile('judge_input.json')">验收输入</button>
153
- <button class="secondary" onclick="loadFile('verdict.json')">验收结论</button>
154
- </div>
149
+ <div id="fileTabs" class="toolbar file-tabs"></div>
155
150
  <div id="executionSummary" class="execution-summary hidden"></div>
156
- <div id="tmuxPanel" class="tmux-box hidden"></div>
157
151
  <div class="file-content-wrap">
158
152
  <button id="copyLastMessageBtn" class="secondary copy-btn floating-copy-btn hidden" title="复制最终回复内容" onclick="copyFileContent(event)">⧉</button>
159
153
  <pre id="fileContent"></pre>
@@ -169,6 +163,7 @@ let currentState = null;
169
163
  let lastAutoRefreshAt = null;
170
164
  let runListVisibleCount = 10;
171
165
  let latestRuns = [];
166
+ const statusByRunId = new Map();
172
167
  const AUTO_REFRESH_MS = 3000;
173
168
  const RUN_LIST_PAGE_SIZE = 10;
174
169
 
@@ -201,7 +196,31 @@ function durationSeconds(start, end) {
201
196
  const endMs = end ? new Date(end).getTime() : Date.now();
202
197
  return Math.max(0, Math.round((endMs - new Date(start).getTime()) / 1000));
203
198
  }
204
- function runTimingText(s) { return `开始时刻 ${formatDateTime(s.createdAt)}|用时 ${durationSeconds(s.createdAt, s.updatedAt)} 秒`; }
199
+ function maxIsoTime(values) {
200
+ const times = values.map(value => Date.parse(value || '')).filter(Number.isFinite);
201
+ if (!times.length) return null;
202
+ return new Date(Math.max(...times)).toISOString();
203
+ }
204
+ function runDurationEnd(s) {
205
+ if (!s) return null;
206
+ const terminalStatuses = new Set(['judged', 'judge_failed', 'batch_blocked', 'plan_failed', 'plan_empty', 'stopped']);
207
+ if (!terminalStatuses.has(s.status)) return null;
208
+ return maxIsoTime([
209
+ s.stoppedAt,
210
+ s.stopInfo?.stoppedAt,
211
+ s.judge?.endedAt,
212
+ s.planner?.endedAt,
213
+ ...(s.tasks || []).flatMap(task => [task.endedAt, task.completedAt, task.stoppedAt])
214
+ ]) || s.updatedAt;
215
+ }
216
+ function runTimingText(s) { return `开始时刻 ${formatDateTime(s.createdAt)}|用时 ${durationSeconds(s.createdAt, runDurationEnd(s))} 秒`; }
217
+ function basenamePath(value) {
218
+ const parts = String(value || '').split(/[\\/]/).filter(Boolean);
219
+ return parts.at(-1) || value || '-';
220
+ }
221
+ function metaChip(label, value, { title = value, danger = false, long = false, extra = '' } = {}) {
222
+ return `<span class="meta-chip ${danger ? 'danger' : ''} ${long ? 'long' : ''}" title="${esc(title)}"><span class="meta-label">${esc(label)}</span><span class="meta-value">${esc(value)}</span>${extra}</span>`;
223
+ }
205
224
  function isTmuxMode() { return currentState?.runner === 'tmux'; }
206
225
  function taskById(id) {
207
226
  if (!currentState) return null;
@@ -209,6 +228,26 @@ function taskById(id) {
209
228
  if (id === 'judge') return currentState.judge;
210
229
  return (currentState.tasks || []).find(t => t.id === id) || null;
211
230
  }
231
+ function tmuxSessionName(state = currentState) {
232
+ return state?.tmux?.tmuxSessionName || state?.tmux?.sessionName || (state?.runId ? `input-kanban-${state.runId}` : '');
233
+ }
234
+ function hasRunTmuxMetadata(state = currentState) {
235
+ if (!state || state.runner !== 'tmux') return false;
236
+ if (state.tmux?.hasTmuxSession || state.tmux?.tmuxSessionName || state.tmux?.tmuxAttachCommand) return true;
237
+ const roles = [state.planner, ...(state.tasks || []), state.judge];
238
+ return roles.some(role => role?.tmux?.ready === true && (role.tmux.sessionName || role.tmux.windowName || role.tmux.target));
239
+ }
240
+ function runAttachCommand(state = currentState) {
241
+ if (!hasRunTmuxMetadata(state)) return '';
242
+ return state?.tmux?.tmuxAttachCommand || `tmux attach-session -t ${tmuxSessionName(state)}`;
243
+ }
244
+ function runListHasTmuxMetadata(run) {
245
+ if (run?.runner !== 'tmux') return false;
246
+ if (selectedRun === run.runId && currentState?.runId === run.runId) return hasRunTmuxMetadata(currentState);
247
+ const cached = statusByRunId.get(run.runId);
248
+ if (cached) return hasRunTmuxMetadata(cached);
249
+ return !!(run?.tmux?.hasTmuxSession || run?.tmux?.tmuxSessionName || run?.tmux?.tmuxAttachCommand);
250
+ }
212
251
 
213
252
  async function loadHealth() {
214
253
  const h = await api('/api/health');
@@ -228,7 +267,7 @@ function hideCreateForm() {
228
267
  document.getElementById('filePanel').classList.remove('hidden');
229
268
  }
230
269
  async function createRun() {
231
- const body = { label: label.value, repo: repo.value, maxParallel: maxParallel.value, taskText: taskText.value };
270
+ const body = { label: label.value, repo: repo.value, maxParallel: maxParallel.value, workerSandbox: workerSandbox.value, taskText: taskText.value };
232
271
  const r = await api('/api/runs', { method: 'POST', body: JSON.stringify(body) });
233
272
  selectedRun = r.runId; selectedTask = null; selectedFileName = null;
234
273
  clearFileView();
@@ -244,7 +283,7 @@ function renderRunList() {
244
283
  const visibleRuns = latestRuns.slice(0, runListVisibleCount);
245
284
  const cards = visibleRuns.map(r => `
246
285
  <div class="run-card ${selectedRun === r.runId ? 'active' : ''}" onclick="selectRun('${r.runId}')">
247
- <div class="run-card-title"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span>${pill(r.status)}</div>
286
+ <div class="run-card-title"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span><span>${pill(r.status)}</span></div>
248
287
  <div class="run-card-id muted">${esc(r.runId)}</div>
249
288
  <div class="run-card-created">创建 ${esc(formatDateTime(r.createdAt))}</div>
250
289
  <div class="run-card-progress"><span>进度 ${r.completed}/${r.total}</span><span>执行中 ${r.running}|失败 ${r.failed}</span></div>
@@ -262,14 +301,39 @@ async function selectRun(id) { selectedRun = id; selectedTask = null; selectedFi
262
301
  async function refreshSelected({auto=false} = {}) {
263
302
  if (!selectedRun) return;
264
303
  currentState = await api(`/api/runs/${selectedRun}/status`);
304
+ statusByRunId.set(selectedRun, currentState);
265
305
  if (auto) lastAutoRefreshAt = new Date();
266
- document.getElementById('selected').innerHTML = `<div class="build-title"><span>${esc(currentState.label)}</span>${pill(currentState.status)}</div><div class="build-meta">${esc(currentState.runId)}|仓库=${esc(currentState.repo)}<br>${esc(runTimingText(currentState))}</div>`;
306
+ document.getElementById('selected').innerHTML = renderSelectedHeader();
267
307
  updateAutoRefreshHint();
268
308
  updateRunNotice();
269
309
  await loadTaskDescription();
270
310
  renderTasks(); await refreshRuns();
271
311
  if (selectedTask && selectedFileName) await loadFile(selectedFileName, { preserveScroll: true });
272
312
  }
313
+ function renderSelectedHeader() {
314
+ if (!currentState) return '<div class="muted">未选择任务批次</div>';
315
+ const sandbox = currentState.workerSandbox || 'workspace-write';
316
+ const chips = [
317
+ metaChip('Run ID', currentState.runId, { long: true }),
318
+ metaChip('仓库', basenamePath(currentState.repo), { title: currentState.repo }),
319
+ metaChip('沙箱', sandbox, { danger: sandbox === 'danger-full-access' }),
320
+ metaChip('开始', formatDateTime(currentState.createdAt)),
321
+ metaChip('用时', `${durationSeconds(currentState.createdAt, runDurationEnd(currentState))} 秒`)
322
+ ];
323
+ if (currentState.runner === 'tmux') {
324
+ if (hasRunTmuxMetadata(currentState)) {
325
+ chips.push(metaChip('终端', tmuxSessionName(currentState), {
326
+ long: true,
327
+ extra: `<button class="secondary copy-btn" onclick="copyTmuxRunCommand(event)">复制tmux attach指令</button>`
328
+ }));
329
+ } else {
330
+ chips.push(metaChip('终端', 'tmux 现场尚未生成'));
331
+ }
332
+ }
333
+ chips.push(metaChip('刷新', `每 ${AUTO_REFRESH_MS / 1000} 秒`));
334
+ chips.push(metaChip('上次', lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发'));
335
+ return `<div class="build-title"><span>${esc(currentState.label)}</span>${pill(currentState.status)}</div><div class="build-meta">${chips.join('')}</div>`;
336
+ }
273
337
  async function loadTaskDescription() {
274
338
  if (!selectedRun) { document.getElementById('taskDescription').textContent = '未选择任务批次'; return; }
275
339
  document.getElementById('taskDescription').textContent = await api(`/api/runs/${selectedRun}/task-text`);
@@ -307,16 +371,9 @@ function updateAutoRefreshHint() {
307
371
  function taskStatusCell(t) {
308
372
  if (t?.manualCompletion) {
309
373
  const original = t.originalStatus || t.manualCompletion.originalStatus || t.manualCompletion.previousStatus || 'unknown';
310
- return `${pill(original)} <span class="pill completed">手动标记成功已完成</span>`;
374
+ return `<span class="status-stack">${pill(original)}<span class="pill completed">手动标记成功已完成</span></span>`;
311
375
  }
312
- const hint = attentionHintCell(t);
313
- return `${pill(t?.status)}${hint}`;
314
- }
315
- function attentionHintCell(t) {
316
- if (!t?.attentionHint) return '';
317
- const command = t.attentionHint.attachCommand ? ` <code>${esc(t.attentionHint.attachCommand)}</code>` : '';
318
- const reasons = Array.isArray(t.attentionHint.reasons) && t.attentionHint.reasons.length ? `|${esc(t.attentionHint.reasons.join(' / '))}` : '';
319
- return `<span class="attention-hint" title="${esc(t.attentionHint.message || '')}">可能需要人工介入;请 attach tmux 检查。${command}${reasons}</span>`;
376
+ return pill(t?.status);
320
377
  }
321
378
  function taskActionCell(id, t) {
322
379
  if (!t || id === 'planner' || id === 'judge') return '-';
@@ -324,16 +381,13 @@ function taskActionCell(id, t) {
324
381
  if (!['unknown', 'failed'].includes(t.status)) return '-';
325
382
  return `<button class="danger" onclick="markTaskCompleted(event, '${id}')">手动标记成功</button>`;
326
383
  }
384
+ function shortSessionId(thread) {
385
+ const text = String(thread || '');
386
+ return text.length > 8 ? text.slice(-8) : text;
387
+ }
327
388
  function sessionCell(thread) {
328
389
  if (!thread) return '-';
329
- return `<span class="session-cell">${esc(thread)}</span><button class="copy-btn" title="复制 Codex 会话ID" onclick="copySessionId(event, '${esc(thread)}')">⧉</button>`;
330
- }
331
- function tmuxCell(t) {
332
- if (!isTmuxMode()) return '-';
333
- const tmux = t?.tmux;
334
- if (!tmux) return '<span class="muted">未启动终端</span>';
335
- const label = tmux.windowName || tmux.target || 'tmux';
336
- return `<span class="session-cell" title="${esc(tmux.target || '')}">${esc(label)}</span>`;
390
+ 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>`;
337
391
  }
338
392
  function taskStartedCell(t) {
339
393
  return t?.startedAt ? formatDateTime(t.startedAt) : '-';
@@ -343,29 +397,67 @@ function taskDurationCell(t) {
343
397
  const end = t.endedAt || t.completedAt || t.stoppedAt || (t.status === 'running' ? null : t.updatedAt);
344
398
  return `${durationSeconds(t.startedAt, end)} 秒`;
345
399
  }
400
+ function processExitCell(t) {
401
+ return `${esc(t?.pid || '-')} / ${esc(t?.exitCode ?? '-')}`;
402
+ }
403
+ function taskActionInfoCell(id, t) {
404
+ return `<div class="row-actions">${taskActionCell(id, t)}</div>`;
405
+ }
346
406
  function taskRow(id, role, t) {
347
407
  const thread = t?.codexThread?.id || '';
348
- return `<tr class="job-row ${selectedTask === id ? 'selected' : ''}" onclick="selectTask('${id}')"><td><b class="task-name" title="${esc(id)}">${esc(id)}</b><span class="muted task-role">${esc(displayRole(role))}</span></td><td>${taskStatusCell(t)}</td><td>${esc(taskStartedCell(t))}</td><td>${esc(taskDurationCell(t))}</td><td>${esc(t?.pid || '-')}</td><td>${esc(t?.exitCode ?? '-')}</td><td>${sessionCell(thread)}</td><td>${tmuxCell(t)}</td><td>${esc(t?.files?.lastMessage?.exists ? '有' : '-')}</td><td>${taskActionCell(id, t)}</td></tr>`;
408
+ return `<tr class="job-row ${selectedTask === id ? 'selected' : ''}" onclick="selectTask('${id}')"><td><b class="task-name" title="${esc(id)}">${esc(id)}</b><span class="muted task-role">${esc(displayRole(role))}</span></td><td>${taskStatusCell(t)}</td><td>${esc(taskStartedCell(t))}</td><td>${esc(taskDurationCell(t))}</td><td>${processExitCell(t)}</td><td>${sessionCell(thread)}</td><td>${esc(t?.files?.lastMessage?.exists ? '有' : '-')}</td><td>${taskActionInfoCell(id, t)}</td></tr>`;
349
409
  }
350
410
  function renderTasks() {
351
411
  const s = currentState;
352
412
  const rows = [taskRow('planner','planner',s.planner)];
413
+ const columnCount = 8;
353
414
  if (Array.isArray(s.batches) && s.batches.length) {
354
415
  for (const b of s.batches) {
355
416
  const done = (b.tasks || []).filter(t => t.status === 'completed').length;
356
- rows.push(`<tr class="batch-row"><td colspan="10">${esc(b.name || b.id)} ${pill(b.status)} <span class="muted">${esc(b.id)}|最大并发 ${esc(b.maxParallel || '-')}|${done}/${(b.tasks || []).length}</span></td></tr>`);
417
+ rows.push(`<tr class="batch-row"><td colspan="${columnCount}">${esc(b.name || b.id)} ${pill(b.status)} <span class="muted">${esc(b.id)}|最大并发 ${esc(b.maxParallel || '-')}|${done}/${(b.tasks || []).length}</span></td></tr>`);
357
418
  for (const t of b.tasks || []) rows.push(taskRow(t.id, t.name, t));
358
419
  }
359
420
  } else rows.push(...(s.tasks||[]).map(t => taskRow(t.id, t.name, t)));
360
421
  rows.push(taskRow('judge','最终验收',s.judge));
361
- document.getElementById('tasks').innerHTML = `<table><tr><th>任务</th><th>状态</th><th>发起时间</th><th>用时</th><th>进程号</th><th>退出码</th><th>Codex 会话ID</th><th>终端</th><th>最终回复</th><th>操作</th></tr>${rows.join('')}</table>`;
422
+ document.getElementById('tasks').innerHTML = `<table><tr><th>任务</th><th>状态</th><th>发起时间</th><th>用时</th><th>进程号/退出码</th><th>Codex 会话ID</th><th>最终回复</th><th>操作</th></tr>${rows.join('')}</table>`;
423
+ }
424
+ const FILE_TAB_SETS = {
425
+ planner: [
426
+ ['last_message.md', '最终回复'], ['events.pretty', '执行过程'], ['stderr.log', '错误日志'], ['prompt.md', '提示词'], ['events.jsonl', '事件日志']
427
+ ],
428
+ worker: [
429
+ ['events.pretty', '执行过程'], ['last_message.md', '最终回复'], ['stderr.log', '错误日志'], ['prompt.md', '提示词'], ['result.json', '结果'], ['evidence.json', '证据'], ['events.jsonl', '事件日志']
430
+ ],
431
+ judge: [
432
+ ['verdict.json', '验收结论'], ['judge_input.json', '验收输入'], ['last_message.md', '最终回复'], ['events.pretty', '执行过程'], ['stderr.log', '错误日志'], ['prompt.md', '提示词'], ['events.jsonl', '事件日志']
433
+ ]
434
+ };
435
+ function roleForSelectedTask() {
436
+ if (selectedTask === 'planner') return 'planner';
437
+ if (selectedTask === 'judge') return 'judge';
438
+ return selectedTask ? 'worker' : 'worker';
439
+ }
440
+ function fileTabsForSelectedTask() {
441
+ return FILE_TAB_SETS[roleForSelectedTask()] || FILE_TAB_SETS.worker;
442
+ }
443
+ function renderFileTabs() {
444
+ const el = document.getElementById('fileTabs');
445
+ if (!el) return;
446
+ if (!selectedTask) { el.innerHTML = ''; return; }
447
+ el.innerHTML = fileTabsForSelectedTask().map(([name, label]) => `<button class="secondary" onclick="loadFile('${name}')">${esc(label)}</button>`).join('');
362
448
  }
363
449
  async function selectTask(id) {
364
450
  selectedTask = id;
365
451
  document.getElementById('fileTitle').textContent = `${selectedRun} / ${selectedTask}`;
452
+ renderFileTabs();
366
453
  renderTasks();
367
- if (selectedFileName) await loadFile(selectedFileName);
368
- else document.getElementById('fileContent').textContent = '';
454
+ if (selectedFileName && fileTabsForSelectedTask().some(([name]) => name === selectedFileName)) await loadFile(selectedFileName);
455
+ else {
456
+ selectedFileName = null;
457
+ document.getElementById('fileContent').textContent = '';
458
+ hideExecutionSummary();
459
+ updateCopyLastMessageButton();
460
+ }
369
461
  }
370
462
  async function loadFile(name, { preserveScroll = false } = {}) {
371
463
  if (!selectedRun || !selectedTask) return;
@@ -378,14 +470,14 @@ async function loadFile(name, { preserveScroll = false } = {}) {
378
470
  else pre.scrollTop = 0;
379
471
  if (name === 'events.pretty') await renderExecutionSummary();
380
472
  else hideExecutionSummary();
381
- renderTmuxPanel();
382
473
  updateCopyLastMessageButton();
383
474
  }
384
475
  function clearFileView() {
385
476
  document.getElementById('fileTitle').textContent = '点击任务后选择文件';
386
477
  document.getElementById('fileContent').textContent = '';
478
+ const tabs = document.getElementById('fileTabs');
479
+ if (tabs) tabs.innerHTML = '';
387
480
  hideExecutionSummary();
388
- hideTmuxPanel();
389
481
  updateCopyLastMessageButton();
390
482
  }
391
483
  function updateCopyLastMessageButton() {
@@ -411,48 +503,16 @@ function hideExecutionSummary() {
411
503
  el.classList.add('hidden');
412
504
  el.innerHTML = '';
413
505
  }
414
- function hideTmuxPanel() {
415
- const el = document.getElementById('tmuxPanel');
416
- el.classList.add('hidden');
417
- el.innerHTML = '';
418
- }
419
- function renderTmuxPanel() {
420
- const el = document.getElementById('tmuxPanel');
421
- if (!el) return;
422
- if (!selectedTask || !currentState) { hideTmuxPanel(); return; }
423
- if (!isTmuxMode()) {
424
- el.classList.remove('hidden');
425
- el.innerHTML = '<div class="tmux-box-title">终端模式</div><span class="muted">当前 runner 为 headless,无需终端附加操作。</span>';
426
- return;
427
- }
428
- const tmux = taskById(selectedTask)?.tmux;
429
- if (!tmux) {
430
- el.classList.remove('hidden');
431
- el.innerHTML = '<div class="tmux-box-title">tmux 终端</div><span class="muted">该任务尚未生成 tmux window。</span>';
432
- return;
433
- }
434
- el.classList.remove('hidden');
435
- el.innerHTML = `
436
- <div class="tmux-box-title">tmux 终端</div>
437
- <span class="tmux-inline">session:${esc(tmux.sessionName || '-')}</span>
438
- <span class="tmux-inline">window:${esc(tmux.windowName || '-')}</span>
439
- <span class="tmux-inline">target:${esc(tmux.target || '-')}</span>
440
- <div class="tmux-actions">
441
- ${tmux.attachCommand ? `<button class="secondary" onclick="copyTmuxCommand(event, 'attach')">复制 attach</button>` : ''}
442
- ${tmux.selectWindowCommand ? `<button class="secondary" onclick="copyTmuxCommand(event, 'select')">复制 select-window</button>` : ''}
443
- </div>`;
444
- }
445
- async function copyTmuxCommand(event, kind) {
506
+ async function copyTmuxRunCommand(event) {
446
507
  event.stopPropagation();
447
- const tmux = taskById(selectedTask)?.tmux;
448
- const command = kind === 'attach' ? tmux?.attachCommand : tmux?.selectWindowCommand;
508
+ const command = runAttachCommand(currentState);
449
509
  if (!command) return;
450
510
  try {
451
511
  await navigator.clipboard.writeText(command);
452
512
  event.currentTarget.textContent = '已复制';
453
- setTimeout(() => { event.currentTarget.textContent = kind === 'attach' ? '复制 attach' : '复制 select-window'; }, 900);
513
+ setTimeout(() => { event.currentTarget.textContent = '复制tmux attach指令'; }, 900);
454
514
  } catch {
455
- prompt(kind === 'attach' ? '复制 attachCommand' : '复制 selectWindowCommand', command);
515
+ prompt('复制 tmux attach 命令', command);
456
516
  }
457
517
  }
458
518
  async function renderExecutionSummary() {
@@ -0,0 +1,85 @@
1
+ function formatKnownFields(obj, fields) {
2
+ return fields
3
+ .filter(field => obj[field] !== undefined && obj[field] !== null)
4
+ .map(field => ` ${field}: ${typeof obj[field] === 'string' ? obj[field] : JSON.stringify(obj[field], null, 2)}`)
5
+ .join('\n');
6
+ }
7
+
8
+ function formatJson(value) { return indentText(JSON.stringify(value, null, 2)); }
9
+ function indentText(text) { return String(text).split('\n').map(line => ` ${line}`).join('\n'); }
10
+ function truncateText(text, max = 12000) { return text.length > max ? `${text.slice(0, max)}\n...<已截断 ${text.length - max} 字符>` : text; }
11
+
12
+ function displayItemType(type) {
13
+ return {
14
+ command_execution: '命令执行',
15
+ agent_message: '模型回复',
16
+ agentMessage: '模型回复',
17
+ reasoning: '推理',
18
+ file_change: '文件变更',
19
+ fileChange: '文件变更',
20
+ mcp_tool_call: 'MCP 工具调用',
21
+ mcpToolCall: 'MCP 工具调用'
22
+ }[type] || type;
23
+ }
24
+
25
+ function formatCodexItem(seq, action, item = {}) {
26
+ const type = item.type || 'unknown';
27
+ const title = `[${seq}] ${action}: ${displayItemType(type)}`;
28
+ if (type === 'command_execution') {
29
+ const parts = [title];
30
+ if (item.command) parts.push(` 命令: ${item.command}`);
31
+ if (item.status) parts.push(` 状态: ${item.status}`);
32
+ if (item.exit_code !== undefined && item.exit_code !== null) parts.push(` 退出码: ${item.exit_code}`);
33
+ if (item.aggregated_output) parts.push(` 输出:\n${indentText(truncateText(item.aggregated_output))}`);
34
+ return parts.join('\n');
35
+ }
36
+ if (type === 'agent_message' || type === 'agentMessage') {
37
+ const text = item.text || item.message || item.content || '';
38
+ return text ? `${title}\n 内容:\n${indentText(truncateText(String(text)))}` : title;
39
+ }
40
+ if (type === 'reasoning') {
41
+ const summary = item.summary || item.content || '';
42
+ return summary ? `${title}\n 摘要:\n${indentText(truncateText(Array.isArray(summary) ? summary.join('\n') : String(summary)))}` : title;
43
+ }
44
+ if (type === 'file_change' || type === 'fileChange') {
45
+ return `${title}\n${formatKnownFields(item, ['status', 'path', 'changes'])}`.trimEnd();
46
+ }
47
+ return `${title}\n${formatJson(item)}`;
48
+ }
49
+
50
+ export function formatCodexEvent(seq, event) {
51
+ switch (event.type) {
52
+ case 'thread.started':
53
+ return `[${seq}] Codex 会话开始\n 会话ID: ${event.thread_id || '-'}`;
54
+ case 'turn.started':
55
+ return `[${seq}] 回合开始`;
56
+ case 'turn.completed':
57
+ return `[${seq}] 回合完成\n${formatKnownFields(event, ['status', 'error', 'usage'])}`.trimEnd();
58
+ case 'item.started':
59
+ return formatCodexItem(seq, '开始', event.item);
60
+ case 'item.completed':
61
+ return formatCodexItem(seq, '完成', event.item);
62
+ case 'error':
63
+ return `[${seq}] 错误\n${formatJson(event)}`;
64
+ default:
65
+ return `[${seq}] ${event.type || '未知事件'}\n${formatJson(event)}`;
66
+ }
67
+ }
68
+
69
+ export function formatCodexEventsJsonl(text) {
70
+ if (!text.trim()) return '暂无事件日志。';
71
+ const lines = text.split(/\r?\n/).filter(Boolean);
72
+ return lines.map((line, index) => {
73
+ const seq = String(index + 1).padStart(3, '0');
74
+ let event;
75
+ try { event = JSON.parse(line); }
76
+ catch { return `[${seq}] 无法解析事件\n${line}`; }
77
+ return formatCodexEvent(seq, event);
78
+ }).join('\n\n');
79
+ }
80
+
81
+ export function formatCodexEventLine(line, index = 0) {
82
+ const seq = String(index + 1).padStart(3, '0');
83
+ try { return formatCodexEvent(seq, JSON.parse(line)); }
84
+ catch { return `[${seq}] 无法解析事件\n${line}`; }
85
+ }