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,17 @@
1
+ <!doctype html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/png" href="/logo.png" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>octomux</title>
8
+ <script type="module" crossorigin src="/assets/index-Bsj_BLLM.js"></script>
9
+ <link rel="modulepreload" crossorigin href="/assets/vendor-react-BZ8ItZjw.js">
10
+ <link rel="modulepreload" crossorigin href="/assets/vendor-router-DRLGqALp.js">
11
+ <link rel="modulepreload" crossorigin href="/assets/vendor-ui-CWZtXYLx.js">
12
+ <link rel="stylesheet" crossorigin href="/assets/index-Br9dLOzs.css">
13
+ </head>
14
+ <body>
15
+ <div id="root"></div>
16
+ </body>
17
+ </html>
package/dist/logo.png ADDED
Binary file
@@ -0,0 +1,2 @@
1
+ import type { Express } from 'express';
2
+ export declare function setupRoutes(app: Express): void;
@@ -0,0 +1,447 @@
1
+ import { nanoid } from 'nanoid';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { getDb } from './db.js';
6
+ import { execFile as execFileCb } from 'child_process';
7
+ import { promisify } from 'util';
8
+ import { startTask, closeTask, deleteTask, resumeTask, addAgent, stopAgent, createUserTerminal, } from './task-runner.js';
9
+ import { isOrchestratorRunning, startOrchestrator, stopOrchestrator, getOrchestratorSession, } from './orchestrator.js';
10
+ import { hookRoutes } from './hooks.js';
11
+ import { broadcast } from './events.js';
12
+ const execFile = promisify(execFileCb);
13
+ function safeParseJson(s) {
14
+ try {
15
+ return JSON.parse(s || '{}');
16
+ }
17
+ catch {
18
+ return {};
19
+ }
20
+ }
21
+ function derivedStatus(task) {
22
+ if (task.status !== 'running')
23
+ return null;
24
+ const activities = task.agents.filter((a) => a.status !== 'stopped').map((a) => a.hook_activity);
25
+ if (activities.length === 0)
26
+ return 'done';
27
+ if (activities.includes('active'))
28
+ return 'working';
29
+ if (activities.includes('waiting'))
30
+ return 'needs_attention';
31
+ return 'done';
32
+ }
33
+ export function setupRoutes(app) {
34
+ app.use('/api/hooks', hookRoutes);
35
+ // Browse directories for folder picker
36
+ app.get('/api/browse', (req, res) => {
37
+ const dirPath = req.query.path || os.homedir();
38
+ try {
39
+ const stat = fs.statSync(dirPath);
40
+ if (!stat.isDirectory()) {
41
+ res.status(400).json({ error: 'Path is not a directory' });
42
+ return;
43
+ }
44
+ }
45
+ catch {
46
+ res.status(400).json({ error: 'Path does not exist' });
47
+ return;
48
+ }
49
+ const entries = [];
50
+ for (const name of fs.readdirSync(dirPath)) {
51
+ const fullPath = path.join(dirPath, name);
52
+ try {
53
+ const stat = fs.statSync(fullPath);
54
+ if (!stat.isDirectory())
55
+ continue;
56
+ const isGit = fs.existsSync(path.join(fullPath, '.git'));
57
+ entries.push({ name, path: fullPath, isGit });
58
+ }
59
+ catch {
60
+ // skip unreadable entries
61
+ }
62
+ }
63
+ // Sort: git repos first, then hidden dirs last, then alphabetical
64
+ entries.sort((a, b) => {
65
+ if (a.isGit !== b.isGit)
66
+ return a.isGit ? -1 : 1;
67
+ const aHidden = a.name.startsWith('.');
68
+ const bHidden = b.name.startsWith('.');
69
+ if (aHidden !== bHidden)
70
+ return aHidden ? 1 : -1;
71
+ return a.name.localeCompare(b.name);
72
+ });
73
+ const parent = path.dirname(dirPath);
74
+ res.json({
75
+ current: dirPath,
76
+ parent: parent !== dirPath ? parent : null,
77
+ entries,
78
+ });
79
+ });
80
+ // List branches for a git repo
81
+ app.get('/api/branches', async (req, res) => {
82
+ const repoPath = req.query.repo_path;
83
+ if (!repoPath) {
84
+ res.status(400).json({ error: 'repo_path is required' });
85
+ return;
86
+ }
87
+ try {
88
+ const { stdout } = await execFile('git', [
89
+ '-C',
90
+ repoPath,
91
+ 'branch',
92
+ '-a',
93
+ '--format=%(refname:short)',
94
+ ]);
95
+ const branches = stdout
96
+ .trim()
97
+ .split('\n')
98
+ .filter(Boolean)
99
+ .map((b) => b.replace(/^origin\//, ''));
100
+ // Deduplicate (local + remote may overlap)
101
+ const unique = [...new Set(branches)].filter((b) => b !== 'HEAD');
102
+ res.json(unique);
103
+ }
104
+ catch {
105
+ res.status(400).json({ error: 'Failed to list branches' });
106
+ }
107
+ });
108
+ // Get default branch for a git repo
109
+ app.get('/api/default-branch', async (req, res) => {
110
+ const repoPath = req.query.repo_path;
111
+ if (!repoPath) {
112
+ res.status(400).json({ error: 'repo_path is required' });
113
+ return;
114
+ }
115
+ try {
116
+ const { stdout } = await execFile('git', [
117
+ '-C',
118
+ repoPath,
119
+ 'symbolic-ref',
120
+ 'refs/remotes/origin/HEAD',
121
+ ]);
122
+ const branch = stdout.trim().replace('refs/remotes/origin/', '');
123
+ res.json({ branch });
124
+ }
125
+ catch {
126
+ // Fallback to 'main'
127
+ res.json({ branch: 'main' });
128
+ }
129
+ });
130
+ // Recent repository paths from past tasks
131
+ app.get('/api/recent-repos', (_req, res) => {
132
+ const db = getDb();
133
+ const rows = db
134
+ .prepare(`SELECT repo_path, MAX(created_at) as last_used FROM tasks GROUP BY repo_path ORDER BY last_used DESC LIMIT 10`)
135
+ .all();
136
+ res.json(rows);
137
+ });
138
+ // List all tasks with agents
139
+ app.get('/api/tasks', (req, res) => {
140
+ const db = getDb();
141
+ const repoPath = req.query.repo_path;
142
+ let tasks;
143
+ if (repoPath) {
144
+ tasks = db
145
+ .prepare('SELECT * FROM tasks WHERE repo_path = ? ORDER BY created_at DESC')
146
+ .all(repoPath);
147
+ }
148
+ else {
149
+ tasks = db.prepare('SELECT * FROM tasks ORDER BY created_at DESC').all();
150
+ }
151
+ const agentStmt = db.prepare('SELECT * FROM agents WHERE task_id = ? ORDER BY window_index');
152
+ const promptStmt = db.prepare(`SELECT pp.*, a.label as agent_label
153
+ FROM permission_prompts pp
154
+ LEFT JOIN agents a ON pp.agent_id = a.id
155
+ WHERE pp.task_id = ? AND pp.status = 'pending'
156
+ ORDER BY pp.created_at ASC`);
157
+ const result = tasks.map((task) => {
158
+ const agents = agentStmt.all(task.id);
159
+ const pendingPrompts = promptStmt.all(task.id);
160
+ const parsedPrompts = pendingPrompts.map((pp) => ({
161
+ ...pp,
162
+ tool_input: safeParseJson(pp.tool_input),
163
+ }));
164
+ return {
165
+ ...task,
166
+ agents,
167
+ pending_prompts: parsedPrompts,
168
+ derived_status: derivedStatus({ status: task.status, agents }),
169
+ };
170
+ });
171
+ res.json(result);
172
+ });
173
+ // Get single task with agents
174
+ app.get('/api/tasks/:id', (req, res) => {
175
+ const db = getDb();
176
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
177
+ if (!task) {
178
+ res.status(404).json({ error: 'Task not found' });
179
+ return;
180
+ }
181
+ const agents = db
182
+ .prepare('SELECT * FROM agents WHERE task_id = ? ORDER BY window_index')
183
+ .all(task.id);
184
+ const pendingPrompts = db
185
+ .prepare(`SELECT pp.*, a.label as agent_label
186
+ FROM permission_prompts pp
187
+ LEFT JOIN agents a ON pp.agent_id = a.id
188
+ WHERE pp.task_id = ? AND pp.status = 'pending'
189
+ ORDER BY pp.created_at ASC`)
190
+ .all(task.id);
191
+ const parsedPrompts = pendingPrompts.map((pp) => ({
192
+ ...pp,
193
+ tool_input: safeParseJson(pp.tool_input),
194
+ }));
195
+ res.json({
196
+ ...task,
197
+ agents,
198
+ pending_prompts: parsedPrompts,
199
+ derived_status: derivedStatus({ status: task.status, agents }),
200
+ });
201
+ });
202
+ // Create task
203
+ app.post('/api/tasks', async (req, res) => {
204
+ const body = req.body;
205
+ if (!body.title || !body.description || !body.repo_path) {
206
+ res.status(400).json({ error: 'title, description, and repo_path are required' });
207
+ return;
208
+ }
209
+ const db = getDb();
210
+ const id = nanoid(12);
211
+ db.prepare('INSERT INTO tasks (id, title, description, repo_path, status, branch, base_branch, initial_prompt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(id, body.title, body.description, body.repo_path, 'draft', body.branch ?? null, body.base_branch ?? null, body.initial_prompt ?? null);
212
+ // Start task immediately unless explicitly saved as draft
213
+ if (!body.draft) {
214
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
215
+ await startTask(task);
216
+ }
217
+ const created = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
218
+ created.agents = db
219
+ .prepare('SELECT * FROM agents WHERE task_id = ? ORDER BY window_index')
220
+ .all(id);
221
+ broadcast({ type: 'task:created', payload: { taskId: id } });
222
+ res.status(201).json(created);
223
+ });
224
+ // Update task status
225
+ app.patch('/api/tasks/:id', async (req, res) => {
226
+ const body = req.body;
227
+ const db = getDb();
228
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
229
+ if (!task) {
230
+ res.status(404).json({ error: 'Task not found' });
231
+ return;
232
+ }
233
+ // Draft field updates (title, description, repo_path, branch, base_branch, initial_prompt)
234
+ const hasDraftFields = [
235
+ 'title',
236
+ 'description',
237
+ 'repo_path',
238
+ 'branch',
239
+ 'base_branch',
240
+ 'initial_prompt',
241
+ ].some((k) => body[k] !== undefined);
242
+ if (hasDraftFields) {
243
+ if (task.status !== 'draft') {
244
+ res.status(400).json({ error: 'Can only edit fields on draft tasks' });
245
+ return;
246
+ }
247
+ if (body.title !== undefined && !body.title.trim()) {
248
+ res.status(400).json({ error: 'title is required' });
249
+ return;
250
+ }
251
+ if (body.description !== undefined && !body.description.trim()) {
252
+ res.status(400).json({ error: 'description is required' });
253
+ return;
254
+ }
255
+ if (body.repo_path !== undefined) {
256
+ if (!body.repo_path.trim()) {
257
+ res.status(400).json({ error: 'repo_path is required' });
258
+ return;
259
+ }
260
+ if (!fs.existsSync(body.repo_path)) {
261
+ res.status(400).json({ error: 'repo_path does not exist' });
262
+ return;
263
+ }
264
+ }
265
+ const fields = [];
266
+ const values = [];
267
+ for (const key of [
268
+ 'title',
269
+ 'description',
270
+ 'repo_path',
271
+ 'branch',
272
+ 'base_branch',
273
+ 'initial_prompt',
274
+ ]) {
275
+ if (body[key] !== undefined) {
276
+ fields.push(`${key} = ?`);
277
+ values.push(body[key] ?? null);
278
+ }
279
+ }
280
+ if (fields.length > 0) {
281
+ fields.push(`updated_at = datetime('now')`);
282
+ values.push(task.id);
283
+ db.prepare(`UPDATE tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values);
284
+ }
285
+ }
286
+ else if (body.status === 'running') {
287
+ // Resume task
288
+ if (task.status !== 'closed' && task.status !== 'error') {
289
+ res.status(400).json({ error: 'Can only resume tasks in closed or error state' });
290
+ return;
291
+ }
292
+ if (!task.worktree || !fs.existsSync(task.worktree)) {
293
+ res.status(400).json({ error: 'Worktree no longer exists on disk' });
294
+ return;
295
+ }
296
+ await resumeTask(task);
297
+ }
298
+ else if (body.status === 'closed') {
299
+ await closeTask(task);
300
+ }
301
+ const updated = db.prepare('SELECT * FROM tasks WHERE id = ?').get(task.id);
302
+ updated.agents = db
303
+ .prepare('SELECT * FROM agents WHERE task_id = ? ORDER BY window_index')
304
+ .all(task.id);
305
+ broadcast({ type: 'task:updated', payload: { taskId: task.id } });
306
+ res.json(updated);
307
+ });
308
+ // Start a draft task
309
+ app.post('/api/tasks/:id/start', async (req, res) => {
310
+ const db = getDb();
311
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
312
+ if (!task) {
313
+ res.status(404).json({ error: 'Task not found' });
314
+ return;
315
+ }
316
+ if (task.status !== 'draft') {
317
+ res.status(400).json({ error: 'Only draft tasks can be started' });
318
+ return;
319
+ }
320
+ await startTask(task);
321
+ const updated = db.prepare('SELECT * FROM tasks WHERE id = ?').get(task.id);
322
+ updated.agents = db
323
+ .prepare('SELECT * FROM agents WHERE task_id = ? ORDER BY window_index')
324
+ .all(task.id);
325
+ broadcast({ type: 'task:updated', payload: { taskId: task.id } });
326
+ res.json(updated);
327
+ });
328
+ // Delete task
329
+ app.delete('/api/tasks/:id', async (req, res) => {
330
+ const db = getDb();
331
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
332
+ if (!task) {
333
+ res.status(404).json({ error: 'Task not found' });
334
+ return;
335
+ }
336
+ const taskId = task.id;
337
+ await deleteTask(task);
338
+ db.prepare('DELETE FROM agents WHERE task_id = ?').run(taskId);
339
+ db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
340
+ broadcast({ type: 'task:deleted', payload: { taskId } });
341
+ res.status(204).send();
342
+ });
343
+ // Add agent to task
344
+ app.post('/api/tasks/:id/agents', async (req, res) => {
345
+ const db = getDb();
346
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
347
+ if (!task) {
348
+ res.status(404).json({ error: 'Task not found' });
349
+ return;
350
+ }
351
+ if (task.status !== 'running') {
352
+ res.status(400).json({ error: 'Can only add agents to running tasks' });
353
+ return;
354
+ }
355
+ const body = req.body;
356
+ const agent = await addAgent(task, body.prompt);
357
+ broadcast({ type: 'task:updated', payload: { taskId: task.id } });
358
+ res.status(201).json(agent);
359
+ });
360
+ // Stop agent
361
+ app.delete('/api/tasks/:id/agents/:agentId', async (req, res) => {
362
+ const db = getDb();
363
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
364
+ const agent = db
365
+ .prepare('SELECT * FROM agents WHERE id = ? AND task_id = ?')
366
+ .get(req.params.agentId, req.params.id);
367
+ if (!task || !agent) {
368
+ res.status(404).json({ error: 'Task or agent not found' });
369
+ return;
370
+ }
371
+ await stopAgent(task, agent);
372
+ broadcast({ type: 'task:updated', payload: { taskId: task.id } });
373
+ res.json({ success: true });
374
+ });
375
+ // Create user terminal (lazily creates tmux window with nvim)
376
+ app.post('/api/tasks/:id/user-terminal', async (req, res) => {
377
+ const db = getDb();
378
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
379
+ if (!task) {
380
+ res.status(404).json({ error: 'Task not found' });
381
+ return;
382
+ }
383
+ if (task.status !== 'running') {
384
+ res.status(400).json({ error: 'Can only create user terminal for running tasks' });
385
+ return;
386
+ }
387
+ if (!task.tmux_session) {
388
+ res.status(400).json({ error: 'Task has no tmux session' });
389
+ return;
390
+ }
391
+ try {
392
+ const userWindowIndex = await createUserTerminal(task);
393
+ broadcast({ type: 'task:updated', payload: { taskId: task.id } });
394
+ res.json({ user_window_index: userWindowIndex });
395
+ }
396
+ catch (err) {
397
+ res.status(500).json({ error: err.message });
398
+ }
399
+ });
400
+ // Send message to agent via tmux send-keys
401
+ app.post('/api/tasks/:id/agents/:agentId/message', async (req, res) => {
402
+ const db = getDb();
403
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
404
+ if (!task) {
405
+ res.status(404).json({ error: 'Task not found' });
406
+ return;
407
+ }
408
+ if (task.status !== 'running') {
409
+ res.status(400).json({ error: 'Task is not running' });
410
+ return;
411
+ }
412
+ const agent = db
413
+ .prepare('SELECT * FROM agents WHERE id = ? AND task_id = ?')
414
+ .get(req.params.agentId, req.params.id);
415
+ if (!agent) {
416
+ res.status(404).json({ error: 'Agent not found' });
417
+ return;
418
+ }
419
+ const { message } = req.body;
420
+ if (!message) {
421
+ res.status(400).json({ error: 'message is required' });
422
+ return;
423
+ }
424
+ await execFile('tmux', [
425
+ 'send-keys',
426
+ '-t',
427
+ `${task.tmux_session}:${agent.window_index}`,
428
+ message,
429
+ 'Enter',
430
+ ]);
431
+ res.json({ success: true });
432
+ });
433
+ // ─── Orchestrator ──────────────────────────────────────────────────────────
434
+ app.get('/api/orchestrator/status', async (_req, res) => {
435
+ const running = await isOrchestratorRunning();
436
+ res.json({ running, session: getOrchestratorSession() });
437
+ });
438
+ app.post('/api/orchestrator/start', async (req, res) => {
439
+ const cwd = req.body?.cwd;
440
+ await startOrchestrator(cwd);
441
+ res.json({ running: true, session: getOrchestratorSession() });
442
+ });
443
+ app.post('/api/orchestrator/stop', async (_req, res) => {
444
+ await stopOrchestrator();
445
+ res.json({ running: false });
446
+ });
447
+ }
@@ -0,0 +1,2 @@
1
+ import express from 'express';
2
+ export declare function createApp(): express.Express;
@@ -0,0 +1,13 @@
1
+ import express from 'express';
2
+ import { setupRoutes } from './api.js';
3
+ export function createApp() {
4
+ const app = express();
5
+ app.use(express.json({ limit: '1mb' }));
6
+ setupRoutes(app);
7
+ // Error handler
8
+ app.use((err, _req, res, _next) => {
9
+ console.error('Unhandled error:', err.message, err.stack);
10
+ res.status(500).json({ error: err.message });
11
+ });
12
+ return app;
13
+ }
@@ -0,0 +1,7 @@
1
+ import Database from 'better-sqlite3';
2
+ export declare const SCHEMA = "\nCREATE TABLE IF NOT EXISTS tasks (\n id TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n description TEXT NOT NULL,\n repo_path TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'draft',\n branch TEXT,\n base_branch TEXT,\n worktree TEXT,\n tmux_session TEXT,\n pr_url TEXT,\n pr_number INTEGER,\n initial_prompt TEXT,\n error TEXT,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE TABLE IF NOT EXISTS agents (\n id TEXT PRIMARY KEY,\n task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,\n window_index INTEGER NOT NULL,\n label TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'running',\n claude_session_id TEXT,\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE TABLE IF NOT EXISTS permission_prompts (\n id TEXT PRIMARY KEY,\n task_id TEXT NOT NULL,\n agent_id TEXT,\n session_id TEXT NOT NULL,\n tool_name TEXT NOT NULL,\n tool_input TEXT NOT NULL DEFAULT '{}',\n status TEXT NOT NULL DEFAULT 'pending',\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n resolved_at TEXT,\n FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,\n FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE\n);\n\nCREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);\nCREATE INDEX IF NOT EXISTS idx_agents_task ON agents(task_id);\nCREATE INDEX IF NOT EXISTS idx_permission_prompts_task_id ON permission_prompts(task_id);\nCREATE INDEX IF NOT EXISTS idx_permission_prompts_status ON permission_prompts(status);\nCREATE INDEX IF NOT EXISTS idx_permission_prompts_agent_status ON permission_prompts(agent_id, status);\n";
3
+ export declare function getDb(): Database.Database;
4
+ /** Replace the singleton db instance (for testing). */
5
+ export declare function setDb(instance: Database.Database): void;
6
+ /** Initialize a database with schema and pragmas. */
7
+ export declare function initDb(instance: Database.Database): void;
@@ -0,0 +1,107 @@
1
+ import Database from 'better-sqlite3';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import { fileURLToPath } from 'url';
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const DB_DIR = path.join(__dirname, '..', 'data');
7
+ const DB_PATH = path.join(DB_DIR, 'tasks.db');
8
+ export const SCHEMA = `
9
+ CREATE TABLE IF NOT EXISTS tasks (
10
+ id TEXT PRIMARY KEY,
11
+ title TEXT NOT NULL,
12
+ description TEXT NOT NULL,
13
+ repo_path TEXT NOT NULL,
14
+ status TEXT NOT NULL DEFAULT 'draft',
15
+ branch TEXT,
16
+ base_branch TEXT,
17
+ worktree TEXT,
18
+ tmux_session TEXT,
19
+ pr_url TEXT,
20
+ pr_number INTEGER,
21
+ initial_prompt TEXT,
22
+ error TEXT,
23
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
24
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
25
+ );
26
+
27
+ CREATE TABLE IF NOT EXISTS agents (
28
+ id TEXT PRIMARY KEY,
29
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
30
+ window_index INTEGER NOT NULL,
31
+ label TEXT NOT NULL,
32
+ status TEXT NOT NULL DEFAULT 'running',
33
+ claude_session_id TEXT,
34
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS permission_prompts (
38
+ id TEXT PRIMARY KEY,
39
+ task_id TEXT NOT NULL,
40
+ agent_id TEXT,
41
+ session_id TEXT NOT NULL,
42
+ tool_name TEXT NOT NULL,
43
+ tool_input TEXT NOT NULL DEFAULT '{}',
44
+ status TEXT NOT NULL DEFAULT 'pending',
45
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
46
+ resolved_at TEXT,
47
+ FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
48
+ FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
49
+ );
50
+
51
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
52
+ CREATE INDEX IF NOT EXISTS idx_agents_task ON agents(task_id);
53
+ CREATE INDEX IF NOT EXISTS idx_permission_prompts_task_id ON permission_prompts(task_id);
54
+ CREATE INDEX IF NOT EXISTS idx_permission_prompts_status ON permission_prompts(status);
55
+ CREATE INDEX IF NOT EXISTS idx_permission_prompts_agent_status ON permission_prompts(agent_id, status);
56
+ `;
57
+ let db;
58
+ export function getDb() {
59
+ if (!db) {
60
+ fs.mkdirSync(DB_DIR, { recursive: true });
61
+ db = new Database(DB_PATH);
62
+ initDb(db);
63
+ }
64
+ return db;
65
+ }
66
+ /** Replace the singleton db instance (for testing). */
67
+ export function setDb(instance) {
68
+ db = instance;
69
+ }
70
+ /** Initialize a database with schema and pragmas. */
71
+ export function initDb(instance) {
72
+ instance.pragma('journal_mode = WAL');
73
+ instance.pragma('foreign_keys = ON');
74
+ instance.exec(SCHEMA);
75
+ // Migrations
76
+ const cols = instance.pragma('table_info(tasks)');
77
+ const colNames = cols.map((c) => c.name);
78
+ if (!colNames.includes('initial_prompt')) {
79
+ instance.exec('ALTER TABLE tasks ADD COLUMN initial_prompt TEXT');
80
+ }
81
+ // Add claude_session_id to agents table (for existing DBs)
82
+ const agentCols = instance.pragma('table_info(agents)');
83
+ const agentColNames = agentCols.map((c) => c.name);
84
+ if (!agentColNames.includes('claude_session_id')) {
85
+ instance.exec('ALTER TABLE agents ADD COLUMN claude_session_id TEXT');
86
+ }
87
+ if (!colNames.includes('base_branch')) {
88
+ instance.exec('ALTER TABLE tasks ADD COLUMN base_branch TEXT');
89
+ }
90
+ if (!colNames.includes('user_window_index')) {
91
+ instance.exec('ALTER TABLE tasks ADD COLUMN user_window_index INTEGER');
92
+ }
93
+ // Add hook_activity columns to agents table (for existing DBs)
94
+ const agentCols2 = instance.pragma('table_info(agents)');
95
+ const agentColNames2 = agentCols2.map((c) => c.name);
96
+ if (!agentColNames2.includes('hook_activity')) {
97
+ instance.exec("ALTER TABLE agents ADD COLUMN hook_activity TEXT NOT NULL DEFAULT 'active'");
98
+ instance.exec('ALTER TABLE agents ADD COLUMN hook_activity_updated_at TEXT');
99
+ }
100
+ // Migrate any legacy 'created' status to 'draft' (old schema default)
101
+ instance.exec(`UPDATE tasks SET status = 'draft' WHERE status = 'created'`);
102
+ // Resolve any stale pending permission prompts from previous run
103
+ // and reset agents stuck in 'waiting' state (hooks lost during restart)
104
+ instance.exec(`UPDATE permission_prompts SET status = 'resolved', resolved_at = datetime('now') WHERE status = 'pending'`);
105
+ instance.exec(`UPDATE agents SET hook_activity = 'active', hook_activity_updated_at = datetime('now')
106
+ WHERE hook_activity = 'waiting' AND status = 'running'`);
107
+ }
@@ -0,0 +1,13 @@
1
+ import type { IncomingMessage } from 'http';
2
+ import type { Duplex } from 'stream';
3
+ export interface ServerEvent {
4
+ type: 'task:updated' | 'task:created' | 'task:deleted';
5
+ payload: {
6
+ taskId: string;
7
+ };
8
+ }
9
+ export declare function setupEventWebSocket(): void;
10
+ export declare function handleEventUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): boolean;
11
+ export declare function broadcast(event: ServerEvent): void;
12
+ export declare function getEventClientCount(): number;
13
+ export declare function cleanupEventClients(): void;
@@ -0,0 +1,35 @@
1
+ import { WebSocketServer, WebSocket } from 'ws';
2
+ const clients = new Set();
3
+ let wss;
4
+ export function setupEventWebSocket() {
5
+ wss = new WebSocketServer({ noServer: true });
6
+ }
7
+ export function handleEventUpgrade(req, socket, head) {
8
+ if (req.url !== '/ws/events')
9
+ return false;
10
+ wss.handleUpgrade(req, socket, head, (ws) => {
11
+ clients.add(ws);
12
+ ws.on('close', () => clients.delete(ws));
13
+ ws.on('error', () => clients.delete(ws));
14
+ });
15
+ return true;
16
+ }
17
+ export function broadcast(event) {
18
+ const message = JSON.stringify(event);
19
+ for (const client of clients) {
20
+ if (client.readyState === WebSocket.OPEN) {
21
+ client.send(message);
22
+ }
23
+ }
24
+ }
25
+ export function getEventClientCount() {
26
+ return clients.size;
27
+ }
28
+ export function cleanupEventClients() {
29
+ for (const client of clients) {
30
+ if (client.readyState === WebSocket.OPEN) {
31
+ client.close(1001, 'Server shutting down');
32
+ }
33
+ }
34
+ clients.clear();
35
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Install Claude Code hook settings into a worktree's `.claude/settings.local.json`.
3
+ * Merges with any existing settings, preserving non-hook keys and non-overlapping hook events.
4
+ */
5
+ export declare function installHookSettings(worktreePath: string): void;