input-kanban 0.0.6 → 0.0.7

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/PROJECT_GUIDE.md CHANGED
@@ -147,6 +147,7 @@ Recovery options:
147
147
  - Inspect `events.pretty`, `stderr.log`, `last_message.md`, and artifacts.
148
148
  - Manually mark `failed` or `unknown` workers as completed if the user confirms the work is actually done.
149
149
  - Manual completion writes `workers/<taskId>/manual_completion.json`.
150
+ - If the user pastes a manual success result, it is saved as `workers/<taskId>/manual_result.md` and included in final judge input.
150
151
  - The UI preserves the original failed or unknown status while also showing the manual completion marker.
151
152
 
152
153
  ## Stop and Archive
@@ -255,6 +256,8 @@ runs/<runId>/planner/
255
256
  runs/<runId>/planner_attempts/attempt-XX/
256
257
  runs/<runId>/workers/<taskId>/
257
258
  runs/<runId>/judge/judge_input.json
259
+ runs/<runId>/workers/<taskId>/events_timed.jsonl
260
+ runs/<runId>/workers/<taskId>/manual_result.md
258
261
  runs/<runId>/judge/verdict.json
259
262
  ```
260
263
 
@@ -271,6 +274,7 @@ runs/<runId>/judge/verdict.json
271
274
  - `POST /api/runs/:runId/judge`
272
275
  - `POST /api/runs/:runId/stop`
273
276
  - `POST /api/runs/:runId/archive`
277
+ - `PATCH /api/runs/:runId/label`
274
278
  - `GET /api/runs/:runId/task-text`
275
279
  - `GET /api/runs/:runId/tasks/:taskId/file?name=...`
276
280
  - `POST /api/runs/:runId/tasks/:taskId/mark-completed`
@@ -336,7 +340,7 @@ notes or handoff.
336
340
  1. Headless runner:
337
341
  - Start the app with `input-kanban --runner headless --runs-dir <tmp-runs-dir> --repo <target-repo> --port <free-port>`.
338
342
  - Create a small run, plan it, dispatch at least one worker, and run the final judge if the plan requires it.
339
- - Verify the run state reports `runner: headless`, no task exposes `tmux` metadata, and role directories contain the expected `prompt.md`, `events.jsonl`, `stderr.log`, `last_message.md`, and `exit_code` files.
343
+ - Verify the run state reports `runner: headless`, no task exposes `tmux` metadata, and role directories contain the expected `prompt.md`, `events.jsonl`, `events_timed.jsonl`, `stderr.log`, `last_message.md`, and `exit_code` files.
340
344
  - Stop the run and verify no unrelated local process is affected.
341
345
 
342
346
  2. tmux runner, only when `tmux -V` succeeds:
@@ -354,6 +358,10 @@ notes or handoff.
354
358
  - Verify the package includes `bin/`, `src/`, `public/`, `README.md`, `README.en.md`, `PROJECT_GUIDE.md`, `ENVIRONMENT.md`, and `package.json`.
355
359
  - Verify no runtime run directories, local logs, or unrelated temporary artifacts are included.
356
360
 
361
+ ## Release Notes
362
+
363
+ Keep a single repository-level `RELEASE_NOTES.md` with recent version history. Do not add one tracked `RELEASE_NOTES.vX.Y.Z.md` file per release. When creating a GitHub Release, use a temporary notes file or copy the relevant version section from `RELEASE_NOTES.md` into `gh release create --notes-file`.
364
+
357
365
  ## Change Guidelines
358
366
 
359
367
  - Do not add automatic worker retry unless there is a verified rollback or idempotency mechanism.
@@ -0,0 +1,69 @@
1
+ # Release Notes
2
+
3
+ ## v0.0.7
4
+
5
+ ### Highlights
6
+
7
+ - Improve the run list cards: replace `Run ID` with a repository chip and copy action, add frozen duration, and hide rename buttons until hover/focus.
8
+ - Streamline dashboard flow: creating a run now automatically starts planning, selecting a task opens its execution log by default, and running-task conflicts show Chinese user-facing messages.
9
+ - Improve task detail ergonomics: copy actions are available for both final replies and verdict JSON, and manual success completion stores pasted human evidence.
10
+ - Add execution timing artifacts and summary chips, including `events_timed.jsonl`, command duration breakdowns, model/scheduler time, startup/teardown time, and system event counts.
11
+ - Strengthen run creation validation by rejecting missing target paths and directories outside a Git work tree before planning starts.
12
+ - Keep release notes in a single repository-level `RELEASE_NOTES.md` and include that file in the npm package.
13
+
14
+ ### Verification
15
+
16
+ - `npm run check` passed with 43 tests.
17
+ - `npm pack --dry-run` passed before publishing.
18
+
19
+ ## v0.0.6
20
+
21
+ ### Changes
22
+
23
+ - Validate the target repository when creating a run, rejecting missing paths and directories outside a Git work tree before planning starts.
24
+ - Add a compact copy button for the full repository path in the run detail header.
25
+ - Document the Git work tree requirement in the README, English README, environment reference, and project guide.
26
+
27
+ ### Verification
28
+
29
+ - `npm run check` passed with 38 tests.
30
+ - `npm pack --dry-run` confirmed package contents before the version bump.
31
+
32
+ ## v0.0.5
33
+
34
+ ### Highlights
35
+
36
+ - Add Input Kanban branding icons to the dashboard header and browser tab.
37
+ - Add standard favicon and Apple touch icon assets so browsers can show the same visual identity as the page header.
38
+ - Align left run-list metadata with the compact chip style used in run details.
39
+ - Align batch-row metadata in the task table with the same chip style for `Batch ID`, `最大并发`, and `进度`.
40
+
41
+ ### Notes
42
+
43
+ - This is a UI polish release after `v0.0.4`.
44
+ - No runner, scheduler, tmux, sandbox, or Codex execution behavior changes are included.
45
+
46
+ ### Verification
47
+
48
+ - `npm run check` passed.
49
+ - `npm pack --dry-run` passed.
50
+
51
+ ## v0.0.4
52
+
53
+ ### Highlights
54
+
55
+ - Add tmux batch layout: one session per run, windows for planner, each batch, and judge, with batch windows showing an overview pane plus worker panes.
56
+ - Show formatted Codex execution output in tmux panes while keeping raw JSONL logs in `events.jsonl`.
57
+ - Add worker sandbox selection in the create form, including explicit `danger-full-access` for controlled test repositories.
58
+ - Refine the dashboard UI with compact run metadata chips, no redundant tmux badges, no file-viewer tmux panel, and role-specific file tabs.
59
+ - Freeze run duration after terminal states such as final judge completion, instead of continuing to count on auto-refresh.
60
+
61
+ ### Notes
62
+
63
+ - `codex exec` is treated as non-interactive; tmux mode provides live terminal visibility but does not implement manual approval prompts.
64
+ - The dashboard exposes the run-level `tmux attach-session` copy action after tmux metadata is available.
65
+ - Use `danger-full-access` only when you explicitly want to relax worker sandbox limits in a controlled environment.
66
+
67
+ ### Verification
68
+
69
+ - `npm run check` passed.
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import readline from 'node:readline';
5
+
6
+ const [eventsFile, timedEventsFile] = process.argv.slice(2);
7
+ if (!eventsFile || !timedEventsFile) {
8
+ console.error('usage: input-kanban-timestamp-events.js <events.jsonl> <events_timed.jsonl>');
9
+ process.exit(2);
10
+ }
11
+
12
+ await fs.promises.mkdir(path.dirname(eventsFile), { recursive: true });
13
+ await fs.promises.mkdir(path.dirname(timedEventsFile), { recursive: true });
14
+
15
+ const events = fs.createWriteStream(eventsFile, { flags: 'a' });
16
+ const timed = fs.createWriteStream(timedEventsFile, { flags: 'a' });
17
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
18
+
19
+ for await (const line of rl) {
20
+ const rawLine = `${line}\n`;
21
+ events.write(rawLine);
22
+ process.stdout.write(rawLine);
23
+ const receivedAt = new Date().toISOString();
24
+ try {
25
+ timed.write(`${JSON.stringify({ receivedAt, event: JSON.parse(line) })}\n`);
26
+ } catch {
27
+ timed.write(`${JSON.stringify({ receivedAt, rawLine: line })}\n`);
28
+ }
29
+ }
30
+
31
+ await Promise.all([
32
+ new Promise(resolve => events.end(resolve)),
33
+ new Promise(resolve => timed.end(resolve))
34
+ ]);
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "input-kanban",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "input-kanban": "bin/input-kanban.js"
7
7
  },
8
8
  "scripts": {
9
9
  "start": "node bin/input-kanban.js",
10
- "check": "node --check bin/input-kanban.js && node --check bin/input-kanban-format-events.js && node --check bin/input-kanban-tmux-overview.js && node --check src/server.js && node --check src/orchestrator.js && node --check src/appServerClient.js && node --check src/utils.js && node --check src/eventFormatter.js && node --check src/runners/index.js && node --check src/runners/headlessRunner.js && node --check src/runners/tmuxRunner.js && node --check src/runners/tmuxUtils.js && node --check src/tmux.js && node --test"
10
+ "check": "node --check bin/input-kanban.js && node --check bin/input-kanban-format-events.js && node --check bin/input-kanban-timestamp-events.js && node --check bin/input-kanban-tmux-overview.js && node --check src/server.js && node --check src/orchestrator.js && node --check src/appServerClient.js && node --check src/utils.js && node --check src/eventFormatter.js && node --check src/runners/index.js && node --check src/runners/headlessRunner.js && node --check src/runners/tmuxRunner.js && node --check src/runners/tmuxUtils.js && node --check src/tmux.js && node --test"
11
11
  },
12
12
  "dependencies": {},
13
13
  "description": "A local Codex orchestration kanban dashboard",
@@ -17,6 +17,7 @@
17
17
  "public",
18
18
  "README.md",
19
19
  "README.en.md",
20
+ "RELEASE_NOTES.md",
20
21
  "PROJECT_GUIDE.md",
21
22
  "ENVIRONMENT.md"
22
23
  ],
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
2
+ <path fill="#000" d="M24 8h80c8.8 0 16 7.2 16 16v80c0 8.8-7.2 16-16 16H24c-8.8 0-16-7.2-16-16V24C8 15.2 15.2 8 24 8Zm8 24v64h14V32H32Zm25 0v64h14V32H57Zm25 0v64h14V32H82Z"/>
3
+ </svg>
package/public/index.html CHANGED
@@ -4,8 +4,10 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Input 看板</title>
7
- <link rel="icon" type="image/png" sizes="32x32" href="/assets/input-kanban-favicon-32.png" />
8
- <link rel="apple-touch-icon" sizes="180x180" href="/assets/input-kanban-apple-touch-icon.png" />
7
+ <link rel="icon" type="image/png" sizes="32x32" href="/assets/input-kanban-favicon-32.png?v=2" />
8
+ <link rel="shortcut icon" type="image/png" href="/assets/input-kanban-favicon-32.png?v=2" />
9
+ <link rel="mask-icon" href="/assets/input-kanban-mask-icon.svg" color="#2563eb" />
10
+ <link rel="apple-touch-icon" sizes="180x180" href="/assets/input-kanban-apple-touch-icon.png?v=2" />
9
11
  <style>
10
12
  :root { --bg:#0b1220; --panel:#111827; --panel-2:#0f172a; --line:#334155; --line-strong:#64748b; --text:#e2e8f0; --muted:#94a3b8; --blue:#2563eb; --green:#166534; --red:#991b1b; --gray:#475569; --orange:#b45309; }
11
13
  body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: var(--bg); color: var(--text); }
@@ -66,6 +68,7 @@
66
68
  .run-card:hover { border-color: var(--line-strong); background: #162033; transform: translateY(-1px); }
67
69
  .run-card.active { border-color: #60a5fa; box-shadow: inset 3px 0 0 #60a5fa; }
68
70
  .run-card-title { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: flex-start; font-weight: 800; }
71
+ .run-card-name-wrap { min-width: 0; display: flex; align-items: flex-start; gap: 6px; }
69
72
  .run-card-name { min-width: 0; overflow: hidden; overflow-wrap: anywhere; word-break: break-word; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
70
73
  .run-card-title .pill { flex: 0 0 auto; }
71
74
  .run-card-id { margin-top: 5px; word-break: break-all; }
@@ -84,12 +87,21 @@
84
87
  .log-panel { margin-top: 16px; }
85
88
  .file-tabs button { font-size: 13px; }
86
89
  .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; }
90
+ .rename-btn { opacity: 0; pointer-events: none; transition: opacity .15s ease; }
91
+ .run-card:hover .rename-btn, .run-card:focus-within .rename-btn, .build-title:hover .rename-btn, .build-title:focus-within .rename-btn, .rename-btn:focus { opacity: 1; pointer-events: auto; }
92
+ .icon-svg { width: 14px; height: 14px; display: block; }
87
93
  .session-cell { word-break: break-all; }
88
94
  .row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
89
95
  .row-actions .danger, .row-actions .secondary { margin: 0; }
90
96
  .status-stack { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 5px; }
91
- .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; }
97
+ .execution-summary { display: flex; flex-wrap: wrap; gap: 7px; 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; }
92
98
  .execution-summary.hidden { display: none; }
99
+ .summary-chip { display: inline-flex; align-items: center; gap: 5px; max-width: 100%; padding: 4px 7px; border: 1px solid var(--line); border-radius: 999px; background: #0f172a; color: #cbd5e1; line-height: 1.2; }
100
+ .summary-chip.command-type { background: rgba(37,99,235,.12); border-color: rgba(96,165,250,.35); }
101
+ .summary-break { flex-basis: 100%; height: 0; }
102
+ .summary-label { color: var(--muted); font-weight: 800; }
103
+ .summary-value { color: #e2e8f0; font-weight: 800; }
104
+ .summary-duration { color: #bfdbfe; font-weight: 800; }
93
105
  .execution-summary + pre { border-top-left-radius: 0; border-top-right-radius: 0; }
94
106
  .notice { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #1f2937; color: #cbd5e1; }
95
107
  .notice.warning { border-color: #92400e; background: rgba(180,83,9,.18); }
@@ -97,6 +109,10 @@
97
109
  .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); }
98
110
  .file-content-wrap:hover .floating-copy-btn:not(.hidden), .floating-copy-btn:focus { opacity: 1; pointer-events: auto; }
99
111
  .floating-copy-btn:not(.hidden) + pre { padding-top: 42px; }
112
+ .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); }
113
+ .modal-backdrop.hidden { display: none; }
114
+ .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; }
115
+ .modal-card textarea { min-height: 220px; }
100
116
  </style>
101
117
  </head>
102
118
  <body>
@@ -153,21 +169,35 @@
153
169
  </section>
154
170
 
155
171
  <section id="filePanel" class="log-panel">
156
- <h2>文件查看</h2>
157
- <div id="fileTitle" class="muted">点击任务后选择文件</div>
172
+ <h2>任务详情</h2>
173
+ <div id="fileTitle" class="muted">点击任务后查看详情</div>
158
174
  <div id="fileTabs" class="toolbar file-tabs"></div>
159
175
  <div id="executionSummary" class="execution-summary hidden"></div>
160
176
  <div class="file-content-wrap">
161
- <button id="copyLastMessageBtn" class="secondary copy-btn floating-copy-btn hidden" title="复制最终回复内容" onclick="copyFileContent(event)">⧉</button>
177
+ <button id="copyLastMessageBtn" class="secondary copy-btn floating-copy-btn hidden" title="复制当前内容" onclick="copyFileContent(event)">⧉</button>
162
178
  <pre id="fileContent"></pre>
163
179
  </div>
164
180
  </section>
165
181
  </div>
166
182
  </main>
183
+ <div id="manualCompleteModal" class="modal-backdrop hidden">
184
+ <div class="modal-card">
185
+ <h2>手动标记成功</h2>
186
+ <div id="manualCompleteTitle" class="muted"></div>
187
+ <label>人工成功执行结果</label>
188
+ <textarea id="manualCompleteResult" placeholder="粘贴 codex resume 后的最终回复、验证结果、关键证据或 artifact 路径"></textarea>
189
+ <div class="muted">该内容会保存为 manual_result.md,显示在“结果”中,并作为 final judge 的人工成功证据。</div>
190
+ <div class="toolbar">
191
+ <button onclick="submitManualComplete()">确认标记成功</button>
192
+ <button class="secondary" onclick="closeManualCompleteModal()">取消</button>
193
+ </div>
194
+ </div>
195
+ </div>
167
196
  <script>
168
197
  let selectedRun = null;
169
198
  let selectedTask = null;
170
199
  let selectedFileName = null;
200
+ let manualCompleteTaskId = null;
171
201
  let currentState = null;
172
202
  let lastAutoRefreshAt = null;
173
203
  let runListVisibleCount = 10;
@@ -178,10 +208,26 @@ const RUN_LIST_PAGE_SIZE = 10;
178
208
 
179
209
  async function api(path, opts={}) {
180
210
  const res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...opts });
181
- if (!res.ok) throw new Error(await res.text());
211
+ if (!res.ok) {
212
+ const error = new Error(await res.text());
213
+ error.status = res.status;
214
+ throw error;
215
+ }
182
216
  const ct = res.headers.get('content-type') || '';
183
217
  return ct.includes('application/json') ? res.json() : res.text();
184
218
  }
219
+ function errorDetail(error) {
220
+ const message = error?.message || String(error || '');
221
+ try { return JSON.parse(message).error || message; }
222
+ catch { return message; }
223
+ }
224
+ function userFacingErrorMessage(error) {
225
+ const detail = errorDetail(error);
226
+ if (/planner already running/i.test(detail)) return '任务拆分正在进行中,请稍后查看结果。';
227
+ if (/judge already running/i.test(detail)) return '验收正在进行中,请稍后查看结果。';
228
+ if (/already running/i.test(detail)) return '任务正在进行中,请稍后查看结果。';
229
+ return error?.message || String(error);
230
+ }
185
231
  const statusText = {
186
232
  created: '已创建', pending: '等待中', planning: '拆分中', planned: '已拆分',
187
233
  running: '执行中', completed: '已完成', failed: '失败', unknown: '未知',
@@ -222,7 +268,7 @@ function runDurationEnd(s) {
222
268
  ...(s.tasks || []).flatMap(task => [task.endedAt, task.completedAt, task.stoppedAt])
223
269
  ]) || s.updatedAt;
224
270
  }
225
- function runTimingText(s) { return `开始时刻 ${formatDateTime(s.createdAt)}|用时 ${durationSeconds(s.createdAt, runDurationEnd(s))} 秒`; }
271
+ function runTimingText(s) { return `开始时刻 ${formatDateTime(s.createdAt)}|用时 ${formatDurationMs(durationSeconds(s.createdAt, runDurationEnd(s)) * 1000)}`; }
226
272
  function basenamePath(value) {
227
273
  const parts = String(value || '').split(/[\\/]/).filter(Boolean);
228
274
  return parts.at(-1) || value || '-';
@@ -230,6 +276,9 @@ function basenamePath(value) {
230
276
  function metaChip(label, value, { title = value, danger = false, long = false, extra = '' } = {}) {
231
277
  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>`;
