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.
@@ -7,32 +7,28 @@ import {
7
7
  pathForRun, roleDir, safeIdPart, RUNNER
8
8
  } from './utils.js';
9
9
  import { matchThreadToMarkers } from './appServerClient.js';
10
+ import { formatCodexEventsJsonl } from './eventFormatter.js';
10
11
  import { defaultRunner } from './runners/index.js';
11
12
 
12
13
  const runner = defaultRunner;
13
- const ATTENTION_IDLE_MS = 5 * 60 * 1000;
14
- const ATTENTION_MIN_RUNTIME_MS = 10 * 60 * 1000;
15
- const ATTENTION_KEYWORDS = [
16
- 'permission',
17
- 'approval',
18
- 'approve',
19
- 'confirm',
20
- 'continue',
21
- 'password',
22
- 'authentication',
23
- 'authenticate'
24
- ];
14
+ const VALID_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
15
+
16
+ function normalizeSandbox(value, fallback = 'workspace-write') {
17
+ const sandbox = String(value || '').trim();
18
+ if (VALID_SANDBOXES.has(sandbox)) return sandbox;
19
+ return fallback;
20
+ }
25
21
 
26
22
  function statePath(runDir) { return path.join(runDir, 'run_state.json'); }
27
23
  function planPath(runDir) { return path.join(runDir, 'plan.json'); }
28
24
 
29
- export async function createRun({ label = 'task', taskText = '', repo = DEFAULT_REPO, maxParallel = 3 } = {}) {
25
+ export async function createRun({ label = 'task', taskText = '', repo = DEFAULT_REPO, maxParallel = 3, workerSandbox = 'workspace-write' } = {}) {
30
26
  const runId = makeRunId(label);
31
27
  const runDir = pathForRun(runId);
32
28
  await ensureDir(runDir);
33
29
  await fsp.writeFile(path.join(runDir, 'task.md'), taskText || '');
34
30
  const state = {
35
- runId, label, repo: path.resolve(repo), maxParallel: Number(maxParallel) || 3,
31
+ runId, label, repo: path.resolve(repo), maxParallel: Number(maxParallel) || 3, workerSandbox: normalizeSandbox(workerSandbox),
36
32
  runner: RUNNER,
37
33
  status: 'created', createdAt: nowIso(), updatedAt: nowIso(),
38
34
  planner: { status: 'pending' }, batches: [], tasks: [], judge: { status: 'pending' }
@@ -87,7 +83,7 @@ Preferred schema with blocking batches:
87
83
  "id": "T-01",
88
84
  "name": "short name",
89
85
  "prompt": "complete worker prompt",
90
- "sandbox": "workspace-write",
86
+ "sandbox": "${state.workerSandbox || 'workspace-write'}",
91
87
  "expectedArtifacts": []
92
88
  }
93
89
  ]
@@ -103,7 +99,7 @@ Backward-compatible schema also accepted:
103
99
  "id": "T-01",
104
100
  "name": "short name",
105
101
  "prompt": "complete worker prompt",
106
- "sandbox": "workspace-write",
102
+ "sandbox": "${state.workerSandbox || 'workspace-write'}",
107
103
  "expectedArtifacts": []
108
104
  }
109
105
  ]
@@ -114,6 +110,7 @@ Rules:
114
110
  - Use batch maxParallel to express whether tasks in the same batch may run concurrently or serially.
115
111
  - Keep tasks scoped and independently executable.
116
112
  - Include exact output/artifact expectations in each worker prompt.
113
+ - Default worker sandbox for this run is ${state.workerSandbox || 'workspace-write'}; use that sandbox unless a task has a specific safety reason to be stricter.
117
114
  - If the input already contains task sections, preserve their ids when practical.
118
115
 
119
116
  User task:
