input-kanban 0.0.2 → 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.
@@ -0,0 +1,220 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {
5
+ CODEX_BIN,
6
+ ensureDir,
7
+ nowIso,
8
+ readTextMaybe,
9
+ writeJsonAtomic
10
+ } from '../utils.js';
11
+ import {
12
+ DEFAULT_TMUX_BIN,
13
+ sanitizeTmuxSessionName,
14
+ sanitizeTmuxWindowName,
15
+ tmuxHasSession,
16
+ tmuxKillSession,
17
+ tmuxNewSession,
18
+ tmuxNewWindow,
19
+ tmuxSelectLayout,
20
+ tmuxSplitWindow
21
+ } from '../tmux.js';
22
+
23
+ function processKey(runId, taskId) {
24
+ return `${runId}:${taskId}`;
25
+ }
26
+
27
+ function roleForTask(taskId) {
28
+ if (taskId === 'planner') return 'planner';
29
+ if (taskId === 'judge') return 'judge';
30
+ return 'worker';
31
+ }
32
+
33
+ function windowNameForTask(taskId, batchId = null) {
34
+ const role = roleForTask(taskId);
35
+ if (role === 'worker') return sanitizeTmuxWindowName(batchId || 'batch-1');
36
+ return sanitizeTmuxWindowName(role);
37
+ }
38
+
39
+ function sessionNameForRun(runId) {
40
+ return sanitizeTmuxSessionName(`input-kanban-${runId}`);
41
+ }
42
+
43
+ function shellQuote(value) {
44
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
45
+ }
46
+
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 }) {
58
+ return `#!/usr/bin/env bash
59
+ set -u
60
+
61
+ CODEX_BIN=${shellQuote(codexBin)}
62
+ SANDBOX=${shellQuote(sandbox)}
63
+ CWD=${shellQuote(cwd)}
64
+ OUT_DIR=${shellQuote(outDir)}
65
+ RUN_ID=${shellQuote(runId)}
66
+ TASK_ID=${shellQuote(taskId)}
67
+ ROLE=${shellQuote(role)}
68
+ PROMPT_FILE="$OUT_DIR/prompt.md"
69
+ EVENTS="$OUT_DIR/events.jsonl"
70
+ STDERR_LOG="$OUT_DIR/stderr.log"
71
+ FORMATTER_BIN=${shellQuote(formatterBin)}
72
+ LAST_MESSAGE="$OUT_DIR/last_message.md"
73
+ EXIT_CODE="$OUT_DIR/exit_code"
74
+
75
+ cd "$CWD"
76
+ 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)
79
+ code=$?
80
+ printf '%s' "$code" > "$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
89
+ `;
90
+ }
91
+
92
+ export function createTmuxRunner({
93
+ codexBin = CODEX_BIN,
94
+ tmuxBin = DEFAULT_TMUX_BIN,
95
+ tmuxOptions = {},
96
+ pollMs = 1000
97
+ } = {}) {
98
+ const runningWindows = new Map();
99
+
100
+ async function startCodexTask({ runId, taskId, batchId = null, runStatePath = null, prompt, sandbox, cwd, outDir }) {
101
+ await ensureDir(outDir);
102
+ const sessionName = sessionNameForRun(runId);
103
+ const role = roleForTask(taskId);
104
+ const windowName = windowNameForTask(taskId, batchId);
105
+ const key = processKey(runId, taskId);
106
+ const promptFile = path.join(outDir, 'prompt.md');
107
+ const runScript = path.join(outDir, 'run.sh');
108
+ const exitFile = path.join(outDir, 'exit_code');
109
+ const metadataFile = path.join(outDir, 'tmux.json');
110
+ const startedAt = nowIso();
111
+
112
+ await fsp.writeFile(promptFile, prompt);
113
+ await fsp.writeFile(runScript, buildRunScript({ codexBin, sandbox, cwd, outDir, runId, taskId, role }));
114
+ await fsp.chmod(runScript, 0o755);
115
+
116
+ const metadata = {
117
+ type: 'input_kanban_tmux_task',
118
+ version: 1,
119
+ runner: 'tmux',
120
+ runId,
121
+ taskId,
122
+ role,
123
+ batchId,
124
+ sessionName,
125
+ windowName,
126
+ target: `${sessionName}:${windowName}`,
127
+ runScript,
128
+ promptFile,
129
+ cwd,
130
+ sandbox,
131
+ startedAt,
132
+ ready: false,
133
+ status: 'pending'
134
+ };
135
+ await writeJsonAtomic(metadataFile, metadata);
136
+
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;
160
+ }
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
+
173
+ const listeners = [];
174
+ let exited = false;
175
+ let exitCode = null;
176
+ const timer = setInterval(async () => {
177
+ const text = await readTextMaybe(exitFile, 1000);
178
+ if (text === '') return;
179
+ clearInterval(timer);
180
+ runningWindows.delete(key);
181
+ const code = Number(text.trim());
182
+ exited = true;
183
+ exitCode = Number.isNaN(code) ? null : code;
184
+ for (const listener of listeners) listener(exitCode);
185
+ }, Math.max(100, Number(pollMs) || 1000));
186
+
187
+ const handle = {
188
+ pid: null,
189
+ onExit(listener) {
190
+ if (exited) listener(exitCode);
191
+ else listeners.push(listener);
192
+ },
193
+ stop() {}
194
+ };
195
+ runningWindows.set(key, { sessionName, windowName, timer });
196
+ return handle;
197
+ }
198
+
199
+ async function stopRun(runId) {
200
+ for (const [key, value] of runningWindows.entries()) {
201
+ if (!key.startsWith(`${runId}:`)) continue;
202
+ clearInterval(value.timer);
203
+ runningWindows.delete(key);
204
+ }
205
+ const sessionName = sessionNameForRun(runId);
206
+ try {
207
+ if (await tmuxHasSession(sessionName, { ...tmuxOptions, tmuxBin })) {
208
+ await tmuxKillSession(sessionName, { ...tmuxOptions, tmuxBin });
209
+ }
210
+ } catch (error) {
211
+ if (!/no such session/i.test(error?.message || '')) throw error;
212
+ }
213
+ }
214
+
215
+ function hasRunning(runId, taskId) {
216
+ return runningWindows.has(processKey(runId, taskId));
217
+ }
218
+
219
+ return { kind: 'tmux', sessionNameForRun, startCodexTask, stopRun, hasRunning };
220
+ }
@@ -0,0 +1,17 @@
1
+ export {
2
+ DEFAULT_TMUX_BIN,
3
+ TmuxUnavailableError,
4
+ checkTmuxAvailable,
5
+ ensureTmuxAvailable,
6
+ runTmux,
7
+ sanitizeTmuxName,
8
+ sanitizeTmuxSessionName,
9
+ sanitizeTmuxWindowName,
10
+ tmuxHasSession,
11
+ tmuxKillSession,
12
+ tmuxKillWindow,
13
+ tmuxNewSession,
14
+ tmuxNewWindow,
15
+ tmuxSelectLayout,
16
+ tmuxSplitWindow
17
+ } from '../tmux.js';
package/src/server.js CHANGED
@@ -3,7 +3,7 @@ import fsp from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { CodexAppServerClient } from './appServerClient.js';
6
- import { APP_ROOT, DEFAULT_REPO, RUNS_DIR } from './utils.js';
6
+ import { APP_ROOT, DEFAULT_REPO, RUNNER, RUNS_DIR } from './utils.js';
7
7
  import { createRun, listRuns, startPlanner, dispatchRun, startJudge, refreshRun, readRunFile, readRunTaskText, markTaskCompleted, stopRun, archiveRun } from './orchestrator.js';