232
278
  }
279
+ function editIcon() {
280
+ return '<svg class="icon-svg" viewBox="0 0 24 24" focusable="false" aria-hidden="true"><path d="M4 16.5V20h3.5L18.1 9.4l-3.5-3.5L4 16.5Z" fill="currentColor"/><path d="m16 4.5 3.5 3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
281
+ }
233
282
  function isTmuxMode() { return currentState?.runner === 'tmux'; }
234
283
  function taskById(id) {
235
284
  if (!currentState) return null;
@@ -250,6 +299,14 @@ function runAttachCommand(state = currentState) {
250
299
  if (!hasRunTmuxMetadata(state)) return '';
251
300
  return state?.tmux?.tmuxAttachCommand || `tmux attach-session -t ${tmuxSessionName(state)}`;
252
301
  }
302
+ function runCardDurationEnd(run) {
303
+ const cached = statusByRunId.get(run.runId);
304
+ if (cached) return runDurationEnd(cached);
305
+ return run.durationEnd || null;
306
+ }
307
+ function runCardDurationText(run) {
308
+ return formatDurationMs(durationSeconds(run.createdAt, runCardDurationEnd(run)) * 1000);
309
+ }
253
310
  function runListHasTmuxMetadata(run) {
254
311
  if (run?.runner !== 'tmux') return false;
255
312
  if (selectedRun === run.runId && currentState?.runId === run.runId) return hasRunTmuxMetadata(currentState);
@@ -282,6 +339,8 @@ async function createRun() {
282
339
  clearFileView();
283
340
  hideCreateForm();
284
341
  await refreshRuns(); await refreshSelected();
342
+ await api(`/api/runs/${selectedRun}/plan`, { method: 'POST' });
343
+ await refreshSelected();
285
344
  }
286
345
  async function refreshRuns() {
287
346
  const data = await api('/api/runs');
@@ -292,10 +351,11 @@ function renderRunList() {
292
351
  const visibleRuns = latestRuns.slice(0, runListVisibleCount);
293
352
  const cards = visibleRuns.map(r => `
294
353
  <div class="run-card ${selectedRun === r.runId ? 'active' : ''}" onclick="selectRun('${r.runId}')">
295
- <div class="run-card-title"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span><span>${pill(r.status)}</span></div>
354
+ <div class="run-card-title"><span class="run-card-name-wrap"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span><button class="secondary copy-btn rename-btn" title="修改任务批次名称" onclick="renameRunLabel(event, '${r.runId}')">${editIcon()}</button></span><span>${pill(r.status)}</span></div>
296
355
  <div class="run-card-meta">
297
- ${metaChip('Run ID', r.runId, { long: true })}
356
+ ${metaChip('仓库', basenamePath(r.repo), { title: r.repo, long: true, extra: `<button class="secondary copy-btn" title="复制仓库地址" onclick="copyRunRepoPath(event, '${r.runId}')">⧉</button>` })}
298
357
  ${metaChip('创建', formatDateTime(r.createdAt))}
358
+ ${metaChip('用时', runCardDurationText(r))}
299
359
  ${metaChip('进度', `${r.completed}/${r.total}`)}
300
360
  ${metaChip('执行中', r.running)}
301
361
  ${metaChip('失败', r.failed, { danger: Number(r.failed) > 0 })}
@@ -335,7 +395,7 @@ function renderSelectedHeader() {
335
395
  }),
336
396
  metaChip('沙箱', sandbox, { danger: sandbox === 'danger-full-access' }),
337
397
  metaChip('开始', formatDateTime(currentState.createdAt)),
338
- metaChip('用时', `${durationSeconds(currentState.createdAt, runDurationEnd(currentState))} 秒`)
398
+ metaChip('用时', formatDurationMs(durationSeconds(currentState.createdAt, runDurationEnd(currentState)) * 1000))
339
399
  ];
340
400
  if (currentState.runner === 'tmux') {
341
401
  if (hasRunTmuxMetadata(currentState)) {
@@ -349,7 +409,7 @@ function renderSelectedHeader() {
349
409
  }
350
410
  chips.push(metaChip('刷新', `每 ${AUTO_REFRESH_MS / 1000} 秒`));
351
411
  chips.push(metaChip('上次', lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发'));
352
- return `<div class="build-title"><span>${esc(currentState.label)}</span>${pill(currentState.status)}</div><div class="build-meta">${chips.join('')}</div>`;
412
+ return `<div class="build-title"><span>${esc(currentState.label)}</span><button class="secondary copy-btn rename-btn" title="修改任务批次名称" onclick="renameRunLabel(event, currentState.runId)">${editIcon()}</button>${pill(currentState.status)}</div><div class="build-meta">${chips.join('')}</div>`;
353
413
  }
354
414
  async function loadTaskDescription() {
355
415
  if (!selectedRun) { document.getElementById('taskDescription').textContent = '未选择任务批次'; return; }
@@ -409,10 +469,17 @@ function sessionCell(thread) {
409
469
  function taskStartedCell(t) {
410
470
  return t?.startedAt ? formatDateTime(t.startedAt) : '-';
411
471
  }
412
- function taskDurationCell(t) {
413
- if (!t?.startedAt) return '-';
472
+ function taskDurationMs(t) {
473
+ if (!t?.startedAt) return null;
414
474
  const end = t.endedAt || t.completedAt || t.stoppedAt || (t.status === 'running' ? null : t.updatedAt);
415
- return `${durationSeconds(t.startedAt, end)} 秒`;
475
+ const endMs = end ? new Date(end).getTime() : Date.now();
476
+ const startMs = new Date(t.startedAt).getTime();
477
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null;
478
+ return Math.max(0, endMs - startMs);
479
+ }
480
+ function taskDurationCell(t) {
481
+ const ms = taskDurationMs(t);
482
+ return ms === null ? '-' : formatDurationMs(ms);
416
483
  }
417
484
  function processExitCell(t) {
418
485
  return `${esc(t?.pid || '-')} / ${esc(t?.exitCode ?? '-')}`;
@@ -468,12 +535,17 @@ async function selectTask(id) {
468
535
  document.getElementById('fileTitle').textContent = `${selectedRun} / ${selectedTask}`;
469
536
  renderFileTabs();
470
537
  renderTasks();
471
- if (selectedFileName && fileTabsForSelectedTask().some(([name]) => name === selectedFileName)) await loadFile(selectedFileName);
538
+ const tabs = fileTabsForSelectedTask();
539
+ if (selectedFileName && tabs.some(([name]) => name === selectedFileName)) await loadFile(selectedFileName);
472
540
  else {
473
- selectedFileName = null;
474
- document.getElementById('fileContent').textContent = '';
475
- hideExecutionSummary();
476
- updateCopyLastMessageButton();
541
+ const defaultTab = tabs.find(([name]) => name === 'events.pretty') || tabs[0];
542
+ if (defaultTab) await loadFile(defaultTab[0]);
543
+ else {
544
+ selectedFileName = null;
545
+ document.getElementById('fileContent').textContent = '';
546
+ hideExecutionSummary();
547
+ updateCopyLastMessageButton();
548
+ }
477
549
  }
478
550
  }
479
551
  async function loadFile(name, { preserveScroll = false } = {}) {
@@ -481,7 +553,14 @@ async function loadFile(name, { preserveScroll = false } = {}) {
481
553
  selectedFileName = name;
482
554
  const pre = document.getElementById('fileContent');
483
555
  const previousScrollTop = pre.scrollTop;
484
- const text = await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=${encodeURIComponent(name)}`);
556
+ let text;
557
+ const selected = taskById(selectedTask);
558
+ if (name === 'result.json' && selected?.manualCompletion?.hasManualResult) {
559
+ const manualText = await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=manual_result.md`);
560
+ text = manualText ? `【这次是人工结果】\n\n${manualText}` : await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=${encodeURIComponent(name)}`);
561
+ } else {
562
+ text = await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=${encodeURIComponent(name)}`);
563
+ }
485
564
  pre.textContent = text;
486
565
  if (preserveScroll) pre.scrollTop = previousScrollTop;
487
566
  else pre.scrollTop = 0;
@@ -490,7 +569,7 @@ async function loadFile(name, { preserveScroll = false } = {}) {
490
569
  updateCopyLastMessageButton();
491
570
  }
492
571
  function clearFileView() {
493
- document.getElementById('fileTitle').textContent = '点击任务后选择文件';
572
+ document.getElementById('fileTitle').textContent = '点击任务后查看详情';
494
573
  document.getElementById('fileContent').textContent = '';
495
574
  const tabs = document.getElementById('fileTabs');
496
575
  if (tabs) tabs.innerHTML = '';
@@ -500,7 +579,9 @@ function clearFileView() {
500
579
  function updateCopyLastMessageButton() {
501
580
  const button = document.getElementById('copyLastMessageBtn');
502
581
  if (!button) return;
503
- button.classList.toggle('hidden', selectedFileName !== 'last_message.md');
582
+ const copyableFiles = new Set(['last_message.md', 'verdict.json']);
583
+ button.classList.toggle('hidden', !copyableFiles.has(selectedFileName));
584
+ button.title = selectedFileName === 'verdict.json' ? '复制验收结论内容' : '复制最终回复内容';
504
585
  button.textContent = '⧉';
505
586
  }
506
587
  async function copyFileContent(event) {
@@ -520,9 +601,8 @@ function hideExecutionSummary() {
520
601
  el.classList.add('hidden');
521
602
  el.innerHTML = '';
522
603
  }
523
- async function copyRepoPath(event) {
604
+ async function copyRepoPath(event, repoPath = currentState?.repo || '') {
524
605
  event.stopPropagation();
525
- const repoPath = currentState?.repo || '';
526
606
  if (!repoPath) return;
527
607
  try {
528
608
  await navigator.clipboard.writeText(repoPath);
@@ -532,6 +612,10 @@ async function copyRepoPath(event) {
532
612
  prompt('复制仓库地址', repoPath);
533
613
  }
534
614
  }
615
+ async function copyRunRepoPath(event, runId) {
616
+ const repoPath = latestRuns.find(run => run.runId === runId)?.repo || '';
617
+ await copyRepoPath(event, repoPath);
618
+ }
535
619
  async function copyTmuxRunCommand(event) {
536
620
  event.stopPropagation();
537
621
  const command = runAttachCommand(currentState);
@@ -547,32 +631,109 @@ async function copyTmuxRunCommand(event) {
547
631
  async function renderExecutionSummary() {
548
632
  const el = document.getElementById('executionSummary');
549
633
  let raw = '';
634
+ let timedRaw = '';
550
635
  try { raw = await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=events.jsonl`); }
551
636
  catch { raw = ''; }
552
- const summary = summarizeEventsJsonl(raw);
637
+ try { timedRaw = await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=events_timed.jsonl`); }
638
+ catch { timedRaw = ''; }
639
+ const task = taskById(selectedTask);
640
+ const taskMs = taskDurationMs(task);
641
+ const summary = summarizeEventsJsonl(timedRaw || raw, { taskMs });
642
+ const taskDuration = taskDurationCell(task);
643
+ const statChip = (label, value) => `<span class="summary-chip"><span class="summary-label">${esc(label)}</span><span class="summary-value">${esc(value)}</span></span>`;
644
+ const commandTypeChips = summary.commandTypes.length
645
+ ? summary.commandTypes.map(item => `<span class="summary-chip command-type"><span class="summary-value">${esc(item.kind)}</span><span>${item.count}次</span><span class="summary-duration">${esc(item.durationText)}</span><span>占比 ${esc(item.percentText)}</span></span>`).join('')
646
+ : '<span class="summary-chip command-type"><span class="summary-value">-</span></span>';
553
647
  el.classList.remove('hidden');
554
- el.innerHTML = `<span>事件 ${summary.events}</span><span>命令开始 ${summary.commandStarted}</span><span>命令完成 ${summary.commandCompleted}</span><span>MCP ${summary.mcpCalls}</span><span>文件变更 ${summary.fileChanges}</span><span>模型回复 ${summary.agentMessages}</span><span>推理 ${summary.reasoning}</span><span>命令类型 ${esc(summary.commandKindsText)}</span><button class="secondary copy-btn" onclick="scrollFileToBottom()">跳到末尾</button>`;
555
- }
556
- function summarizeEventsJsonl(raw) {
557
- const summary = { events: 0, commandStarted: 0, commandCompleted: 0, mcpCalls: 0, fileChanges: 0, agentMessages: 0, reasoning: 0, commandKinds: new Map(), commandKindsText: '-' };
648
+ el.innerHTML = `${statChip('任务用时', taskDuration)}${statChip('命令用时', summary.commandDurationText)}${statChip('模型/调度', summary.modelOrchestrationText)}${statChip('启动/收尾', summary.startFinishText)}${statChip('事件', summary.events)}${statChip('命令开始', summary.commandStarted)}${statChip('命令完成', summary.commandCompleted)}${statChip('模型回复', summary.agentMessages)}${statChip('推理', summary.reasoning)}${statChip('MCP', summary.mcpCalls)}${statChip('文件变更', summary.fileChanges)}${statChip('系统事件', summary.systemEvents)}<span class="summary-break"></span><span class="summary-label">命令类型</span>${commandTypeChips}<button class="secondary copy-btn" onclick="scrollFileToBottom()">跳到末尾</button>`;
649
+ }
650
+ function eventTimeMs(event) {
651
+ const value = event.receivedAt ?? event.timestamp ?? event.time ?? event.created_at ?? event.createdAt ?? event.ts;
652
+ if (typeof value === 'number' && Number.isFinite(value)) return value > 1e12 ? value : value * 1000;
653
+ if (typeof value !== 'string' || !value.trim()) return null;
654
+ const ms = Date.parse(value);
655
+ return Number.isFinite(ms) ? ms : null;
656
+ }
657
+ function formatDurationMs(ms) {
658
+ if (!Number.isFinite(ms) || ms < 0) return '-';
659
+ if (ms < 1000) return `${Math.round(ms)}毫秒`;
660
+ const seconds = ms / 1000;
661
+ if (seconds < 60) return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}秒`;
662
+ const minutes = Math.floor(seconds / 60);
663
+ const rest = Math.round(seconds % 60);
664
+ return `${minutes}分${String(rest).padStart(2, '0')}秒`;
665
+ }
666
+ function summarizeEventsJsonl(raw, { taskMs = null } = {}) {
667
+ const summary = { events: 0, commandStarted: 0, commandCompleted: 0, mcpCalls: 0, fileChanges: 0, agentMessages: 0, reasoning: 0, systemEvents: 0, commandKinds: new Map(), commandKindsText: '-', commandTypes: [], eventDurationText: '-', commandDurationText: '-', modelOrchestrationText: '-', startFinishText: '-' };
668
+ const commandStarts = new Map();
669
+ const commandCountsByKind = new Map();
670
+ const commandDurationsByKind = new Map();
671
+ let firstEventMs = null;
672
+ let lastEventMs = null;
673
+ let commandDurationTotalMs = 0;
558
674
  for (const line of String(raw || '').split(/\r?\n/).filter(Boolean)) {
559
675
  let event;
560
676
  try { event = JSON.parse(line); } catch { continue; }
677
+ const envelopeTimeMs = eventTimeMs(event);
678
+ event = event.event || event;
561
679
  summary.events++;
680
+ const timeMs = envelopeTimeMs ?? eventTimeMs(event);
681
+ if (timeMs !== null) {
682
+ firstEventMs = firstEventMs === null ? timeMs : Math.min(firstEventMs, timeMs);
683
+ lastEventMs = lastEventMs === null ? timeMs : Math.max(lastEventMs, timeMs);
684
+ }
562
685
  const item = event.item || {};
563
686
  const type = item.type || '';
564
687
  if (type === 'command_execution') {
565
- if (event.type === 'item.started') summary.commandStarted++;
566
- if (event.type === 'item.completed') summary.commandCompleted++;
688
+ const commandId = item.id || `command-${summary.commandStarted + summary.commandCompleted}`;
567
689
  const kind = commandKind(item.command || 'unknown');
690
+ if (event.type === 'item.started') {
691
+ summary.commandStarted++;
692
+ if (timeMs !== null) commandStarts.set(commandId, { timeMs, kind });
693
+ }
694
+ if (event.type === 'item.completed') {
695
+ summary.commandCompleted++;
696
+ const started = commandStarts.get(commandId);
697
+ const completedKind = started?.kind || kind;
698
+ commandCountsByKind.set(completedKind, (commandCountsByKind.get(completedKind) || 0) + 1);
699
+ const inlineDuration = Number(item.duration_ms ?? item.durationMs ?? event.duration_ms ?? event.durationMs);
700
+ let durationMs = null;
701
+ if (Number.isFinite(inlineDuration) && inlineDuration >= 0) durationMs = inlineDuration;
702
+ else if (timeMs !== null && started && Number.isFinite(started.timeMs)) durationMs = Math.max(0, timeMs - started.timeMs);
703
+ if (durationMs !== null) {
704
+ commandDurationTotalMs += durationMs;
705
+ const current = commandDurationsByKind.get(completedKind) || { ms: 0, count: 0 };
706
+ commandDurationsByKind.set(completedKind, { ms: current.ms + durationMs, count: current.count + 1 });
707
+ }
708
+ }
568
709
  summary.commandKinds.set(kind, (summary.commandKinds.get(kind) || 0) + 1);
569
710
  }
570
- if (type === 'mcp_tool_call' || type === 'mcpToolCall') summary.mcpCalls++;
571
- if (type === 'file_change' || type === 'fileChange') summary.fileChanges++;
572
- if (type === 'agent_message' || type === 'agentMessage') summary.agentMessages++;
573
- if (type === 'reasoning') summary.reasoning++;
711
+ else if (type === 'mcp_tool_call' || type === 'mcpToolCall') summary.mcpCalls++;
712
+ else if (type === 'file_change' || type === 'fileChange') summary.fileChanges++;
713
+ else if (type === 'agent_message' || type === 'agentMessage') summary.agentMessages++;
714
+ else if (type === 'reasoning') summary.reasoning++;
715
+ else summary.systemEvents++;
574
716
  }
575
- const kinds = [...summary.commandKinds.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
717
+ let eventDurationMs = null;
718
+ if (firstEventMs !== null && lastEventMs !== null) {
719
+ eventDurationMs = Math.max(0, lastEventMs - firstEventMs);
720
+ summary.eventDurationText = formatDurationMs(eventDurationMs);
721
+ }
722
+ if (commandDurationTotalMs > 0) summary.commandDurationText = formatDurationMs(commandDurationTotalMs);
723
+ if (eventDurationMs !== null) summary.modelOrchestrationText = formatDurationMs(Math.max(0, eventDurationMs - commandDurationTotalMs));
724
+ if (eventDurationMs !== null && Number.isFinite(taskMs) && taskMs >= 0) summary.startFinishText = formatDurationMs(Math.max(0, taskMs - eventDurationMs));
725
+ const commandTypeNames = new Set([...commandCountsByKind.keys(), ...commandDurationsByKind.keys()]);
726
+ summary.commandTypes = [...commandTypeNames].map(kind => {
727
+ const duration = commandDurationsByKind.get(kind);
728
+ return {
729
+ kind,
730
+ count: commandCountsByKind.get(kind) || duration?.count || 0,
731
+ durationMs: duration?.ms ?? null,
732
+ durationText: duration ? formatDurationMs(duration.ms) : '-',
733
+ percentText: duration && Number.isFinite(taskMs) && taskMs > 0 ? `${Math.round((duration.ms / taskMs) * 100)}%` : '-'
734
+ };
735
+ }).sort((a, b) => b.count - a.count || (b.durationMs || 0) - (a.durationMs || 0)).slice(0, 6);
736
+ const kinds = summary.commandTypes.map(item => [item.kind, item.count]);
576
737
  summary.commandKindsText = kinds.length ? kinds.map(([kind, count]) => `${kind}:${count}`).join(' / ') : '-';
577
738
  return summary;
578
739
  }
@@ -602,12 +763,32 @@ async function copySessionId(event, thread) {
602
763
  prompt('复制 Codex 会话ID', thread);
603
764
  }
604
765
  }
766
+ function labelForRun(runId) {
767
+ if (currentState?.runId === runId) return currentState.label || '';
768
+ return (latestRuns.find(run => run.runId === runId)?.label) || '';
769
+ }
770
+ async function renameRunLabel(event, runId = selectedRun) {
771
+ event.stopPropagation();
772
+ if (!runId) return;
773
+ const nextLabel = prompt('修改任务批次名称', labelForRun(runId));
774
+ if (nextLabel === null) return;
775
+ const label = nextLabel.trim();
776
+ if (!label) { alert('任务批次名称不能为空'); return; }
777
+ await runAction(async () => {
778
+ await api(`/api/runs/${runId}/label`, { method: 'PATCH', body: JSON.stringify({ label }) });
779
+ if (selectedRun === runId) await refreshSelected();
780
+ else await refreshRuns();
781
+ });
782
+ }
605
783
  async function planRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/plan`, {method:'POST'}); await refreshSelected(); }); }
606
784
  async function dispatchRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/dispatch`, {method:'POST'}); await refreshSelected(); }); }
