octomux 1.0.0

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.
Files changed (56) hide show
  1. package/README.md +60 -0
  2. package/bin/octomux.js +139 -0
  3. package/cli/dist/client.js +56 -0
  4. package/cli/dist/commands/add-agent.js +20 -0
  5. package/cli/dist/commands/cancel-task.js +10 -0
  6. package/cli/dist/commands/close-task.js +16 -0
  7. package/cli/dist/commands/create-task.js +35 -0
  8. package/cli/dist/commands/delete-task.js +16 -0
  9. package/cli/dist/commands/get-task.js +37 -0
  10. package/cli/dist/commands/list-tasks.js +31 -0
  11. package/cli/dist/commands/resume-task.js +18 -0
  12. package/cli/dist/commands/send-message.js +18 -0
  13. package/cli/dist/format.js +40 -0
  14. package/cli/dist/index.js +36 -0
  15. package/cli/package.json +9 -0
  16. package/dist/assets/TaskDetail-GGGQ2C1J.js +1 -0
  17. package/dist/assets/TerminalView-CguHyqU9.js +3 -0
  18. package/dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  19. package/dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  20. package/dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  21. package/dist/assets/index-Br9dLOzs.css +1 -0
  22. package/dist/assets/index-Bsj_BLLM.js +2 -0
  23. package/dist/assets/vendor-react-BZ8ItZjw.js +49 -0
  24. package/dist/assets/vendor-router-DRLGqALp.js +12 -0
  25. package/dist/assets/vendor-ui-CWZtXYLx.js +31 -0
  26. package/dist/assets/vendor-xterm-DYP7pi_n.css +32 -0
  27. package/dist/assets/vendor-xterm-DvXGiZvM.js +9 -0
  28. package/dist/index.html +17 -0
  29. package/dist/logo.png +0 -0
  30. package/dist-server/api.d.ts +2 -0
  31. package/dist-server/api.js +447 -0
  32. package/dist-server/app.d.ts +2 -0
  33. package/dist-server/app.js +13 -0
  34. package/dist-server/db.d.ts +7 -0
  35. package/dist-server/db.js +107 -0
  36. package/dist-server/events.d.ts +13 -0
  37. package/dist-server/events.js +35 -0
  38. package/dist-server/hook-settings.d.ts +5 -0
  39. package/dist-server/hook-settings.js +195 -0
  40. package/dist-server/hooks.d.ts +2 -0
  41. package/dist-server/hooks.js +118 -0
  42. package/dist-server/index.d.ts +5 -0
  43. package/dist-server/index.js +81 -0
  44. package/dist-server/orchestrator.d.ts +4 -0
  45. package/dist-server/orchestrator.js +37 -0
  46. package/dist-server/poller.d.ts +17 -0
  47. package/dist-server/poller.js +170 -0
  48. package/dist-server/pr-template.d.ts +7 -0
  49. package/dist-server/pr-template.js +29 -0
  50. package/dist-server/task-runner.d.ts +28 -0
  51. package/dist-server/task-runner.js +359 -0
  52. package/dist-server/terminal.d.ts +13 -0
  53. package/dist-server/terminal.js +173 -0
  54. package/dist-server/types.d.ts +73 -0
  55. package/dist-server/types.js +1 -0
  56. package/package.json +113 -0