@@ -163,7 +160,7 @@ export async function startPlanner(runId) {
163
160
  await fsp.rm(planPath(runDir), { force: true });
164
161
  const taskText = await fsp.readFile(path.join(runDir, 'task.md'), 'utf8');
165
162
  const prompt = defaultPlannerPrompt(state, taskText);
166
- const child = await runner.startCodexTask({ runId: state.runId, taskId: 'planner', prompt, sandbox: 'read-only', cwd: state.repo, outDir });
163
+ const child = await runner.startCodexTask({ runId: state.runId, taskId: 'planner', batchId: 'planner', runStatePath: statePath(runDir), prompt, sandbox: 'read-only', cwd: state.repo, outDir });
167
164
  state.status = 'planning';
168
165
  state.planner = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir, attempt: (state.plannerAttempts?.length || 0) + 1 };
169
166
  await saveRun(state);
@@ -180,14 +177,14 @@ export async function startPlanner(runId) {
180
177
  return state;
181
178
  }
182
179
 
183
- function normalizeTask(t, i, batch) {
180
+ function normalizeTask(t, i, batch, defaultSandbox = 'workspace-write') {
184
181
  const id = safeIdPart(t.id || `T-${String(i + 1).padStart(2, '0')}`);
185
182
  return {
186
183
  id,
187
184
  batchId: batch.id,
188
185
  name: t.name || t.id || `Task ${i + 1}`,
189
186
  prompt: t.prompt || t.instructions || '',
190
- sandbox: t.sandbox || 'workspace-write',
187
+ sandbox: normalizeSandbox(t.sandbox, defaultSandbox),
191
188
  expectedArtifacts: Array.isArray(t.expectedArtifacts) ? t.expectedArtifacts : [],
192
189
  status: 'pending'
193
190
  };
@@ -220,7 +217,7 @@ async function rotatePlannerAttempt(state, runDir) {
220
217
  }];
221
218
  }
222
219
 