607
785
  async function judgeRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/judge`, {method:'POST'}); await refreshSelected(); }); }
608
786
  async function runAction(fn) {
609
787
  try { await fn(); }
610
- catch (error) { alert(error.message || String(error)); }
788
+ catch (error) {
789
+ console.error('操作失败', error);
790
+ alert(userFacingErrorMessage(error));
791
+ }
611
792
  }
612
793
  async function stopSelectedRun() {
613
794
  if (!selectedRun) return;
@@ -640,15 +821,29 @@ async function markTaskCompleted(event, taskId) {
640
821
  if (!selectedRun || taskId === 'planner' || taskId === 'judge') return;
641
822
  const task = (currentState?.tasks || []).find(t => t.id === taskId);
642
823
  if (task?.status === 'running') { alert('任务仍在执行中,不能手动标记成功。'); return; }
643
- const ok = confirm(`确认将任务 ${taskId} 手动标记为成功?\n\n请仅在你已经通过 CLI / Codex 会话确认该任务实际完成时使用。\n原始状态会保留在页面和 manual_completion.json 中。`);
644
- if (!ok) return;
645
- selectedTask = taskId;
824
+ manualCompleteTaskId = taskId;
825
+ document.getElementById('manualCompleteTitle').textContent = `${selectedRun} / ${taskId}`;
826
+ document.getElementById('manualCompleteResult').value = '';
827
+ document.getElementById('manualCompleteModal').classList.remove('hidden');
828
+ setTimeout(() => document.getElementById('manualCompleteResult').focus(), 0);
829
+ }
830
+ function closeManualCompleteModal() {
831
+ manualCompleteTaskId = null;
832
+ document.getElementById('manualCompleteModal').classList.add('hidden');
833
+ }
834
+ async function submitManualComplete() {
835
+ const taskId = manualCompleteTaskId;
836
+ if (!selectedRun || !taskId) return;
837
+ const resultText = document.getElementById('manualCompleteResult').value.trim();
838
+ if (!resultText) { alert('请粘贴人工成功执行结果。'); return; }
646
839
  await api(`/api/runs/${selectedRun}/tasks/${taskId}/mark-completed`, {
647
840
  method: 'POST',
648
- body: JSON.stringify({ reason: 'manual success confirmed from dashboard' })
841
+ body: JSON.stringify({ reason: 'manual success confirmed from dashboard', resultText })
649
842
  });
843
+ closeManualCompleteModal();
844
+ selectedTask = taskId;
650
845
  await refreshSelected();
651
- await loadFile('manual_completion.json');
846
+ await loadFile('result.json');
652
847
  }
653
848
 
654
849
  loadHealth().then(refreshRuns);
@@ -356,7 +356,18 @@ export async function archiveRun(runId, { reason = 'archived by user' } = {}) {
356
356
  return state;
357
357
  }
358
358
 
359
- export async function markTaskCompleted(runId, taskId, { reason = 'manual success confirmed by user' } = {}) {
359
+ export async function renameRun(runId, { label = '' } = {}) {
360
+ const state = await loadRun(runId);
361
+ if (!state) throw new Error(`run not found: ${runId}`);
362
+ const nextLabel = String(label || '').trim();
363
+ if (!nextLabel) throw userInputError('run label cannot be empty');
364
+ state.label = nextLabel;
365
+ state.renamedAt = nowIso();
366
+ await saveRun(state);
367
+ return state;
368
+ }
369
+
370
+ export async function markTaskCompleted(runId, taskId, { reason = 'manual success confirmed by user', resultText = '' } = {}) {
360
371
  const state = await loadRun(runId);
361
372
  if (!state) throw new Error(`run not found: ${runId}`);
362
373
  const task = (state.tasks || []).find(t => t.id === taskId);
@@ -366,6 +377,8 @@ export async function markTaskCompleted(runId, taskId, { reason = 'manual succes
366
377
  const outDir = roleDir(runDir, 'worker', task.id);
367
378
  await ensureDir(outDir);
368
379
  if (task.status !== 'completed') {
380
+ const manualResult = String(resultText || '').trim();
381
+ if (manualResult) await fsp.writeFile(path.join(outDir, 'manual_result.md'), manualResult);
369
382
  const override = {
370
383
  type: 'manual_task_completed',
371
384
  runId,
@@ -375,6 +388,9 @@ export async function markTaskCompleted(runId, taskId, { reason = 'manual succes
375
388
  previousStatus: task.status,
376
389
  previousExitCode: task.exitCode ?? null,
377
390
  reason,
391
+ hasManualResult: !!manualResult,
392
+ manualResultFile: manualResult ? 'manual_result.md' : null,
393
+ manualResultPreview: manualResult ? manualResult.slice(0, 500) : '',
378
394
  markedAt: nowIso()
379
395
  };
380
396
  await writeJsonAtomic(path.join(outDir, 'manual_completion.json'), override);
@@ -579,11 +595,13 @@ async function standardFiles(dir) {
579
595
  return {
580
596
  prompt: await fileInfo(path.join(dir, 'prompt.md')),
581
597
  events: await fileInfo(path.join(dir, 'events.jsonl')),
598
+ timedEvents: await fileInfo(path.join(dir, 'events_timed.jsonl')),
582
599
  stderr: await fileInfo(path.join(dir, 'stderr.log')),
583
600
  lastMessage: await fileInfo(path.join(dir, 'last_message.md')),
584
601
  exitCode: await fileInfo(path.join(dir, 'exit_code')),
585
602
  runScript: await fileInfo(path.join(dir, 'run.sh')),
586
- tmux: await fileInfo(path.join(dir, 'tmux.json'))
603
+ tmux: await fileInfo(path.join(dir, 'tmux.json')),
604
+ manualResult: await fileInfo(path.join(dir, 'manual_result.md'))
587
605
  };
588
606
  }
589
607
 
@@ -674,6 +692,7 @@ async function buildJudgeInput(state) {
674
692
  resultJson: await readJson(path.join(dir, 'result.json'), null),
675
693
  evidenceJson: await readJson(path.join(dir, 'evidence.json'), null),
676
694
  manualCompletion: task.manualCompletion || await readJson(path.join(dir, 'manual_completion.json'), null),
695
+ manualResult: await readTextMaybe(path.join(dir, 'manual_result.md'), 200000),
677
696
  tmux: task.tmux || null,
678
697
  stderrTail: await readTextMaybe(path.join(dir, 'stderr.log'), 20000)
679
698
  });
@@ -738,9 +757,22 @@ async function enrichFromAppServer(state, appClient) {
738
757
  }
739
758
  }
740
759
 
760
+ function runDurationEndOfState(s) {
761
+ const terminalStatuses = new Set(['judged', 'judge_failed', 'batch_blocked', 'plan_failed', 'plan_empty', 'stopped']);
762
+ if (!terminalStatuses.has(s.status)) return null;
763
+ const times = [
764
+ s.stoppedAt,
765
+ s.stopInfo?.stoppedAt,
766
+ s.judge?.endedAt,
767
+ s.planner?.endedAt,
768
+ ...(s.tasks || []).flatMap(task => [task.endedAt, task.completedAt, task.stoppedAt])
769
+ ].map(value => Date.parse(value || '')).filter(Number.isFinite);
770
+ return times.length ? new Date(Math.max(...times)).toISOString() : s.updatedAt;
771
+ }
772
+
741
773
  function summaryOfRun(s) {
742
774
  const tasks = s.tasks || [];
743
- return { runId: s.runId, label: s.label, repo: s.repo, status: s.status, runner: s.runner || RUNNER, workerSandbox: s.workerSandbox || 'workspace-write', archived: !!s.archived, createdAt: s.createdAt, updatedAt: s.updatedAt, total: tasks.length, completed: tasks.filter(t => t.status === 'completed').length, failed: tasks.filter(t => ['failed','unknown'].includes(t.status)).length, running: tasks.filter(t => t.status === 'running').length, batches: (s.batches || []).map(b => ({ id: b.id, name: b.name, status: b.status, total: b.tasks?.length || 0, completed: (b.tasks || []).filter(t => t.status === 'completed').length })) };
775
+ return { runId: s.runId, label: s.label, repo: s.repo, status: s.status, runner: s.runner || RUNNER, workerSandbox: s.workerSandbox || 'workspace-write', archived: !!s.archived, createdAt: s.createdAt, updatedAt: s.updatedAt, durationEnd: runDurationEndOfState(s), total: tasks.length, completed: tasks.filter(t => t.status === 'completed').length, failed: tasks.filter(t => ['failed','unknown'].includes(t.status)).length, running: tasks.filter(t => t.status === 'running').length, batches: (s.batches || []).map(b => ({ id: b.id, name: b.name, status: b.status, total: b.tasks?.length || 0, completed: (b.tasks || []).filter(t => t.status === 'completed').length })) };
744
776
  }
745
777
 
746
778
  export async function readRunTaskText(runId) {
@@ -749,7 +781,7 @@ export async function readRunTaskText(runId) {
749
781
 
750
782
  export async function readRunFile(runId, taskId, name) {
751
783
  const runDir = pathForRun(runId);
752
- const allowed = new Set(['prompt.md','events.jsonl','events.pretty','stderr.log','last_message.md','exit_code','result.json','evidence.json','verdict.json','judge_input.json','manual_completion.json','run.sh','tmux.json']);
784
+ const allowed = new Set(['prompt.md','events.jsonl','events_timed.jsonl','events.pretty','stderr.log','last_message.md','exit_code','result.json','evidence.json','verdict.json','judge_input.json','manual_completion.json','manual_result.md','run.sh','tmux.json']);
753
785
  if (!allowed.has(name)) throw new Error('file not allowed');
754
786
  let dir;
755
787
  if (taskId === 'planner') dir = roleDir(runDir, 'planner');
@@ -7,17 +7,48 @@ function processKey(runId, taskId) {
7
7
  return `${runId}:${taskId}`;
8
8
  }
9
9
 
10
+ function captureEventsWithTimestamps(stream, eventsFile, timedEventsFile) {
11
+ const events = fs.createWriteStream(eventsFile, { flags: 'a' });
12
+ const timedEvents = fs.createWriteStream(timedEventsFile, { flags: 'a' });
13
+ let buffer = '';
14
+ const writeLine = line => {
15
+ events.write(`${line}\n`);
16
+ const receivedAt = new Date().toISOString();
17
+ try {
18
+ timedEvents.write(`${JSON.stringify({ receivedAt, event: JSON.parse(line) })}\n`);
19
+ } catch {
20
+ timedEvents.write(`${JSON.stringify({ receivedAt, rawLine: line })}\n`);
21
+ }
22
+ };
23
+ stream.setEncoding('utf8');
24
+ stream.on('data', chunk => {
25
+ buffer += chunk;
26
+ let newlineIndex;
27
+ while ((newlineIndex = buffer.indexOf('\n')) >= 0) {
28
+ const line = buffer.slice(0, newlineIndex).replace(/\r$/, '');
29
+ buffer = buffer.slice(newlineIndex + 1);
30
+ if (line) writeLine(line);
31
+ }
32
+ });
33
+ stream.on('end', () => {
34
+ if (buffer) writeLine(buffer.replace(/\r$/, ''));
35
+ events.end();
36
+ timedEvents.end();
37
+ });
38
+ }
39
+
10
40
  export function createHeadlessRunner({ codexBin = CODEX_BIN } = {}) {
11
41
  const runningProcesses = new Map();
12
42
 
13
43
  function startCodexTask({ runId, taskId, prompt, sandbox, cwd, outDir }) {
14
44
  const events = path.join(outDir, 'events.jsonl');
45
+ const timedEvents = path.join(outDir, 'events_timed.jsonl');
15
46
  const stderr = path.join(outDir, 'stderr.log');
16
47
  const last = path.join(outDir, 'last_message.md');
17
48
  fs.writeFileSync(path.join(outDir, 'prompt.md'), prompt);
18
49
  const args = ['exec', '--json', '--sandbox', sandbox, '-C', cwd, '-o', last, prompt];
19
50
  const child = spawn(codexBin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
20
- child.stdout.pipe(fs.createWriteStream(events, { flags: 'a' }));
51
+ captureEventsWithTimestamps(child.stdout, events, timedEvents);
21
52
  child.stderr.pipe(fs.createWriteStream(stderr, { flags: 'a' }));
22
53
  const key = processKey(runId, taskId);
23
54
  runningProcesses.set(key, child);
@@ -46,6 +46,7 @@ function shellQuote(value) {
46
46
 
47
47
  const BIN_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../bin');
48
48
  const FORMATTER_BIN = path.join(BIN_DIR, 'input-kanban-format-events.js');
49
+ const TIMESTAMP_BIN = path.join(BIN_DIR, 'input-kanban-timestamp-events.js');
49
50
  const OVERVIEW_BIN = path.join(BIN_DIR, 'input-kanban-tmux-overview.js');
50
51
 
51
52
  function buildOverviewCommand(runStatePath) {
@@ -54,7 +55,7 @@ function buildOverviewCommand(runStatePath) {
54
55
  return `while true; do clear; node ${quotedOverviewBin} ${quotedStatePath}; sleep 2; done`;
55
56
  }
56
57
 
57
- function buildRunScript({ codexBin, formatterBin = FORMATTER_BIN, sandbox, cwd, outDir, runId, taskId, role }) {
58
+ function buildRunScript({ codexBin, formatterBin = FORMATTER_BIN, timestampBin = TIMESTAMP_BIN, sandbox, cwd, outDir, runId, taskId, role }) {
58
59
  return `#!/usr/bin/env bash