8
8
 
9
9
  const PUBLIC_DIR = path.join(APP_ROOT, 'public');
@@ -42,7 +42,7 @@ async function handleApi(req, res, url, appClient) {
42
42
  const parts = url.pathname.split('/').filter(Boolean);
43
43
  try {
44
44
  if (req.method === 'GET' && url.pathname === '/api/health') {
45
- return send(res, 200, { ok: true, appRoot: APP_ROOT, runsDir: RUNS_DIR, defaultRepo: DEFAULT_REPO });
45
+ return send(res, 200, { ok: true, appRoot: APP_ROOT, runsDir: RUNS_DIR, defaultRepo: DEFAULT_REPO, runner: RUNNER });
46
46
  }
47
47
  if (parts[1] === 'runs' && parts.length === 2) {
48
48
  if (req.method === 'GET') return send(res, 200, { runs: await listRuns({ includeArchived: url.searchParams.get('includeArchived') === '1' }) });
@@ -100,7 +100,7 @@ export async function startServer({ host = process.env.HOST || '127.0.0.1', port
100
100
  appClient.stop();
101
101
  await new Promise(resolve => server.close(resolve));
102
102
  };
103
- return { server, appClient, host, port, url, defaultRepo: DEFAULT_REPO, runsDir: RUNS_DIR, stop };
103
+ return { server, appClient, host, port, url, defaultRepo: DEFAULT_REPO, runsDir: RUNS_DIR, runner: RUNNER, stop };
104
104
  }
105
105
 
106
106
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
package/src/tmux.js ADDED
@@ -0,0 +1,156 @@
1
+ import { execFile } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import { promisify } from 'node:util';
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ export const DEFAULT_TMUX_BIN = process.env.KANBAN_TMUX_BIN || 'tmux';
8
+
9
+ export class TmuxUnavailableError extends Error {
10
+ constructor(tmuxBin, result) {
11
+ const detail = result?.stderr || result?.stdout || result?.error?.message || 'command failed';
12
+ super(`tmux is unavailable: failed to run "${tmuxBin} -V" (${detail.trim()})`);
13
+ this.name = 'TmuxUnavailableError';
14
+ this.tmuxBin = tmuxBin;
15
+ this.result = result;
16
+ }
17
+ }
18
+
19
+ function normalizeResult(result) {
20
+ return {
21
+ code: Number.isInteger(result?.code) ? result.code : 0,
22
+ stdout: result?.stdout || '',
23
+ stderr: result?.stderr || '',
24
+ error: result?.error
25
+ };
26
+ }
27
+
28
+ async function defaultRunner(command, args, { timeoutMs = 3000 } = {}) {
29
+ try {
30
+ const { stdout, stderr } = await execFileAsync(command, args, { timeout: timeoutMs, windowsHide: true });
31
+ return { code: 0, stdout, stderr };
32
+ } catch (error) {
33
+ const code = Number.isInteger(error?.code) ? error.code : error?.code === 'ENOENT' ? 127 : 1;
34
+ return { code, stdout: error?.stdout || '', stderr: error?.stderr || error?.message || '', error };
35
+ }
36
+ }
37
+
38
+ async function runCommand(command, args, options = {}) {
39
+ const runner = options.runner || defaultRunner;
40
+ return normalizeResult(await runner(command, args, { timeoutMs: options.timeoutMs || 3000 }));
41
+ }
42
+
43
+ function safeFallback(fallback) {
44
+ const value = String(fallback || 'tmux')
45
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
46
+ .replace(/-+/g, '-')
47
+ .replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$/g, '');
48
+ return value || 'tmux';
49
+ }
50
+
51
+ export function sanitizeTmuxName(value, { fallback = 'tmux', maxLength = 80 } = {}) {
52
+ const limit = Math.max(16, Number(maxLength) || 80);
53
+ const original = String(value || '');
54
+ let name = original
55
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
56
+ .replace(/-+/g, '-')
57
+ .replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$/g, '');
58
+
59
+ if (!name) name = safeFallback(fallback);
60
+ if (name.length <= limit) return name;
61
+
62
+ const hash = crypto.createHash('sha256').update(original || name).digest('hex').slice(0, 8);
63
+ const prefixLength = Math.max(1, limit - hash.length - 1);
64
+ const fallbackPrefix = safeFallback(fallback).slice(0, prefixLength);
65
+ const prefix = name.slice(0, prefixLength).replace(/[^a-zA-Z0-9]+$/g, '') || fallbackPrefix;
66
+ return `${prefix}-${hash}`.slice(0, limit);
67
+ }
68
+
69
+ export function sanitizeTmuxSessionName(value, options = {}) {
70
+ return sanitizeTmuxName(value, { fallback: 'input-kanban', ...options });
71
+ }
72
+
73
+ export function sanitizeTmuxWindowName(value, options = {}) {
74
+ return sanitizeTmuxName(value, { fallback: 'worker', ...options });
75
+ }
76
+
77
+ export async function checkTmuxAvailable(options = {}) {
78
+ const tmuxBin = options.tmuxBin || DEFAULT_TMUX_BIN;
79
+ const result = await runCommand(tmuxBin, ['-V'], options);
80
+ return {
81
+ available: result.code === 0,
82
+ tmuxBin,
83
+ version: result.code === 0 ? result.stdout.trim() : '',
84
+ result
85
+ };
86
+ }
87
+
88
+ export async function ensureTmuxAvailable(options = {}) {
89
+ const status = await checkTmuxAvailable(options);
90
+ if (!status.available) throw new TmuxUnavailableError(status.tmuxBin, status.result);
91
+ return status;
92
+ }
93
+
94
+ export async function runTmux(args, options = {}) {
95
+ const tmuxBin = options.tmuxBin || DEFAULT_TMUX_BIN;
96
+ await ensureTmuxAvailable({ ...options, tmuxBin });
97
+ const result = await runCommand(tmuxBin, args, options);
98
+ if (result.code !== 0) {
99
+ const detail = result.stderr || result.stdout || 'command failed';
100
+ throw new Error(`tmux command failed: ${tmuxBin} ${args.join(' ')} (${detail.trim()})`);
101
+ }
102
+ return result;
103
+ }
104
+
105
+ export async function tmuxHasSession(sessionName, options = {}) {
106
+ const tmuxBin = options.tmuxBin || DEFAULT_TMUX_BIN;
107
+ const session = sanitizeTmuxSessionName(sessionName);
108
+ await ensureTmuxAvailable({ ...options, tmuxBin });
109
+ const result = await runCommand(tmuxBin, ['has-session', '-t', session], options);
110
+ return result.code === 0;
111
+ }
112
+
113
+ export async function tmuxNewSession(sessionName, options = {}) {
114
+ const session = sanitizeTmuxSessionName(sessionName);
115
+ const args = ['new-session', '-d', '-s', session];
116
+ if (options.windowName) args.push('-n', sanitizeTmuxWindowName(options.windowName));
117
+ if (options.cwd) args.push('-c', options.cwd);
118
+ if (options.command) args.push(options.command);
119
+ return runTmux(args, options);
120
+ }
121
+
122
+ export async function tmuxNewWindow(sessionName, windowName, options = {}) {
123
+ const session = sanitizeTmuxSessionName(sessionName);
124
+ const window = sanitizeTmuxWindowName(windowName);
125
+ const args = ['new-window', '-t', session, '-n', window];
126
+ if (options.cwd) args.push('-c', options.cwd);
127
+ if (options.command) args.push(options.command);
128
+ return runTmux(args, options);
129
+ }
130
+
131
+ export async function tmuxKillSession(sessionName, options = {}) {
132
+ return runTmux(['kill-session', '-t', sanitizeTmuxSessionName(sessionName)], options);
133
+ }
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
+
152
+ export async function tmuxKillWindow(sessionName, windowName, options = {}) {
153
+ const session = sanitizeTmuxSessionName(sessionName);
154
+ const window = sanitizeTmuxWindowName(windowName);
155
+ return runTmux(['kill-window', '-t', `${session}:${window}`], options);
156
+ }
package/src/utils.js CHANGED
@@ -7,6 +7,15 @@ export const APP_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathn
7
7
  export const DEFAULT_REPO = path.resolve(process.env.KANBAN_DEFAULT_REPO || process.cwd());
8
8
  export const RUNS_DIR = path.resolve(process.env.KANBAN_RUNS_DIR || path.join(process.env.HOME || APP_ROOT, '.input-kanban', 'runs'));
9
9
  export const CODEX_BIN = process.env.KANBAN_CODEX_BIN || 'codex';
10
+ export const VALID_RUNNERS = ['headless', 'tmux'];
11
+
12
+ export function normalizeRunner(value = 'headless', source = 'KANBAN_RUNNER') {
13
+ const runner = String(value || '').trim();
14
+ if (VALID_RUNNERS.includes(runner)) return runner;
15
+ throw new Error(`invalid ${source}: ${value}; expected one of: ${VALID_RUNNERS.join(', ')}`);
16
+ }
17
+
18
+ export const RUNNER = normalizeRunner(process.env.KANBAN_RUNNER || 'headless');
10
19
 
11
20
  export async function ensureDir(dir) { await fsp.mkdir(dir, { recursive: true }); }
12
21
  export function nowIso() { return new Date().toISOString(); }