223
- function normalizePlan(plan, defaultMaxParallel) {
220
+ function normalizePlan(plan, defaultMaxParallel, defaultSandbox = 'workspace-write') {
224
221
  if (Array.isArray(plan.batches)) {
225
222
  const batches = plan.batches.map((b, bi) => {
226
223
  const batch = {
@@ -230,14 +227,14 @@ function normalizePlan(plan, defaultMaxParallel) {
230
227
  status: 'pending',
231
228
  tasks: []
232
229
  };
233
- batch.tasks = (Array.isArray(b.tasks) ? b.tasks : []).map((t, ti) => normalizeTask(t, ti, batch));
230
+ batch.tasks = (Array.isArray(b.tasks) ? b.tasks : []).map((t, ti) => normalizeTask(t, ti, batch, defaultSandbox));
234
231
  return batch;
235
232
  }).filter(b => b.tasks.length);
236
233
  return { ...plan, batches, tasks: batches.flatMap(b => b.tasks) };
237
234
  }
238
235
  if (Array.isArray(plan.tasks)) {
239
236
  const batch = { id: 'batch-1', name: '默认批次', maxParallel: Math.max(1, Number(defaultMaxParallel) || 1), status: 'pending', tasks: [] };
240
- batch.tasks = plan.tasks.map((t, i) => normalizeTask(t, i, batch));
237
+ batch.tasks = plan.tasks.map((t, i) => normalizeTask(t, i, batch, defaultSandbox));
241
238
  return { ...plan, batches: [batch], tasks: batch.tasks };
242
239
  }
243
240
  return null;
@@ -253,7 +250,7 @@ async function materializePlan(state) {
253
250
  state.tasks = [];
254
251
  return { ok: false, empty: false, error: state.planner.planParseError };
255
252
  }
256
- const normalized = normalizePlan(plan, state.maxParallel);
253
+ const normalized = normalizePlan(plan, state.maxParallel, state.workerSandbox || 'workspace-write');
257
254
  if (!normalized || !Array.isArray(normalized.tasks)) {
258
255
  state.planner.planParseError = 'planner JSON did not contain { batches: [...] } or { tasks: [...] }';
259
256
  state.batches = [];
@@ -299,7 +296,7 @@ ORCHESTRATOR_BATCH_ID: ${task.batchId || 'batch-1'}
299
296
 
300
297
  ${task.prompt}
301
298
  `;
302
- const child = await runner.startCodexTask({ runId: state.runId, taskId: task.id, prompt: fullPrompt, sandbox: task.sandbox || 'workspace-write', cwd: state.repo, outDir });
299
+ 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 });
303
300
  Object.assign(task, { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir });
304
301
  }
305
302
 
@@ -390,7 +387,7 @@ export async function startJudge(runId) {
390
387
  const judgeInput = await buildJudgeInput(state);
391
388
  await writeJsonAtomic(judgeInputPath, judgeInput);
392
389
  const prompt = defaultJudgePrompt(state, judgeInputPath);
393
- const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', prompt, sandbox: 'read-only', cwd: state.repo, outDir });
390
+ const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(pathForRun(runId)), prompt, sandbox: 'read-only', cwd: state.repo, outDir });
394
391
  state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir };
395
392
  state.status = 'judging';
396
393
  await saveRun(state);
@@ -419,6 +416,7 @@ async function loadAndRefreshRun(runId, appClient = null, { light = false } = {}
419
416
  for (const task of state.tasks || []) await refreshTask(state, task);
420
417
  await refreshRole(state, state.judge, roleDir(pathForRun(runId), 'judge'));
421
418
  await recoverCompletedJudge(state);
419
+ aggregateRunTmuxMetadata(state);
422
420
  recomputeRunStatus(state);
423
421
  await scheduleMoreWorkers(state);
424
422
  recomputeRunStatus(state);
@@ -463,7 +461,6 @@ async function refreshRole(state, roleState, dir) {
463
461
  else if (roleState.status === 'running' && !runner.hasRunning(state.runId, key)) roleState.status = 'unknown';
464
462
  roleState.files = await standardFiles(dir);
465
463
  await attachTmuxMetadata(roleState, dir);
466
- roleState.attentionHint = await buildAttentionHint({ state, target: roleState, dir });
467
464
  }
468
465
 
469
466
  async function refreshTask(state, task) {
@@ -478,7 +475,7 @@ async function refreshTask(state, task) {
478
475
  } else if (task.status === 'running' && !runner.hasRunning(state.runId, task.id)) task.status = 'unknown';
479
476
  task.files = await standardFiles(dir);
480
477
  await attachTmuxMetadata(task, dir);
481
- task.attentionHint = await buildAttentionHint({ state, target: task, dir });
478
+ delete task.attentionHint;
482
479
  task.artifacts = [];
483
480
  for (const rel of task.expectedArtifacts || []) task.artifacts.push({ path: rel, ...(await fileInfo(path.isAbsolute(rel) ? rel : path.join(state.repo, rel))) });
484
481
  const batch = (state.batches || []).find(b => b.id === task.batchId);
@@ -494,60 +491,65 @@ async function attachTmuxMetadata(target, dir) {
494
491
  delete target.tmux;
495
492
  return;
496
493
  }
494
+ if (raw.ready !== true) {
495
+ target.tmux = {
496
+ runner: 'tmux',
497
+ ready: false,
498
+ status: raw.status || 'pending',
499
+ sessionName: raw.sessionName || '',
500
+ windowName: raw.windowName || '',
501
+ target: raw.target || '',
502
+ runScript: raw.runScript || '',
503
+ startedAt: raw.startedAt || '',
504
+ error: raw.error || ''
505
+ };
506
+ return;
507
+ }
497
508
  const selectWindowCommand = raw.selectWindowCommand || raw.selectCommand || '';
498
509
  target.tmux = {
499
510
  runner: 'tmux',
511
+ ready: true,
512
+ status: raw.status || 'ready',
500
513
  sessionName: raw.sessionName || '',
501
514
  windowName: raw.windowName || '',
502
515
  target: raw.target || '',
503
516
  attachCommand: raw.attachCommand || '',
504
517
  selectWindowCommand,
505
518
  runScript: raw.runScript || '',
506
- startedAt: raw.startedAt || ''
519
+ startedAt: raw.startedAt || '',
520
+ readyAt: raw.readyAt || ''
507
521
  };
508
522
  }
509
523
 
510
- async function buildAttentionHint({ state, target, dir }) {
511
- if (!['running', 'unknown'].includes(target?.status) || state?.status === 'stopped') return null;
512
- if ((state?.runner || RUNNER) !== 'tmux' && !target.tmux) return null;
513
- const reasons = [];
514
- const textTail = [
515
- await readTextMaybe(path.join(dir, 'stderr.log'), 20000),
516
- await readTextMaybe(path.join(dir, 'events.jsonl'), 20000)
517
- ].join('\n');
518
- const keyword = findAttentionKeyword(textTail);
519
- if (keyword) reasons.push(`log tail contains "${keyword}"`);
520
- const idle = taskIdleSnapshot(target);
521
- if (idle.isLongIdle || (target.status === 'unknown' && idle.isIdle)) reasons.push(`no recent log updates for ${Math.round(idle.idleMs / 1000)}s`);
522
- if (!reasons.length) return null;
523
- return {
524
- message: 'This task may need manual intervention; attach to tmux to inspect.',
525
- reasons,
526
- attachCommand: target.tmux?.attachCommand || '',
527
- detectedAt: nowIso()
528
- };
529
- }
530
-
531
- function findAttentionKeyword(text) {
532
- const lower = String(text || '').toLowerCase();
533
- return ATTENTION_KEYWORDS.find(keyword => lower.includes(keyword)) || null;
534
- }
535
-
536
- function taskIdleSnapshot(target) {
537
- const now = Date.now();
538
- const startedMs = Date.parse(target?.startedAt || '');
539
- const runtimeMs = Number.isFinite(startedMs) ? now - startedMs : 0;
540
- const recentMs = [target?.files?.events, target?.files?.stderr, target?.files?.lastMessage]
541
- .filter(info => info?.exists && Number.isFinite(info.mtimeMs))
542
- .map(info => info.mtimeMs)
543
- .sort((a, b) => b - a)[0];
544
- const idleMs = Number.isFinite(recentMs) ? now - recentMs : runtimeMs;
545
- return {
546
- idleMs,
547
- runtimeMs,
548
- isIdle: idleMs >= ATTENTION_IDLE_MS,
549
- isLongIdle: runtimeMs >= ATTENTION_MIN_RUNTIME_MS && idleMs >= ATTENTION_IDLE_MS
524
+ function aggregateRunTmuxMetadata(state) {
525
+ const roles = [state.planner, ...(state.tasks || []), state.judge].filter(Boolean);
526
+ const entries = roles.map(role => role.tmux).filter(tmux => tmux?.runner === 'tmux');
527
+ const readyEntries = entries.filter(tmux => tmux.ready === true);
528
+ if (!entries.length) {
529
+ if ((state.runner || RUNNER) === 'tmux') {
530
+ state.tmux = {
531
+ runner: 'tmux',
532
+ hasTmuxSession: false
533
+ };
534
+ } else {
535
+ delete state.tmux;
536
+ }
537
+ return;
538
+ }
539
+ const withSession = readyEntries.find(tmux => tmux.sessionName || tmux.target || tmux.windowName);
540
+ if (!withSession) {
541
+ state.tmux = {
542
+ runner: 'tmux',
543
+ hasTmuxSession: false
544
+ };
545
+ return;
546
+ }
547
+ state.tmux = {
548
+ runner: 'tmux',
549
+ hasTmuxSession: true,
550
+ tmuxSessionName: withSession.sessionName || ''
550
551
  };
552
+ if (withSession.attachCommand) state.tmux.tmuxAttachCommand = withSession.attachCommand;
551
553
  }
552
554
 
553
555
  async function standardFiles(dir) {
@@ -665,7 +667,8 @@ async function buildJudgeInput(state) {
665
667
  runner: state.runner || RUNNER,
666
668
  createdAt: state.createdAt,
667
669
  updatedAt: state.updatedAt,
668
- maxParallel: state.maxParallel
670
+ maxParallel: state.maxParallel,
671
+ workerSandbox: state.workerSandbox || 'workspace-write'
669
672
  },
670
673
  taskText,
671
674
  plan,
@@ -714,89 +717,9 @@ async function enrichFromAppServer(state, appClient) {
714
717
 
715
718
  function summaryOfRun(s) {
716
719
  const tasks = s.tasks || [];
717
- return { runId: s.runId, label: s.label, repo: s.repo, status: s.status, runner: s.runner || RUNNER, 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 })) };
718
- }
719
-
720
- function formatCodexEventsJsonl(text) {
721
- if (!text.trim()) return '暂无事件日志。';
722
- const lines = text.split(/\r?\n/).filter(Boolean);
723
- return lines.map((line, index) => {
724
- const seq = String(index + 1).padStart(3, '0');
725
- let event;
726
- try { event = JSON.parse(line); }
727
- catch { return `[${seq}] 无法解析事件\n${line}`; }
728
- return formatCodexEvent(seq, event);
729
- }).join('\n\n');
730
- }
731
-
732
- function formatCodexEvent(seq, event) {
733
- switch (event.type) {
734
- case 'thread.started':
735
- return `[${seq}] Codex 会话开始\n 会话ID: ${event.thread_id || '-'}`;
736
- case 'turn.started':
737
- return `[${seq}] 回合开始`;
738
- case 'turn.completed':
739
- return `[${seq}] 回合完成\n${formatKnownFields(event, ['status', 'error', 'usage'])}`.trimEnd();
740
- case 'item.started':
741
- return formatCodexItem(seq, '开始', event.item);
742
- case 'item.completed':
743
- return formatCodexItem(seq, '完成', event.item);
744
- case 'error':
745
- return `[${seq}] 错误\n${formatJson(event)}`;
746
- default:
747
- return `[${seq}] ${event.type || '未知事件'}\n${formatJson(event)}`;
748
- }
749
- }
750
-
751
- function formatCodexItem(seq, action, item = {}) {
752
- const type = item.type || 'unknown';
753
- const title = `[${seq}] ${action}: ${displayItemType(type)}`;
754
- if (type === 'command_execution') {
755
- const parts = [title];
756
- if (item.command) parts.push(` 命令: ${item.command}`);
757
- if (item.status) parts.push(` 状态: ${item.status}`);
758
- if (item.exit_code !== undefined && item.exit_code !== null) parts.push(` 退出码: ${item.exit_code}`);
759
- if (item.aggregated_output) parts.push(` 输出:\n${indentText(truncateText(item.aggregated_output))}`);
760
- return parts.join('\n');
761
- }
762
- if (type === 'agent_message' || type === 'agentMessage') {
763
- const text = item.text || item.message || item.content || '';
764
- return text ? `${title}\n 内容:\n${indentText(truncateText(String(text)))}` : title;
765
- }
766
- if (type === 'reasoning') {
767
- const summary = item.summary || item.content || '';
768
- return summary ? `${title}\n 摘要:\n${indentText(truncateText(Array.isArray(summary) ? summary.join('\n') : String(summary)))}` : title;
769
- }
770
- if (type === 'file_change' || type === 'fileChange') {
771
- return `${title}\n${formatKnownFields(item, ['status', 'path', 'changes'])}`.trimEnd();
772
- }
773
- return `${title}\n${formatJson(item)}`;
720
+ 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 })) };
774
721
  }
775
722
 
776
- function displayItemType(type) {
777
- return {
778
- command_execution: '命令执行',
779
- agent_message: '模型回复',
780
- agentMessage: '模型回复',
781
- reasoning: '推理',
782
- file_change: '文件变更',
783
- fileChange: '文件变更',
784
- mcp_tool_call: 'MCP 工具调用',
785
- mcpToolCall: 'MCP 工具调用'
786
- }[type] || type;
787
- }
788
-
789
- function formatKnownFields(obj, fields) {
790
- return fields
791
- .filter(field => obj[field] !== undefined && obj[field] !== null)
792
- .map(field => ` ${field}: ${typeof obj[field] === 'string' ? obj[field] : JSON.stringify(obj[field], null, 2)}`)
793
- .join('\n');
794
- }
795
-
796
- function formatJson(value) { return indentText(JSON.stringify(value, null, 2)); }
797
- function indentText(text) { return String(text).split('\n').map(line => ` ${line}`).join('\n'); }
798
- function truncateText(text, max = 12000) { return text.length > max ? `${text.slice(0, max)}\n...<已截断 ${text.length - max} 字符>` : text; }
799
-
800
723
  export async function readRunTaskText(runId) {
801
724
  return await readTextMaybe(path.join(pathForRun(runId), 'task.md'), 1000000);
802
725
  }
@@ -1,3 +1,4 @@
1
+ import { fileURLToPath } from 'node:url';
1
2
  import fsp from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import {
@@ -14,7 +15,9 @@ import {
14
15
  tmuxHasSession,
15
16
  tmuxKillSession,
16
17
  tmuxNewSession,
17
- tmuxNewWindow
18
+ tmuxNewWindow,
19
+ tmuxSelectLayout,
20
+ tmuxSplitWindow
18
21
  } from '../tmux.js';
19
22
 
20
23
  function processKey(runId, taskId) {
@@ -27,9 +30,10 @@ function roleForTask(taskId) {
27
30
  return 'worker';
28
31
  }
29
32
 
30
- function windowNameForTask(taskId) {
33
+ function windowNameForTask(taskId, batchId = null) {
31
34
  const role = roleForTask(taskId);
32
- return sanitizeTmuxWindowName(role === 'worker' ? `worker-${taskId}` : role);
35
+ if (role === 'worker') return sanitizeTmuxWindowName(batchId || 'batch-1');
36
+ return sanitizeTmuxWindowName(role);
33
37
  }
34
38
 
35
39
  function sessionNameForRun(runId) {
@@ -40,7 +44,17 @@ function shellQuote(value) {
40
44
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
41
45
  }
42
46
 
43
- function buildRunScript({ codexBin, sandbox, cwd, outDir }) {
47
+ const BIN_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../bin');
48
+ const FORMATTER_BIN = path.join(BIN_DIR, 'input-kanban-format-events.js');
49
+ const OVERVIEW_BIN = path.join(BIN_DIR, 'input-kanban-tmux-overview.js');
50
+
51
+ function buildOverviewCommand(runStatePath) {
52
+ const quotedStatePath = shellQuote(runStatePath);
53
+ const quotedOverviewBin = shellQuote(OVERVIEW_BIN);
54
+ return `while true; do clear; node ${quotedOverviewBin} ${quotedStatePath}; sleep 2; done`;
55
+ }
56
+
57
+ function buildRunScript({ codexBin, formatterBin = FORMATTER_BIN, sandbox, cwd, outDir, runId, taskId, role }) {
44
58
  return `#!/usr/bin/env bash
45
59
  set -u
46
60
 
@@ -48,18 +62,30 @@ CODEX_BIN=${shellQuote(codexBin)}
48
62
  SANDBOX=${shellQuote(sandbox)}
49
63
  CWD=${shellQuote(cwd)}
50
64
  OUT_DIR=${shellQuote(outDir)}
65
+ RUN_ID=${shellQuote(runId)}
66
+ TASK_ID=${shellQuote(taskId)}
67
+ ROLE=${shellQuote(role)}
51
68
  PROMPT_FILE="$OUT_DIR/prompt.md"
52
69
  EVENTS="$OUT_DIR/events.jsonl"
53
70
  STDERR_LOG="$OUT_DIR/stderr.log"
71
+ FORMATTER_BIN=${shellQuote(formatterBin)}
54
72
  LAST_MESSAGE="$OUT_DIR/last_message.md"
55
73
  EXIT_CODE="$OUT_DIR/exit_code"
56
74
 
57
75
  cd "$CWD"
58
76
  rm -f "$EXIT_CODE"
59
- "$CODEX_BIN" exec --json --sandbox "$SANDBOX" -C "$CWD" -o "$LAST_MESSAGE" "$(<"$PROMPT_FILE")" >>"$EVENTS" 2>>"$STDERR_LOG"
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)
60
79
  code=$?
61
80
  printf '%s' "$code" > "$EXIT_CODE"
62
- exit "$code"
81
+ printf '\\nInput Kanban tmux task completed.\\n'
82
+ printf 'runId: %s\\n' "$RUN_ID"
83
+ printf 'taskId: %s\\n' "$TASK_ID"
84
+ printf 'role: %s\\n' "$ROLE"
85
+ printf 'exit code: %s\\n' "$code"
86
+ printf 'artifact dir: %s\\n' "$OUT_DIR"
87
+ printf 'Type exit or press Ctrl-D to close this tmux window.\\n'
88
+ exec "\${SHELL:-/bin/sh}" -i
63
89
  `;
64
90
  }
65
91
 
@@ -71,11 +97,11 @@ export function createTmuxRunner({
71
97
  } = {}) {
72
98
  const runningWindows = new Map();
73
99
 
74
- async function startCodexTask({ runId, taskId, prompt, sandbox, cwd, outDir }) {
100
+ async function startCodexTask({ runId, taskId, batchId = null, runStatePath = null, prompt, sandbox, cwd, outDir }) {
75
101
  await ensureDir(outDir);
76
102
  const sessionName = sessionNameForRun(runId);
77
- const windowName = windowNameForTask(taskId);
78
103
  const role = roleForTask(taskId);
104
+ const windowName = windowNameForTask(taskId, batchId);
79
105
  const key = processKey(runId, taskId);
80
106
  const promptFile = path.join(outDir, 'prompt.md');
81
107
  const runScript = path.join(outDir, 'run.sh');
@@ -84,7 +110,7 @@ export function createTmuxRunner({
84
110
  const startedAt = nowIso();
85
111
 
86
112
  await fsp.writeFile(promptFile, prompt);
87
- await fsp.writeFile(runScript, buildRunScript({ codexBin, sandbox, cwd, outDir }));
113
+ await fsp.writeFile(runScript, buildRunScript({ codexBin, sandbox, cwd, outDir, runId, taskId, role }));
88
114
  await fsp.chmod(runScript, 0o755);
89
115
 
90
116
  const metadata = {
@@ -94,32 +120,56 @@ export function createTmuxRunner({
94
120
  runId,
95
121
  taskId,
96
122
  role,
123
+ batchId,
97
124
  sessionName,
98
125
  windowName,
99
126
  target: `${sessionName}:${windowName}`,
100
- attachCommand: `${tmuxBin} attach-session -t ${sessionName}`,
101
- selectWindowCommand: `${tmuxBin} select-window -t ${sessionName}:${windowName}`,
102
- selectCommand: `${tmuxBin} select-window -t ${sessionName}:${windowName}`,
103
127
  runScript,
104
128
  promptFile,
105
129
  cwd,
106
130
  sandbox,
107
- startedAt
131
+ startedAt,
132
+ ready: false,
133
+ status: 'pending'
108
134
  };
109
135
  await writeJsonAtomic(metadataFile, metadata);
110
136
 
111
- const tmuxCommandOptions = {
112
- ...tmuxOptions,
113
- tmuxBin,
114
- cwd,
115
- command: runScript
116
- };
117
- if (await tmuxHasSession(sessionName, tmuxCommandOptions)) {
118
- await tmuxNewWindow(sessionName, windowName, tmuxCommandOptions);
119
- } else {
120
- await tmuxNewSession(sessionName, { ...tmuxCommandOptions, windowName });
137
+ const overviewCommand = buildOverviewCommand(runStatePath || path.join(path.dirname(path.dirname(outDir)), 'run_state.json'));
138
+ const tmuxCommandOptions = { ...tmuxOptions, tmuxBin, cwd };
139
+ try {
140
+ if (await tmuxHasSession(sessionName, tmuxCommandOptions)) {
141
+ if (!runningWindows.has(`${runId}:__window:${windowName}`)) {
142
+ await tmuxNewWindow(sessionName, windowName, { ...tmuxCommandOptions, command: overviewCommand });
143
+ runningWindows.set(`${runId}:__window:${windowName}`, { sessionName, windowName, overview: true });
144
+ }
145
+ } else {
146
+ await tmuxNewSession(sessionName, { ...tmuxCommandOptions, windowName, command: overviewCommand });
147
+ runningWindows.set(`${runId}:__window:${windowName}`, { sessionName, windowName, overview: true });
148
+ }
149
+ await tmuxSplitWindow(sessionName, windowName, { ...tmuxCommandOptions, vertical: true, command: runScript });
150
+ await tmuxSelectLayout(sessionName, windowName, 'tiled', tmuxCommandOptions);
151
+ } catch (error) {
152
+ await writeJsonAtomic(metadataFile, {
153
+ ...metadata,
154
+ ready: false,
155
+ status: 'failed',
156
+ error: error?.message || String(error),
157
+ failedAt: nowIso()
158
+ });
159
+ throw error;
121
160
  }
122
161
 
162
+ await writeJsonAtomic(metadataFile, {
163
+ ...metadata,
164
+ ready: true,
165
+ status: 'ready',
166
+ attachCommand: `${tmuxBin} attach-session -t ${sessionName}`,
167
+ selectWindowCommand: `${tmuxBin} select-window -t ${sessionName}:${windowName}`,
168
+ selectCommand: `${tmuxBin} select-window -t ${sessionName}:${windowName}`,
169
+ paneCommand: `${tmuxBin} select-window -t ${sessionName}:${windowName}`,
170
+ readyAt: nowIso()
171
+ });
172
+
123
173
  const listeners = [];
124
174
  let exited = false;
125
175
  let exitCode = null;
@@ -11,5 +11,7 @@ export {
11
11
  tmuxKillSession,
12
12
  tmuxKillWindow,
13
13
  tmuxNewSession,
14
- tmuxNewWindow
14
+ tmuxNewWindow,
15
+ tmuxSelectLayout,
16
+ tmuxSplitWindow
15
17
  } from '../tmux.js';
package/src/tmux.js CHANGED
@@ -132,6 +132,23 @@ export async function tmuxKillSession(sessionName, options = {}) {
132
132
  return runTmux(['kill-session', '-t', sanitizeTmuxSessionName(sessionName)], options);
133
133
  }
134
134
 
135
+ export async function tmuxSplitWindow(sessionName, windowName, options = {}) {
136
+ const session = sanitizeTmuxSessionName(sessionName);
137
+ const window = sanitizeTmuxWindowName(windowName);
138
+ const args = ['split-window', '-t', `${session}:${window}`];
139
+ if (options.vertical) args.push('-v');
140
+ else args.push('-h');
141
+ if (options.cwd) args.push('-c', options.cwd);
142
+ if (options.command) args.push(options.command);
143
+ return runTmux(args, options);
144
+ }
145
+
146
+ export async function tmuxSelectLayout(sessionName, windowName, layout = 'tiled', options = {}) {
147
+ const session = sanitizeTmuxSessionName(sessionName);
148
+ const window = sanitizeTmuxWindowName(windowName);
149
+ return runTmux(['select-layout', '-t', `${session}:${window}`, layout], options);
150
+ }
151
+
135
152
  export async function tmuxKillWindow(sessionName, windowName, options = {}) {
136
153
  const session = sanitizeTmuxSessionName(sessionName);
137
154
  const window = sanitizeTmuxWindowName(windowName);