59
60
  set -u
60
61
 
@@ -67,15 +68,17 @@ TASK_ID=${shellQuote(taskId)}
67
68
  ROLE=${shellQuote(role)}
68
69
  PROMPT_FILE="$OUT_DIR/prompt.md"
69
70
  EVENTS="$OUT_DIR/events.jsonl"
71
+ TIMED_EVENTS="$OUT_DIR/events_timed.jsonl"
70
72
  STDERR_LOG="$OUT_DIR/stderr.log"
71
73
  FORMATTER_BIN=${shellQuote(formatterBin)}
74
+ TIMESTAMP_BIN=${shellQuote(timestampBin)}
72
75
  LAST_MESSAGE="$OUT_DIR/last_message.md"
73
76
  EXIT_CODE="$OUT_DIR/exit_code"
74
77
 
75
78
  cd "$CWD"
76
79
  rm -f "$EXIT_CODE"
77
- touch "$EVENTS" "$STDERR_LOG"
78
- "$CODEX_BIN" exec --json --sandbox "$SANDBOX" -C "$CWD" -o "$LAST_MESSAGE" "$(<"$PROMPT_FILE")" > >(tee -a "$EVENTS" | node "$FORMATTER_BIN") 2> >(tee -a "$STDERR_LOG" >&2)
80
+ touch "$EVENTS" "$TIMED_EVENTS" "$STDERR_LOG"
81
+ "$CODEX_BIN" exec --json --sandbox "$SANDBOX" -C "$CWD" -o "$LAST_MESSAGE" "$(<"$PROMPT_FILE")" > >(node "$TIMESTAMP_BIN" "$EVENTS" "$TIMED_EVENTS" | node "$FORMATTER_BIN") 2> >(tee -a "$STDERR_LOG" >&2)
79
82
  code=$?
