input-kanban 0.0.6 → 0.0.8

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.
@@ -15,6 +15,8 @@ import { defaultRunner } from './runners/index.js';
15
15
  const execFileAsync = promisify(execFile);
16
16
  const runner = defaultRunner;
17
17
  const VALID_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
18
+ const MISSING_RUNNER_GRACE_MS = 10000;
19
+ const MAX_DERIVED_LABEL_DISPLAY_WIDTH = 40;
18
20
 
19
21
  function normalizeSandbox(value, fallback = 'workspace-write') {
20
22
  const sandbox = String(value || '').trim();
@@ -25,12 +27,81 @@ function normalizeSandbox(value, fallback = 'workspace-write') {
25
27
  function statePath(runDir) { return path.join(runDir, 'run_state.json'); }
26
28
  function planPath(runDir) { return path.join(runDir, 'plan.json'); }
27
29
 
30
+ function shouldMarkRunnerUnknown(target) {
31
+ const missingSince = Date.parse(target.missingRunnerAt || '');
32
+ if (!Number.isFinite(missingSince)) {
33
+ target.missingRunnerAt = nowIso();
34
+ return false;
35
+ }
36
+ return Date.now() - missingSince >= MISSING_RUNNER_GRACE_MS;
37
+ }
38
+
39
+ function clearMissingRunner(target) {
40
+ delete target.missingRunnerAt;
41
+ }
42
+
43
+ function isPidAlive(pid) {
44
+ const value = Number(pid);
45
+ if (!Number.isFinite(value) || value <= 0) return false;
46
+ try {
47
+ process.kill(value, 0);
48
+ return true;
49
+ } catch (error) {
50
+ return error?.code === 'EPERM';
51
+ }
52
+ }
53
+
54
+ function hasLiveRunnerProcess(state, id, target) {
55
+ return runner.hasRunning(state.runId, id) || isPidAlive(target?.pid);
56
+ }
57
+
58
+ function stopPid(pid, signal = 'SIGTERM') {
59
+ const value = Number(pid);
60
+ if (!Number.isFinite(value) || value <= 0) return false;
61
+ try {
62
+ process.kill(value, signal);
63
+ if (signal !== 'SIGKILL') setTimeout(() => { if (isPidAlive(value)) stopPid(value, 'SIGKILL'); }, 1000).unref?.();
64
+ return true;
65
+ } catch (error) {
66
+ return error?.code === 'ESRCH';
67
+ }
68
+ }
69
+
28
70
  function userInputError(message) {
29
71
  const error = new Error(message);
30
72
  error.statusCode = 400;
31
73
  return error;
32
74
  }
33
75
 
76
+ function charDisplayWidth(char) {
77
+ return char.codePointAt(0) > 0x2e80 ? 2 : 1;
78
+ }
79
+
80
+ function truncateDisplayWidth(text, maxWidth) {
81
+ let width = 0;
82
+ let result = '';
83
+ for (const char of text) {
84
+ const nextWidth = width + charDisplayWidth(char);
85
+ if (nextWidth > maxWidth) return `${result.trimEnd()}…`;
86
+ result += char;
87
+ width = nextWidth;
88
+ }
89
+ return result;
90
+ }
91
+
92
+ function deriveRunLabel(label, taskText) {
93
+ const explicit = String(label || '').trim();
94
+ if (explicit) return explicit;
95
+ const firstLine = String(taskText || '').split(/\r?\n/).map(line => line.trim()).find(Boolean) || 'task';
96
+ const cleaned = firstLine
97
+ .replace(/^#{1,6}\s+/, '')
98
+ .replace(/^[-*+]\s+/, '')
99
+ .replace(/^\d+[.)、]\s*/, '')
100
+ .replace(/\s+/g, ' ')
101
+ .trim();
102
+ return truncateDisplayWidth(cleaned, MAX_DERIVED_LABEL_DISPLAY_WIDTH) || 'task';
103
+ }
104
+
34
105
  async function assertGitWorkTree(repo) {
35
106
  const resolvedRepo = path.resolve(repo || DEFAULT_REPO);
36
107
  let stat;
@@ -44,14 +115,15 @@ async function assertGitWorkTree(repo) {
44
115
  throw userInputError(`target repository is not a git work tree: ${resolvedRepo}`);
45
116
  }
46
117
 
47
- export async function createRun({ label = 'task', taskText = '', repo = DEFAULT_REPO, maxParallel = 3, workerSandbox = 'workspace-write' } = {}) {
118
+ export async function createRun({ label = '', taskText = '', repo = DEFAULT_REPO, maxParallel = 3, workerSandbox = 'workspace-write' } = {}) {
48
119
  const resolvedRepo = await assertGitWorkTree(repo);
49
- const runId = makeRunId(label);
120
+ const runLabel = deriveRunLabel(label, taskText);
121
+ const runId = makeRunId(runLabel);
50
122
  const runDir = pathForRun(runId);
51
123
  await ensureDir(runDir);
52
124
  await fsp.writeFile(path.join(runDir, 'task.md'), taskText || '');
53
125
  const state = {
54
- runId, label, repo: resolvedRepo, maxParallel: Number(maxParallel) || 3, workerSandbox: normalizeSandbox(workerSandbox),
126
+ runId, label: runLabel, repo: resolvedRepo, maxParallel: Number(maxParallel) || 3, workerSandbox: normalizeSandbox(workerSandbox),
55
127
  runner: RUNNER,
56
128
  status: 'created', createdAt: nowIso(), updatedAt: nowIso(),
57
129
  planner: { status: 'pending' }, batches: [], tasks: [], judge: { status: 'pending' }
@@ -200,7 +272,17 @@ export async function startPlanner(runId) {
200
272
  return state;
201
273
  }
202
274
 
203
- function normalizeTask(t, i, batch, defaultSandbox = 'workspace-write') {
275
+ function normalizeExpectedArtifacts(value, runId, taskId) {
276
+ const artifacts = Array.isArray(value) ? value : [];
277
+ return artifacts.map(item => String(item || '').trim()).filter(Boolean).map(item => {
278
+ if (path.isAbsolute(item)) return item;
279
+ const normalized = item.replace(/^\.\//, '');
280
+ if (normalized.includes(runId)) return normalized;
281
+ return path.posix.join('.orchestrator', runId, taskId, normalized);
282
+ });
283
+ }
284
+
285
+ function normalizeTask(t, i, batch, defaultSandbox = 'workspace-write', runId = '') {
204
286
  const id = safeIdPart(t.id || `T-${String(i + 1).padStart(2, '0')}`);
205
287
  return {
206
288
  id,
@@ -208,7 +290,7 @@ function normalizeTask(t, i, batch, defaultSandbox = 'workspace-write') {
208
290
  name: t.name || t.id || `Task ${i + 1}`,
209
291
  prompt: t.prompt || t.instructions || '',
210
292
  sandbox: normalizeSandbox(t.sandbox, defaultSandbox),
211
- expectedArtifacts: Array.isArray(t.expectedArtifacts) ? t.expectedArtifacts : [],
293
+ expectedArtifacts: normalizeExpectedArtifacts(t.expectedArtifacts, runId, id),
212
294
  status: 'pending'
213
295
  };
214
296
  }
@@ -240,7 +322,7 @@ async function rotatePlannerAttempt(state, runDir) {
240
322
  }];
241
323
  }
242
324
 
243
- function normalizePlan(plan, defaultMaxParallel, defaultSandbox = 'workspace-write') {
325
+ function normalizePlan(plan, defaultMaxParallel, defaultSandbox = 'workspace-write', runId = '') {
244
326
  if (Array.isArray(plan.batches)) {
245
327
  const batches = plan.batches.map((b, bi) => {
246
328
  const batch = {
@@ -250,14 +332,14 @@ function normalizePlan(plan, defaultMaxParallel, defaultSandbox = 'workspace-wri
250
332
  status: 'pending',
251
333
  tasks: []
252
334
  };
253
- batch.tasks = (Array.isArray(b.tasks) ? b.tasks : []).map((t, ti) => normalizeTask(t, ti, batch, defaultSandbox));
335
+ batch.tasks = (Array.isArray(b.tasks) ? b.tasks : []).map((t, ti) => normalizeTask(t, ti, batch, defaultSandbox, runId));
254
336
  return batch;
255
337
  }).filter(b => b.tasks.length);
256
338
  return { ...plan, batches, tasks: batches.flatMap(b => b.tasks) };
257
339
  }
258
340
  if (Array.isArray(plan.tasks)) {
259
341
  const batch = { id: 'batch-1', name: '默认批次', maxParallel: Math.max(1, Number(defaultMaxParallel) || 1), status: 'pending', tasks: [] };
260
- batch.tasks = plan.tasks.map((t, i) => normalizeTask(t, i, batch, defaultSandbox));
342
+ batch.tasks = plan.tasks.map((t, i) => normalizeTask(t, i, batch, defaultSandbox, runId));
261
343
  return { ...plan, batches: [batch], tasks: batch.tasks };
262
344
  }
263
345
  return null;
@@ -273,7 +355,7 @@ async function materializePlan(state) {
273
355
  state.tasks = [];
274
356
  return { ok: false, empty: false, error: state.planner.planParseError };
275
357
  }
276
- const normalized = normalizePlan(plan, state.maxParallel, state.workerSandbox || 'workspace-write');
358
+ const normalized = normalizePlan(plan, state.maxParallel, state.workerSandbox || 'workspace-write', state.runId);
277
359
  if (!normalized || !Array.isArray(normalized.tasks)) {
278
360
  state.planner.planParseError = 'planner JSON did not contain { batches: [...] } or { tasks: [...] }';
279
361
  state.batches = [];
@@ -310,6 +392,28 @@ export async function dispatchRun(runId) {
310
392
  return state;
311
393
  }
312
394
 
395
+ function artifactPathForState(state, rel) {
396
+ return path.isAbsolute(rel) ? rel : path.join(state.repo, rel);
397
+ }
398
+
399
+ function workerArtifactInstructions(state, task) {
400
+ const artifacts = task.expectedArtifacts || [];
401
+ if (!artifacts.length) return '';
402
+ const lines = artifacts.map(rel => `- ${artifactPathForState(state, rel)}`);
403
+ return `\n\nRequired output artifacts:\nWrite the following artifact path(s) exactly. Create parent directories if needed.\n${lines.join('\n')}`;
404
+ }
405
+
406
+ function upstreamArtifactInstructions(state, task) {
407
+ const currentBatchIndex = (state.batches || []).findIndex(batch => batch.id === task.batchId);
408
+ if (currentBatchIndex <= 0) return '';
409
+ const previousTaskIds = new Set((state.batches || []).slice(0, currentBatchIndex).flatMap(batch => (batch.tasks || []).map(item => item.id)));
410
+ const lines = (state.tasks || [])
411
+ .filter(item => previousTaskIds.has(item.id))
412
+ .flatMap(item => (item.expectedArtifacts || []).map(rel => `- ${item.id}: ${artifactPathForState(state, rel)}`));
413
+ if (!lines.length) return '';
414
+ return `\n\nAvailable upstream artifacts from completed earlier batches:\n${lines.join('\n')}\nUse only artifacts from this run id: ${state.runId}.`;
415
+ }
416
+
313
417
  async function startWorkerInState(state, task) {
314
418
  const runDir = pathForRun(state.runId);
315
419
  const outDir = roleDir(runDir, 'worker', task.id);
@@ -317,7 +421,7 @@ async function startWorkerInState(state, task) {
317
421
  const fullPrompt = `${marker(state.runId, task.id, 'worker')}
318
422
  ORCHESTRATOR_BATCH_ID: ${task.batchId || 'batch-1'}
319
423
 
320
- ${task.prompt}
424
+ ${task.prompt}${workerArtifactInstructions(state, task)}${upstreamArtifactInstructions(state, task)}
321
425
  `;
322
426
  const child = await runner.startCodexTask({ runId: state.runId, taskId: task.id, batchId: task.batchId || 'batch-1', runStatePath: statePath(runDir), prompt: fullPrompt, sandbox: task.sandbox || state.workerSandbox || 'workspace-write', cwd: state.repo, outDir });
323
427
  Object.assign(task, { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir });
@@ -328,11 +432,28 @@ export async function stopRun(runId, { reason = 'stopped by user' } = {}) {
328
432
  if (!state) throw new Error(`run not found: ${runId}`);
329
433
  const stoppedAt = nowIso();
330
434
  await runner.stopRun(runId);
435
+ const stoppedPids = new Set();
436
+ const stopTargetPid = target => {
437
+ const pid = Number(target?.pid);
438
+ if (Number.isFinite(pid) && pid > 0 && !stoppedPids.has(pid)) {
439
+ stoppedPids.add(pid);
440
+ stopPid(pid);
441
+ }
442
+ };
331
443
  for (const roleState of [state.planner, state.judge]) {
332
- if (roleState?.status === 'running') Object.assign(roleState, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
444
+ if (roleState?.status === 'running') {
445
+ stopTargetPid(roleState);
446
+ Object.assign(roleState, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
447
+ }
333
448
  }
334
449
  for (const task of state.tasks || []) {
335
- if (task.status === 'running') Object.assign(task, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
450
+ if (task.status === 'running') {
451
+ stopTargetPid(task);
452
+ Object.assign(task, { status: 'stopped', stoppedAt, endedAt: stoppedAt });
453
+ }
454
+ }
455
+ for (const batch of state.batches || []) {
456
+ for (const task of batch.tasks || []) if (task.status === 'running') stopTargetPid(task);
336
457
  }
337
458
  for (const batch of state.batches || []) {
338
459
  if ((batch.tasks || []).some(t => t.status === 'stopped')) batch.status = 'stopped';
@@ -356,7 +477,18 @@ export async function archiveRun(runId, { reason = 'archived by user' } = {}) {
356
477
  return state;
357
478
  }
358
479
 
359
- export async function markTaskCompleted(runId, taskId, { reason = 'manual success confirmed by user' } = {}) {
480
+ export async function renameRun(runId, { label = '' } = {}) {
481
+ const state = await loadRun(runId);
482
+ if (!state) throw new Error(`run not found: ${runId}`);
483
+ const nextLabel = String(label || '').trim();
484
+ if (!nextLabel) throw userInputError('run label cannot be empty');
485
+ state.label = nextLabel;
486
+ state.renamedAt = nowIso();
487
+ await saveRun(state);
488
+ return state;
489
+ }
490
+
491
+ export async function markTaskCompleted(runId, taskId, { reason = 'manual success confirmed by user', resultText = '' } = {}) {
360
492
  const state = await loadRun(runId);
361
493
  if (!state) throw new Error(`run not found: ${runId}`);
362
494
  const task = (state.tasks || []).find(t => t.id === taskId);
@@ -366,6 +498,8 @@ export async function markTaskCompleted(runId, taskId, { reason = 'manual succes
366
498
  const outDir = roleDir(runDir, 'worker', task.id);
367
499
  await ensureDir(outDir);
368
500
  if (task.status !== 'completed') {
501
+ const manualResult = String(resultText || '').trim();
502
+ if (manualResult) await fsp.writeFile(path.join(outDir, 'manual_result.md'), manualResult);
369
503
  const override = {
370
504
  type: 'manual_task_completed',
371
505
  runId,
@@ -375,6 +509,9 @@ export async function markTaskCompleted(runId, taskId, { reason = 'manual succes
375
509
  previousStatus: task.status,
376
510
  previousExitCode: task.exitCode ?? null,
377
511
  reason,
512
+ hasManualResult: !!manualResult,
513
+ manualResultFile: manualResult ? 'manual_result.md' : null,
514
+ manualResultPreview: manualResult ? manualResult.slice(0, 500) : '',
378
515
  markedAt: nowIso()
379
516
  };
380
517
  await writeJsonAtomic(path.join(outDir, 'manual_completion.json'), override);
@@ -479,9 +616,16 @@ async function refreshRole(state, roleState, dir) {
479
616
  if (exit !== '') {
480
617
  roleState.exitCode = Number(exit.trim());
481
618
  if (!roleState.endedAt && exitInfo.exists) roleState.endedAt = exitInfo.mtime;
482
- if (roleState.status === 'running') roleState.status = roleState.exitCode === 0 ? 'completed' : 'failed';
619
+ clearMissingRunner(roleState);
620
+ if (['running', 'unknown'].includes(roleState.status)) roleState.status = roleState.exitCode === 0 ? 'completed' : 'failed';
621
+ }
622
+ else if (['running', 'unknown'].includes(roleState.status) && hasLiveRunnerProcess(state, key, roleState)) {
623
+ roleState.status = 'running';
624
+ clearMissingRunner(roleState);
483
625
  }
484
- else if (roleState.status === 'running' && !runner.hasRunning(state.runId, key)) roleState.status = 'unknown';
626
+ else if (roleState.status === 'running' && !hasLiveRunnerProcess(state, key, roleState)) {
627
+ if (shouldMarkRunnerUnknown(roleState)) roleState.status = 'unknown';
628
+ } else if (roleState.status === 'running') clearMissingRunner(roleState);
485
629
  roleState.files = await standardFiles(dir);
486
630
  await attachTmuxMetadata(roleState, dir);
487
631
  }
@@ -494,8 +638,14 @@ async function refreshTask(state, task) {
494
638
  if (exit !== '') {
495
639
  task.exitCode = Number(exit.trim());
496
640
  if (!task.endedAt && exitInfo.exists) task.endedAt = exitInfo.mtime;
497
- if (task.status === 'running') task.status = task.exitCode === 0 ? 'completed' : 'failed';
498
- } else if (task.status === 'running' && !runner.hasRunning(state.runId, task.id)) task.status = 'unknown';
641
+ clearMissingRunner(task);
642
+ if (['pending', 'running', 'unknown'].includes(task.status)) task.status = task.exitCode === 0 ? 'completed' : 'failed';
643
+ } else if (['running', 'unknown'].includes(task.status) && hasLiveRunnerProcess(state, task.id, task)) {
644
+ task.status = 'running';
645
+ clearMissingRunner(task);
646
+ } else if (task.status === 'running' && !hasLiveRunnerProcess(state, task.id, task)) {
647
+ if (shouldMarkRunnerUnknown(task)) task.status = 'unknown';
648
+ } else if (task.status === 'running') clearMissingRunner(task);
499
649
  task.files = await standardFiles(dir);
500
650
  await attachTmuxMetadata(task, dir);
501
651
  delete task.attentionHint;
@@ -579,11 +729,13 @@ async function standardFiles(dir) {
579
729
  return {
580
730
  prompt: await fileInfo(path.join(dir, 'prompt.md')),
581
731
  events: await fileInfo(path.join(dir, 'events.jsonl')),
732
+ timedEvents: await fileInfo(path.join(dir, 'events_timed.jsonl')),
582
733
  stderr: await fileInfo(path.join(dir, 'stderr.log')),
583
734
  lastMessage: await fileInfo(path.join(dir, 'last_message.md')),
584
735
  exitCode: await fileInfo(path.join(dir, 'exit_code')),
585
736
  runScript: await fileInfo(path.join(dir, 'run.sh')),
586
- tmux: await fileInfo(path.join(dir, 'tmux.json'))
737
+ tmux: await fileInfo(path.join(dir, 'tmux.json')),
738
+ manualResult: await fileInfo(path.join(dir, 'manual_result.md'))
587
739
  };
588
740
  }
589
741
 
@@ -674,6 +826,7 @@ async function buildJudgeInput(state) {
674
826
  resultJson: await readJson(path.join(dir, 'result.json'), null),
675
827
  evidenceJson: await readJson(path.join(dir, 'evidence.json'), null),
676
828
  manualCompletion: task.manualCompletion || await readJson(path.join(dir, 'manual_completion.json'), null),
829
+ manualResult: await readTextMaybe(path.join(dir, 'manual_result.md'), 200000),
677
830
  tmux: task.tmux || null,
678
831
  stderrTail: await readTextMaybe(path.join(dir, 'stderr.log'), 20000)
679
832
  });
@@ -738,9 +891,22 @@ async function enrichFromAppServer(state, appClient) {
738
891
  }
739
892
  }
740
893
 
894
+ function runDurationEndOfState(s) {
895
+ const terminalStatuses = new Set(['judged', 'judge_failed', 'batch_blocked', 'plan_failed', 'plan_empty', 'stopped']);
896
+ if (!terminalStatuses.has(s.status)) return null;
897
+ const times = [
898
+ s.stoppedAt,
899
+ s.stopInfo?.stoppedAt,
900
+ s.judge?.endedAt,
901
+ s.planner?.endedAt,
902
+ ...(s.tasks || []).flatMap(task => [task.endedAt, task.completedAt, task.stoppedAt])
903
+ ].map(value => Date.parse(value || '')).filter(Number.isFinite);
904
+ return times.length ? new Date(Math.max(...times)).toISOString() : s.updatedAt;
905
+ }
906
+
741
907
  function summaryOfRun(s) {
742
908
  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 })) };
909
+ 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
910
  }
745
911
 
746
912
  export async function readRunTaskText(runId) {
@@ -749,7 +915,7 @@ export async function readRunTaskText(runId) {
749
915
 
750
916
  export async function readRunFile(runId, taskId, name) {
751
917
  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']);
918
+ 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
919
  if (!allowed.has(name)) throw new Error('file not allowed');
754
920
  let dir;
755
921
  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');