@@ -0,0 +1,170 @@
1
+ import { execFile as execFileCb } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { getDb } from './db.js';
4
+ import { closeTask } from './task-runner.js';
5
+ import { installHookSettings } from './hook-settings.js';
6
+ import { broadcast } from './events.js';
7
+ const execFile = promisify(execFileCb);
8
+ const STATUS_INTERVAL = process.env.NODE_ENV === 'test' ? 0 : 5000;
9
+ const PR_INTERVAL = process.env.NODE_ENV === 'test' ? 0 : 30000;
10
+ const MERGED_PR_INTERVAL = process.env.NODE_ENV === 'test' ? 0 : 30000;
11
+ let statusTimer = null;
12
+ let prTimer = null;
13
+ let mergedPrTimer = null;
14
+ // ─── Session Status Polling ──────────────────────────────────────────────────
15
+ export async function checkTaskStatus(task) {
16
+ if (!task.tmux_session)
17
+ return 'dead';
18
+ try {
19
+ await execFile('tmux', ['has-session', '-t', task.tmux_session]);
20
+ return 'alive';
21
+ }
22
+ catch {
23
+ return 'dead';
24
+ }
25
+ }
26
+ export async function pollStatuses() {
27
+ const db = getDb();
28
+ const runningTasks = db
29
+ .prepare("SELECT * FROM tasks WHERE status IN ('running', 'setting_up')")
30
+ .all();
31
+ const results = await Promise.allSettled(runningTasks.map(async (task) => {
32
+ const status = await checkTaskStatus(task);
33
+ return { task, status };
34
+ }));
35
+ for (const result of results) {
36
+ if (result.status !== 'fulfilled')
37
+ continue;
38
+ const { task, status } = result.value;
39
+ if (status === 'dead' && task.status === 'running') {
40
+ db.prepare(`UPDATE tasks SET status = 'closed', updated_at = datetime('now') WHERE id = ?`).run(task.id);
41
+ db.prepare(`UPDATE agents SET status = 'stopped', hook_activity = 'idle', hook_activity_updated_at = datetime('now') WHERE task_id = ? AND status = 'running'`).run(task.id);
42
+ broadcast({ type: 'task:updated', payload: { taskId: task.id } });
43
+ }
44
+ else if (status === 'dead' && task.status === 'setting_up') {
45
+ db.prepare(`UPDATE tasks SET status = 'error', error = 'Setup interrupted', updated_at = datetime('now') WHERE id = ?`).run(task.id);
46
+ db.prepare(`UPDATE agents SET status = 'stopped', hook_activity = 'idle', hook_activity_updated_at = datetime('now') WHERE task_id = ? AND status = 'running'`).run(task.id);
47
+ broadcast({ type: 'task:updated', payload: { taskId: task.id } });
48
+ }
49
+ }
50
+ }
51
+ // ─── Hook Installation ──────────────────────────────────────────────────────
52
+ /**
53
+ * Ensure hooks are installed in all running task worktrees.
54
+ * Handles tasks created before the hook feature existed.
55
+ */
56
+ export function ensureHooksInstalled() {
57
+ const db = getDb();
58
+ const runningTasks = db
59
+ .prepare("SELECT * FROM tasks WHERE status IN ('running', 'setting_up') AND worktree IS NOT NULL")
60
+ .all();
61
+ for (const task of runningTasks) {
62
+ try {
63
+ installHookSettings(task.worktree);
64
+ }
65
+ catch {
66
+ // Non-critical — don't crash the poller
67
+ }
68
+ }
69
+ }
70
+ // ─── PR Detection ────────────────────────────────────────────────────────────
71
+ export async function detectPR(task) {
72
+ if (!task.branch || !task.repo_path)
73
+ return null;
74
+ try {
75
+ const { stdout } = await execFile('gh', ['pr', 'list', '--head', task.branch, '--json', 'url,number', '--limit', '1'], {
76
+ cwd: task.repo_path,
77
+ });
78
+ const prs = JSON.parse(stdout.trim() || '[]');
79
+ if (prs.length > 0) {
80
+ return { url: prs[0].url, number: prs[0].number };
81
+ }
82
+ return null;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ export async function pollPRs() {
89
+ const db = getDb();
90
+ const tasks = db
91
+ .prepare("SELECT * FROM tasks WHERE status IN ('running', 'closed') AND pr_url IS NULL AND branch IS NOT NULL")
92
+ .all();
93
+ const results = await Promise.allSettled(tasks.map(async (task) => {
94
+ const pr = await detectPR(task);
95
+ return { task, pr };
96
+ }));
97
+ for (const result of results) {
98
+ if (result.status !== 'fulfilled')
99
+ continue;
100
+ const { task, pr } = result.value;
101
+ if (pr) {
102
+ db.prepare(`UPDATE tasks SET pr_url = ?, pr_number = ?, updated_at = datetime('now') WHERE id = ?`).run(pr.url, pr.number, task.id);
103
+ broadcast({ type: 'task:updated', payload: { taskId: task.id } });
104
+ }
105
+ }
106
+ }
107
+ // ─── Merged PR Detection ────────────────────────────────────────────────────
108
+ export async function checkMergedPRs() {
109
+ const db = getDb();
110
+ const tasks = db
111
+ .prepare("SELECT * FROM tasks WHERE status = 'running' AND pr_number IS NOT NULL")
112
+ .all();
113
+ const results = await Promise.allSettled(tasks.map(async (task) => {
114
+ const { stdout } = await execFile('gh', ['pr', 'view', String(task.pr_number), '--json', 'state'], {
115
+ cwd: task.repo_path,
116
+ });
117
+ const { state } = JSON.parse(stdout.trim());
118
+ return { task, state };
119
+ }));
120
+ for (const result of results) {
121
+ if (result.status !== 'fulfilled')
122
+ continue;
123
+ const { task, state } = result.value;
124
+ if (state === 'MERGED') {
125
+ try {
126
+ await closeTask(task);
127
+ broadcast({ type: 'task:updated', payload: { taskId: task.id } });
128
+ }
129
+ catch {
130
+ // closeTask failure shouldn't stop processing other tasks
131
+ }
132
+ }
133
+ }
134
+ }
135
+ export async function pollMergedPRs() {
136
+ try {
137
+ await checkMergedPRs();
138
+ }
139
+ catch (err) {
140
+ console.error('pollMergedPRs error:', err);
141
+ }
142
+ }
143
+ // ─── Lifecycle ───────────────────────────────────────────────────────────────
144
+ export function startPolling() {
145
+ // Install hooks in any running worktrees that might be missing them
146
+ ensureHooksInstalled();
147
+ if (STATUS_INTERVAL > 0) {
148
+ statusTimer = setInterval(pollStatuses, STATUS_INTERVAL);
149
+ }
150
+ if (PR_INTERVAL > 0) {
151
+ prTimer = setInterval(pollPRs, PR_INTERVAL);
152
+ }
153
+ if (MERGED_PR_INTERVAL > 0) {
154
+ mergedPrTimer = setInterval(pollMergedPRs, MERGED_PR_INTERVAL);
155
+ }
156
+ }
157
+ export function stopPolling() {
158
+ if (statusTimer) {
159
+ clearInterval(statusTimer);
160
+ statusTimer = null;
161
+ }
162
+ if (prTimer) {
163
+ clearInterval(prTimer);
164
+ prTimer = null;
165
+ }
166
+ if (mergedPrTimer) {
167
+ clearInterval(mergedPrTimer);
168
+ mergedPrTimer = null;
169
+ }
170
+ }
@@ -0,0 +1,7 @@
1
+ export interface PRPromptContext {
2
+ taskTitle: string;
3
+ taskDescription: string;
4
+ commitLog: string;
5
+ diffStats: string;
6
+ }
7
+ export declare function buildPRPrompt(context: PRPromptContext): string;
@@ -0,0 +1,29 @@
1
+ export function buildPRPrompt(context) {
2
+ return `Generate a pull request title and description based on the following changes.
3
+
4
+ Task: ${context.taskTitle}
5
+ Description: ${context.taskDescription}
6
+
7
+ Commits:
8
+ ${context.commitLog}
9
+
10
+ File changes:
11
+ ${context.diffStats}
12
+
13
+ Requirements:
14
+ - PR title must follow Conventional Commits: <type>(<scope>): <description>
15
+ Types: feat, fix, refactor, test, docs, chore
16
+ - PR body must use this exact format:
17
+
18
+ ## What
19
+ <1-3 bullet points describing what changed>
20
+
21
+ ## Why
22
+ <1-2 sentences explaining the motivation>
23
+
24
+ ## Testing
25
+ <Bulleted checklist of how to verify the changes>
26
+
27
+ Return ONLY valid JSON with no other text:
28
+ {"title": "the PR title", "body": "the PR body in markdown"}`;
29
+ }
@@ -0,0 +1,28 @@
1
+ import type { Task, Agent } from './types.js';
2
+ /**
3
+ * Poll tmux pane content until Claude Code's TUI is ready for input.
4
+ * Detects readiness by looking for the `>` input prompt character that
5
+ * Claude renders once its ink-based TUI has fully initialized.
6
+ * Falls back to proceeding after timeout (best-effort).
7
+ */
8
+ export declare function waitForClaudeReady(session: string, windowIndex: number, timeoutMs?: number): Promise<void>;
9
+ /**
10
+ * Kill all linked viewer sessions (`<tmuxSession>-v-*`) for a specific task.
11
+ * Safe to call even if no linked sessions exist.
12
+ */
13
+ export declare function cleanupLinkedSessions(tmuxSession: string): Promise<void>;
14
+ /**
15
+ * Clean up orphaned `-v-` viewer sessions from previous runs.
16
+ * Only kills linked sessions whose parent session no longer exists.
17
+ */
18
+ export declare function cleanupOrphanedViewerSessions(): Promise<void>;
19
+ /** Generate a git-safe branch slug from a title + task ID suffix. */
20
+ export declare function slugifyTitle(title: string, id: string): string;
21
+ export declare function startTask(task: Task): Promise<void>;
22
+ export declare function addAgent(task: Task, prompt?: string): Promise<Agent>;
23
+ export declare function closeTask(task: Task): Promise<void>;
24
+ export declare function deleteTask(task: Task): Promise<void>;
25
+ export declare function stopAgent(task: Task, agent: Agent): Promise<void>;
26
+ export declare function createUserTerminal(task: Task): Promise<number>;
27
+ export declare function resumeTask(task: Task): Promise<void>;
28
+ export declare function dispatchToWindow(session: string, windowIndex: number, text: string): Promise<void>;
@@ -0,0 +1,359 @@
1
+ import { execFile as execFileCb, spawn } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import crypto from 'crypto';
4
+ import path from 'path';
5
+ import fs from 'fs';
6
+ import { nanoid } from 'nanoid';
7
+ import { getDb } from './db.js';
8
+ import { installHookSettings } from './hook-settings.js';
9
+ const execFile = promisify(execFileCb);
10
+ const CLAUDE_READY_TIMEOUT = process.env.NODE_ENV === 'test' ? 0 : 30_000;
11
+ const CLAUDE_READY_POLL_INTERVAL = 500;
12
+ /** Get the active window index of a tmux session. */
13
+ async function getActiveWindowIndex(session) {
14
+ const { stdout } = await execFile('tmux', [
15
+ 'display-message',
16
+ '-t',
17
+ session,
18
+ '-p',
19
+ '#{window_index}',
20
+ ]);
21
+ return parseInt(stdout.trim(), 10);
22
+ }
23
+ /** Get the index of the last window in a tmux session. */
24
+ async function getLastWindowIndex(session) {
25
+ const { stdout } = await execFile('tmux', [
26
+ 'list-windows',
27
+ '-t',
28
+ session,
29
+ '-F',
30
+ '#{window_index}',
31
+ ]);
32
+ const indices = stdout.trim().split('\n').map(Number);
33
+ return Math.max(...indices);
34
+ }
35
+ function sleep(ms) {
36
+ return new Promise((resolve) => setTimeout(resolve, ms));
37
+ }
38
+ /**
39
+ * Poll tmux pane content until Claude Code's TUI is ready for input.
40
+ * Detects readiness by looking for the `>` input prompt character that
41
+ * Claude renders once its ink-based TUI has fully initialized.
42
+ * Falls back to proceeding after timeout (best-effort).
43
+ */
44
+ export async function waitForClaudeReady(session, windowIndex, timeoutMs = CLAUDE_READY_TIMEOUT) {
45
+ if (timeoutMs === 0)
46
+ return; // skip in tests
47
+ const target = `${session}:${windowIndex}`;
48
+ const start = Date.now();
49
+ while (Date.now() - start < timeoutMs) {
50
+ try {
51
+ const { stdout } = await execFile('tmux', ['capture-pane', '-t', target, '-p']);
52
+ // Claude Code renders a "❯" (or ">") prompt when ready for input
53
+ const lines = stdout.split('\n');
54
+ if (lines.some((line) => /^\s*[>❯]/.test(line))) {
55
+ return;
56
+ }
57
+ }
58
+ catch {
59
+ // pane may not exist yet — keep polling
60
+ }
61
+ await sleep(CLAUDE_READY_POLL_INTERVAL);
62
+ }
63
+ // Timeout — proceed anyway (best-effort)
64
+ }
65
+ /**
66
+ * Kill all linked viewer sessions (`<tmuxSession>-v-*`) for a specific task.
67
+ * Safe to call even if no linked sessions exist.
68
+ */
69
+ export async function cleanupLinkedSessions(tmuxSession) {
70
+ let stdout;
71
+ try {
72
+ ({ stdout } = await execFile('tmux', ['list-sessions', '-F', '#{session_name}']));
73
+ }
74
+ catch {
75
+ return; // tmux server not running or no sessions
76
+ }
77
+ const prefix = `${tmuxSession}-v-`;
78
+ const linked = stdout
79
+ .trim()
80
+ .split('\n')
81
+ .filter((name) => name.startsWith(prefix));
82
+ for (const session of linked) {
83
+ await execFile('tmux', ['kill-session', '-t', session]).catch(() => { });
84
+ }
85
+ }
86
+ /**
87
+ * Clean up orphaned `-v-` viewer sessions from previous runs.
88
+ * Only kills linked sessions whose parent session no longer exists.
89
+ */
90
+ export async function cleanupOrphanedViewerSessions() {
91
+ let stdout;
92
+ try {
93
+ ({ stdout } = await execFile('tmux', ['list-sessions', '-F', '#{session_name}']));
94
+ }
95
+ catch {
96
+ return;
97
+ }
98
+ const sessions = new Set(stdout.trim().split('\n').filter(Boolean));
99
+ const viewerPattern = /^(octomux-agent-.+)-v-/;
100
+ for (const name of sessions) {
101
+ const match = name.match(viewerPattern);
102
+ if (match) {
103
+ const parentSession = match[1];
104
+ if (!sessions.has(parentSession)) {
105
+ await execFile('tmux', ['kill-session', '-t', name]).catch(() => { });
106
+ }
107
+ }
108
+ }
109
+ }
110
+ /** Generate a git-safe branch slug from a title + task ID suffix. */
111
+ export function slugifyTitle(title, id) {
112
+ const slug = title
113
+ .toLowerCase()
114
+ .replace(/[^a-z0-9-]+/g, '-') // replace non-alphanumeric with hyphens
115
+ .replace(/-{2,}/g, '-') // collapse consecutive hyphens
116
+ .replace(/^-|-$/g, '') // trim leading/trailing hyphens
117
+ .slice(0, 50);
118
+ const suffix = id.slice(0, 6);
119
+ return `${slug}-${suffix}`;
120
+ }
121
+ export async function startTask(task) {
122
+ const db = getDb();
123
+ const id = task.id;
124
+ const session = `octomux-agent-${id}`;
125
+ const slug = slugifyTitle(task.title, id);
126
+ const branch = task.branch || `agents/${slug}`;
127
+ const worktreeDir = task.branch || slug;
128
+ const worktreePath = path.join(task.repo_path, '.worktrees', worktreeDir);
129
+ try {
130
+ // 1. Update status to setting_up
131
+ db.prepare(`UPDATE tasks SET status = ?, tmux_session = ?, branch = ?, worktree = ?, updated_at = datetime('now') WHERE id = ?`).run('setting_up', session, branch, worktreePath, id);
132
+ // 2. Validate repo path
133
+ if (!fs.existsSync(task.repo_path)) {
134
+ throw new Error(`Repository path does not exist: ${task.repo_path}`);
135
+ }
136
+ await execFile('git', ['-C', task.repo_path, 'rev-parse', '--is-inside-work-tree']);
137
+ // 3. Ensure .worktrees directory exists
138
+ const worktreeBaseDir = path.join(task.repo_path, '.worktrees');
139
+ fs.mkdirSync(worktreeBaseDir, { recursive: true });
140
+ // 4. Create worktree (optionally from a base branch)
141
+ const worktreeArgs = ['-C', task.repo_path, 'worktree', 'add', worktreePath, '-b', branch];
142
+ if (task.base_branch) {
143
+ worktreeArgs.push(task.base_branch);
144
+ }
145
+ await execFile('git', worktreeArgs);
146
+ // 5. Copy .claude/settings.local.json if it exists
147
+ const settingsSrc = path.join(task.repo_path, '.claude', 'settings.local.json');
148
+ const settingsDst = path.join(worktreePath, '.claude', 'settings.local.json');
149
+ if (fs.existsSync(settingsSrc)) {
150
+ fs.mkdirSync(path.dirname(settingsDst), { recursive: true });
151
+ fs.copyFileSync(settingsSrc, settingsDst);
152
+ }
153
+ // 5b. Install hook settings for permission tracking
154
+ installHookSettings(worktreePath);
155
+ // 6. Create tmux session
156
+ await execFile('tmux', ['new-session', '-d', '-s', session, '-c', worktreePath]);
157
+ // Prevent grouped viewer sessions from constraining window size
158
+ await execFile('tmux', ['set-option', '-t', session, 'aggressive-resize', 'on']);
159
+ // 7. Query the actual window index (respects tmux base-index)
160
+ const windowIndex = await getActiveWindowIndex(session);
161
+ // 8. Create first agent record with session ID
162
+ const agentId = nanoid(12);
163
+ const claudeSessionId = crypto.randomUUID();
164
+ db.prepare('INSERT INTO agents (id, task_id, window_index, label, claude_session_id) VALUES (?, ?, ?, ?, ?)').run(agentId, id, windowIndex, 'Agent 1', claudeSessionId);
165
+ // 9. Launch claude in the window with session tracking
166
+ await execFile('tmux', [
167
+ 'send-keys',
168
+ '-t',
169
+ `${session}:${windowIndex}`,
170
+ `claude --session-id ${claudeSessionId}`,
171
+ 'Enter',
172
+ ]);
173
+ // 10. Wait for Claude to be ready, then dispatch initial prompt
174
+ if (task.initial_prompt) {
175
+ await waitForClaudeReady(session, windowIndex);
176
+ await dispatchToWindow(session, windowIndex, task.initial_prompt);
177
+ }
178
+ // 12. Mark as running
179
+ db.prepare(`UPDATE tasks SET status = ?, updated_at = datetime('now') WHERE id = ?`).run('running', id);
180
+ }
181
+ catch (err) {
182
+ db.prepare(`UPDATE tasks SET status = ?, error = ?, updated_at = datetime('now') WHERE id = ?`).run('error', err.message, id);
183
+ }
184
+ }
185
+ export async function addAgent(task, prompt) {
186
+ const db = getDb();
187
+ // Determine label from active (non-stopped) agent count
188
+ const activeAgents = db
189
+ .prepare(`SELECT * FROM agents WHERE task_id = ? AND status != 'stopped' ORDER BY window_index`)
190
+ .all(task.id);
191
+ const label = `Agent ${activeAgents.length + 1}`;
192
+ // Create new tmux window
193
+ await execFile('tmux', ['new-window', '-t', task.tmux_session, '-c', task.worktree]);
194
+ // Query the actual window index of the newly created window
195
+ const windowIndex = await getLastWindowIndex(task.tmux_session);
196
+ // Create agent record with session ID
197
+ const agentId = nanoid(12);
198
+ const claudeSessionId = crypto.randomUUID();
199
+ db.prepare('INSERT INTO agents (id, task_id, window_index, label, claude_session_id) VALUES (?, ?, ?, ?, ?)').run(agentId, task.id, windowIndex, label, claudeSessionId);
200
+ // Launch claude with session tracking
201
+ await execFile('tmux', [
202
+ 'send-keys',
203
+ '-t',
204
+ `${task.tmux_session}:${windowIndex}`,
205
+ `claude --session-id ${claudeSessionId}`,
206
+ 'Enter',
207
+ ]);
208
+ // Dispatch prompt if provided
209
+ if (prompt) {
210
+ await waitForClaudeReady(task.tmux_session, windowIndex);
211
+ await dispatchToWindow(task.tmux_session, windowIndex, prompt);
212
+ }
213
+ return {
214
+ id: agentId,
215
+ task_id: task.id,
216
+ window_index: windowIndex,
217
+ label,
218
+ status: 'running',
219
+ claude_session_id: claudeSessionId,
220
+ hook_activity: 'active',
221
+ hook_activity_updated_at: null,
222
+ created_at: new Date().toISOString(),
223
+ };
224
+ }
225
+ export async function closeTask(task) {
226
+ const db = getDb();
227
+ // Resolve all pending permission prompts for this task
228
+ db.prepare(`UPDATE permission_prompts SET status = 'resolved', resolved_at = datetime('now')
229
+ WHERE task_id = ? AND status = 'pending'`).run(task.id);
230
+ // Mark task as closed and all agents as stopped
231
+ db.prepare(`UPDATE tasks SET status = 'closed', updated_at = datetime('now') WHERE id = ?`).run(task.id);
232
+ db.prepare(`UPDATE agents SET status = 'stopped', hook_activity = 'idle', hook_activity_updated_at = datetime('now') WHERE task_id = ?`).run(task.id);
233
+ // Kill linked viewer sessions, then main tmux session — worktree and branch are preserved for resume
234
+ if (task.tmux_session) {
235
+ await cleanupLinkedSessions(task.tmux_session);
236
+ await execFile('tmux', ['kill-session', '-t', task.tmux_session]).catch(() => { });
237
+ }
238
+ }
239
+ export async function deleteTask(task) {
240
+ // Kill linked viewer sessions, then main tmux session
241
+ if (task.tmux_session) {
242
+ await cleanupLinkedSessions(task.tmux_session);
243
+ await execFile('tmux', ['kill-session', '-t', task.tmux_session]).catch(() => { });
244
+ }
245
+ // Remove worktree
246
+ if (task.worktree) {
247
+ await execFile('git', [
248
+ '-C',
249
+ task.repo_path,
250
+ 'worktree',
251
+ 'remove',
252
+ task.worktree,
253
+ '--force',
254
+ ]).catch(() => { });
255
+ }
256
+ // Delete branch
257
+ if (task.branch) {
258
+ await execFile('git', ['-C', task.repo_path, 'branch', '-D', task.branch]).catch(() => { });
259
+ }
260
+ }
261
+ export async function stopAgent(task, agent) {
262
+ const db = getDb();
263
+ // Resolve pending permission prompts for this agent
264
+ db.prepare(`UPDATE permission_prompts SET status = 'resolved', resolved_at = datetime('now')
265
+ WHERE agent_id = ? AND status = 'pending'`).run(agent.id);
266
+ // Kill the specific tmux window
267
+ await execFile('tmux', ['kill-window', '-t', `${task.tmux_session}:${agent.window_index}`]).catch(() => { });
268
+ // Mark agent as stopped and idle
269
+ db.prepare(`UPDATE agents SET status = 'stopped', hook_activity = 'idle', hook_activity_updated_at = datetime('now') WHERE id = ?`).run(agent.id);
270
+ }
271
+ export async function createUserTerminal(task) {
272
+ if (task.user_window_index !== null && task.user_window_index !== undefined) {
273
+ return task.user_window_index;
274
+ }
275
+ const db = getDb();
276
+ await execFile('tmux', ['new-window', '-t', task.tmux_session, '-c', task.worktree]);
277
+ const windowIndex = await getLastWindowIndex(task.tmux_session);
278
+ await execFile('tmux', [
279
+ 'send-keys',
280
+ '-t',
281
+ `${task.tmux_session}:${windowIndex}`,
282
+ 'nvim .',
283
+ 'Enter',
284
+ ]);
285
+ db.prepare(`UPDATE tasks SET user_window_index = ?, updated_at = datetime('now') WHERE id = ?`).run(windowIndex, task.id);
286
+ return windowIndex;
287
+ }
288
+ export async function resumeTask(task) {
289
+ const db = getDb();
290
+ const session = task.tmux_session;
291
+ try {
292
+ // 1. Set status synchronously to prevent poller race
293
+ db.prepare(`UPDATE tasks SET status = 'setting_up', error = NULL, user_window_index = NULL, updated_at = datetime('now') WHERE id = ?`).run(task.id);
294
+ // 2. Kill any stale linked viewer sessions and tmux session
295
+ await cleanupLinkedSessions(session);
296
+ await execFile('tmux', ['kill-session', '-t', session]).catch(() => { });
297
+ // 3. Create fresh tmux session
298
+ await execFile('tmux', ['new-session', '-d', '-s', session, '-c', task.worktree]);
299
+ await execFile('tmux', ['set-option', '-t', session, 'aggressive-resize', 'on']);
300
+ // 3b. Install hook settings for permission tracking
301
+ installHookSettings(task.worktree);
302
+ // 4. Get stopped agents
303
+ const agents = db
304
+ .prepare(`SELECT * FROM agents WHERE task_id = ? AND status = 'stopped' ORDER BY window_index`)
305
+ .all(task.id);
306
+ for (let i = 0; i < agents.length; i++) {
307
+ const agent = agents[i];
308
+ let windowIndex;
309
+ if (i === 0) {
310
+ // Use the initial session window
311
+ windowIndex = await getActiveWindowIndex(session);
312
+ }
313
+ else {
314
+ // Create new window for subsequent agents
315
+ await execFile('tmux', ['new-window', '-t', session, '-c', task.worktree]);
316
+ windowIndex = await getLastWindowIndex(session);
317
+ }
318
+ // Launch claude with resume or continue
319
+ let claudeCmd;
320
+ if (agent.claude_session_id) {
321
+ claudeCmd = `claude --resume ${agent.claude_session_id}`;
322
+ }
323
+ else {
324
+ const newSessionId = crypto.randomUUID();
325
+ claudeCmd = `claude --continue --session-id ${newSessionId}`;
326
+ db.prepare('UPDATE agents SET claude_session_id = ? WHERE id = ?').run(newSessionId, agent.id);
327
+ }
328
+ await execFile('tmux', ['send-keys', '-t', `${session}:${windowIndex}`, claudeCmd, 'Enter']);
329
+ // Update agent record
330
+ db.prepare(`UPDATE agents SET window_index = ?, status = 'running' WHERE id = ?`).run(windowIndex, agent.id);
331
+ }
332
+ // 5. Mark task as running
333
+ db.prepare(`UPDATE tasks SET status = 'running', updated_at = datetime('now') WHERE id = ?`).run(task.id);
334
+ }
335
+ catch (err) {
336
+ db.prepare(`UPDATE tasks SET status = 'error', error = ?, updated_at = datetime('now') WHERE id = ?`).run(err.message, task.id);
337
+ }
338
+ }
339
+ export async function dispatchToWindow(session, windowIndex, text) {
340
+ const target = `${session}:${windowIndex}`;
341
+ const bufferName = `octomux-${nanoid(8)}`;
342
+ // Load text into a named tmux buffer (avoids race with concurrent dispatches).
343
+ // Do NOT append '\n' — send-keys Enter handles submission separately.
344
+ await new Promise((resolve, reject) => {
345
+ const proc = spawn('tmux', ['load-buffer', '-b', bufferName, '-']);
346
+ proc.stdin.write(text);
347
+ proc.stdin.end();
348
+ proc.on('close', (code) => {
349
+ if (code === 0)
350
+ resolve();
351
+ else
352
+ reject(new Error(`tmux load-buffer exited with code ${code}`));
353
+ });
354
+ });
355
+ // Paste named buffer into the target window, -d deletes buffer after paste
356
+ await execFile('tmux', ['paste-buffer', '-b', bufferName, '-d', '-t', target]);
357
+ // Press Enter to submit the prompt
358
+ await execFile('tmux', ['send-keys', '-t', target, 'Enter']);
359
+ }
@@ -0,0 +1,13 @@
1
+ import { WebSocket } from 'ws';
2
+ import { type IPty } from 'node-pty';
3
+ import type { IncomingMessage } from 'http';
4
+ import type { Duplex } from 'stream';
5
+ interface TerminalConnection {
6
+ ws: WebSocket;
7
+ pty: IPty;
8
+ }
9
+ export declare function setupTerminalWebSocket(): void;
10
+ export declare function handleTerminalUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): boolean;
11
+ export declare function getActiveConnections(): Map<string, TerminalConnection[]>;
12
+ export declare function cleanupAllConnections(): void;
13
+ export {};