80
83
  printf '%s' "$code" > "$EXIT_CODE"
81
84
  printf '\\nInput Kanban tmux task completed.\\n'
package/src/server.js CHANGED
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { CodexAppServerClient } from './appServerClient.js';
6
6
  import { APP_ROOT, DEFAULT_REPO, RUNNER, RUNS_DIR } from './utils.js';
7
- import { createRun, listRuns, startPlanner, dispatchRun, startJudge, refreshRun, readRunFile, readRunTaskText, markTaskCompleted, stopRun, archiveRun } from './orchestrator.js';
7
+ import { createRun, listRuns, startPlanner, dispatchRun, startJudge, refreshRun, readRunFile, readRunTaskText, markTaskCompleted, stopRun, archiveRun, renameRun } from './orchestrator.js';
8
8
 
9
9
  const PUBLIC_DIR = path.join(APP_ROOT, 'public');
10
10
 
@@ -66,6 +66,10 @@ async function handleApi(req, res, url, appClient) {
66
66
  const body = await readBody(req);
67
67
  return send(res, 200, await archiveRun(runId, body));
68
68
  }
69
+ if (parts.length === 4 && parts[3] === 'label' && req.method === 'PATCH') {
70
+ const body = await readBody(req);
71
+ return send(res, 200, await renameRun(runId, body));
72
+ }
69
73
  if (parts.length === 4 && parts[3] === 'task-text' && req.method === 'GET') return send(res, 200, await readRunTaskText(runId), 'text/plain');
70
74
  if (parts.length === 6 && parts[3] === 'tasks' && parts[5] === 'file' && req.method === 'GET') {
71
75
  const text = await readRunFile(runId, parts[4], url.searchParams.get('name') || 'last_message.md');