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,173 @@
1
+ import { execFile as execFileCb } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { WebSocketServer, WebSocket } from 'ws';
4
+ import { spawn } from 'node-pty';
5
+ import { nanoid } from 'nanoid';
6
+ import { getDb } from './db.js';
7
+ import { getOrchestratorSession } from './orchestrator.js';
8
+ const execFile = promisify(execFileCb);
9
+ const connections = new Map();
10
+ let wss;
11
+ export function setupTerminalWebSocket() {
12
+ wss = new WebSocketServer({ noServer: true });
13
+ }
14
+ export function handleTerminalUpgrade(req, socket, head) {
15
+ // Match /ws/terminal/orchestrator
16
+ const orchMatch = req.url?.match(/^\/ws\/terminal\/orchestrator$/);
17
+ if (orchMatch) {
18
+ wss.handleUpgrade(req, socket, head, (ws) => {
19
+ handleOrchestratorConnection(ws);
20
+ });
21
+ return true;
22
+ }
23
+ // Match /ws/terminal/:taskId/:windowIndex
24
+ const match = req.url?.match(/^\/ws\/terminal\/([^/]+)\/(\d+)$/);
25
+ if (!match)
26
+ return false;
27
+ wss.handleUpgrade(req, socket, head, (ws) => {
28
+ const taskId = match[1];
29
+ const windowIndex = parseInt(match[2], 10);
30
+ handleConnection(ws, taskId, windowIndex);
31
+ });
32
+ return true;
33
+ }
34
+ function attachToTmuxSession(ws, tmuxTarget, connKey, closeReason, linkedSession, pendingMessages) {
35
+ let pty;
36
+ try {
37
+ pty = spawn('tmux', ['attach-session', '-t', tmuxTarget], {
38
+ name: 'xterm-256color',
39
+ cols: 120,
40
+ rows: 30,
41
+ env: process.env,
42
+ });
43
+ }
44
+ catch {
45
+ ws.close(4005, closeReason);
46
+ return;
47
+ }
48
+ let ptyExited = false;
49
+ if (!connections.has(connKey)) {
50
+ connections.set(connKey, []);
51
+ }
52
+ connections.get(connKey).push({ ws, pty });
53
+ // PTY → WebSocket
54
+ pty.onData((data) => {
55
+ if (ws.readyState === WebSocket.OPEN) {
56
+ ws.send(data);
57
+ }
58
+ });
59
+ const handleMessage = (data) => {
60
+ if (ptyExited)
61
+ return;
62
+ const msg = typeof data === 'string' ? data : data.toString();
63
+ // Handle resize messages
64
+ try {
65
+ const parsed = JSON.parse(msg);
66
+ if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
67
+ pty.resize(parsed.cols, parsed.rows);
68
+ return;
69
+ }
70
+ }
71
+ catch {
72
+ // Not JSON, treat as terminal input
73
+ }
74
+ try {
75
+ pty.write(msg);
76
+ }
77
+ catch {
78
+ // PTY already exited
79
+ }
80
+ };
81
+ // Replace any buffering handler with the real one
82
+ ws.removeAllListeners('message');
83
+ ws.on('message', handleMessage);
84
+ // Replay any messages that arrived before the PTY was ready
85
+ if (pendingMessages) {
86
+ for (const msg of pendingMessages) {
87
+ handleMessage(msg);
88
+ }
89
+ }
90
+ const cleanupLinkedSession = () => {
91
+ if (linkedSession) {
92
+ execFile('tmux', ['kill-session', '-t', linkedSession]).catch(() => { });
93
+ }
94
+ };
95
+ // Cleanup on WebSocket close
96
+ ws.on('close', () => {
97
+ if (!ptyExited) {
98
+ pty.kill();
99
+ }
100
+ cleanupLinkedSession();
101
+ const conns = connections.get(connKey);
102
+ if (conns) {
103
+ const idx = conns.findIndex((c) => c.ws === ws);
104
+ if (idx >= 0)
105
+ conns.splice(idx, 1);
106
+ if (conns.length === 0)
107
+ connections.delete(connKey);
108
+ }
109
+ });
110
+ // Cleanup on PTY exit
111
+ pty.onExit(() => {
112
+ ptyExited = true;
113
+ cleanupLinkedSession();
114
+ if (ws.readyState === WebSocket.OPEN) {
115
+ ws.close(4006, 'Terminal process exited');
116
+ }
117
+ });
118
+ }
119
+ async function handleConnection(ws, taskId, windowIndex) {
120
+ // Buffer messages that arrive while we set up the tmux session.
121
+ // Without this, the client's initial resize message (sent on WS open) would be
122
+ // lost because the message handler isn't registered until after the async tmux
123
+ // setup completes — Node.js EventEmitter discards events with no listeners.
124
+ const pendingMessages = [];
125
+ ws.on('message', (data) => {
126
+ pendingMessages.push(data);
127
+ });
128
+ const db = getDb();
129
+ const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
130
+ if (!task || !task.tmux_session) {
131
+ ws.close(4004, 'Task not found or no tmux session');
132
+ return;
133
+ }
134
+ // Create a grouped session so each viewer has independent window selection.
135
+ // Without this, all clients attached to the same session share the active window,
136
+ // meaning switching tabs in one browser would affect all other viewers.
137
+ const linkedSession = `${task.tmux_session}-v-${nanoid(6)}`;
138
+ try {
139
+ await execFile('tmux', ['new-session', '-d', '-t', task.tmux_session, '-s', linkedSession]);
140
+ // Prevent grouped sessions from constraining window size to the smallest client
141
+ await execFile('tmux', ['set-option', '-t', linkedSession, 'aggressive-resize', 'on']).catch(() => { });
142
+ await execFile('tmux', ['select-window', '-t', `${linkedSession}:${windowIndex}`]);
143
+ }
144
+ catch {
145
+ ws.close(4005, 'Failed to create terminal view session');
146
+ return;
147
+ }
148
+ const connKey = `${taskId}:${windowIndex}`;
149
+ attachToTmuxSession(ws, linkedSession, connKey, 'Failed to attach to tmux session', linkedSession, pendingMessages);
150
+ }
151
+ function handleOrchestratorConnection(ws) {
152
+ const session = getOrchestratorSession();
153
+ attachToTmuxSession(ws, session, 'orchestrator', 'Failed to attach to orchestrator session');
154
+ }
155
+ export function getActiveConnections() {
156
+ return connections;
157
+ }
158
+ export function cleanupAllConnections() {
159
+ for (const [, conns] of connections) {
160
+ for (const { ws, pty } of conns) {
161
+ try {
162
+ pty.kill();
163
+ }
164
+ catch {
165
+ // already dead
166
+ }
167
+ if (ws.readyState === WebSocket.OPEN) {
168
+ ws.close(1001, 'Server shutting down');
169
+ }
170
+ }
171
+ }
172
+ connections.clear();
173
+ }
@@ -0,0 +1,73 @@
1
+ export type TaskStatus = 'draft' | 'setting_up' | 'running' | 'closed' | 'error';
2
+ export type AgentStatus = 'running' | 'idle' | 'waiting' | 'stopped';
3
+ export type HookActivity = 'active' | 'idle' | 'waiting';
4
+ export type DerivedTaskStatus = 'working' | 'needs_attention' | 'done';
5
+ export interface Task {
6
+ id: string;
7
+ title: string;
8
+ description: string;
9
+ repo_path: string;
10
+ status: TaskStatus;
11
+ branch: string | null;
12
+ base_branch: string | null;
13
+ worktree: string | null;
14
+ tmux_session: string | null;
15
+ pr_url: string | null;
16
+ pr_number: number | null;
17
+ user_window_index: number | null;
18
+ initial_prompt: string | null;
19
+ error: string | null;
20
+ created_at: string;
21
+ updated_at: string;
22
+ agents?: Agent[];
23
+ pending_prompts?: PermissionPrompt[];
24
+ derived_status?: DerivedTaskStatus | null;
25
+ }
26
+ export interface Agent {
27
+ id: string;
28
+ task_id: string;
29
+ window_index: number;
30
+ label: string;
31
+ status: AgentStatus;
32
+ claude_session_id: string | null;
33
+ hook_activity: HookActivity;
34
+ hook_activity_updated_at: string | null;
35
+ created_at: string;
36
+ }
37
+ export interface PermissionPrompt {
38
+ id: string;
39
+ task_id: string;
40
+ agent_id: string | null;
41
+ agent_label: string;
42
+ session_id: string;
43
+ tool_name: string;
44
+ tool_input: Record<string, unknown>;
45
+ status: 'pending' | 'resolved';
46
+ created_at: string;
47
+ resolved_at: string | null;
48
+ }
49
+ export interface CreateTaskRequest {
50
+ title: string;
51
+ description: string;
52
+ repo_path: string;
53
+ branch?: string;
54
+ base_branch?: string;
55
+ initial_prompt?: string;
56
+ draft?: boolean;
57
+ }
58
+ export interface OrchestratorStatus {
59
+ running: boolean;
60
+ session: string;
61
+ }
62
+ export interface AddAgentRequest {
63
+ prompt?: string;
64
+ }
65
+ export interface UpdateTaskRequest {
66
+ status?: 'closed' | 'running';
67
+ title?: string;
68
+ description?: string;
69
+ repo_path?: string;
70
+ branch?: string;
71
+ base_branch?: string;
72
+ initial_prompt?: string;
73
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,113 @@
1
+ {
2
+ "name": "octomux",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "octomux": "./bin/octomux.js"
7
+ },
8
+ "engines": {
9
+ "node": ">=20"
10
+ },
11
+ "files": [
12
+ "bin/",
13
+ "dist/",
14
+ "dist-server/",
15
+ "!dist-server/*.test.*",
16
+ "!dist-server/test-helpers.*",
17
+ "cli/dist/",
18
+ "cli/package.json"
19
+ ],
20
+ "scripts": {
21
+ "dev": "concurrently \"bun:dev:server\" \"bun:dev:client\"",
22
+ "dev:server": "tsx watch server/index.ts",
23
+ "dev:client": "vite",
24
+ "build": "vite build && bun run build:server && bun run cli:build",
25
+ "build:server": "tsup server/index.ts --format esm --minify --out-dir dist-server --external better-sqlite3 --external node-pty --external ws",
26
+ "start": "NODE_ENV=production node dist-server/index.js",
27
+ "test": "vitest run",
28
+ "test:watch": "vitest",
29
+ "test:e2e": "playwright test",
30
+ "test:e2e:ui": "playwright test --ui",
31
+ "lint": "eslint .",
32
+ "lint:fix": "eslint . --fix",
33
+ "format": "prettier --write .",
34
+ "format:check": "prettier --check .",
35
+ "cli:build": "cd cli && tsc",
36
+ "typecheck": "tsc --noEmit",
37
+ "prepublishOnly": "bun run build",
38
+ "postinstall": "chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper 2>/dev/null || true",
39
+ "prepare": "husky",
40
+ "changelog": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s"
41
+ },
42
+ "lint-staged": {
43
+ "*.{ts,tsx}": [
44
+ "eslint --fix",
45
+ "prettier --write"
46
+ ],
47
+ "*.{json,md,css,html}": [
48
+ "prettier --write"
49
+ ]
50
+ },
51
+ "trustedDependencies": [
52
+ "better-sqlite3",
53
+ "husky"
54
+ ],
55
+ "dependencies": {
56
+ "@base-ui/react": "^1.2.0",
57
+ "@fontsource-variable/geist": "^5.2.8",
58
+ "better-sqlite3": "^11.7.0",
59
+ "class-variance-authority": "^0.7.1",
60
+ "clsx": "^2.1.1",
61
+ "express": "^5.0.1",
62
+ "nanoid": "^5.0.9",
63
+ "node-pty": "^1.0.0",
64
+ "shadcn": "latest",
65
+ "sonner": "^2.0.7",
66
+ "tailwind-merge": "^3.5.0",
67
+ "tw-animate-css": "^1.4.0",
68
+ "ws": "^8.18.0",
69
+ "chalk": "^5.6.2",
70
+ "commander": "^14.0.3"
71
+ },
72
+ "devDependencies": {
73
+ "@commitlint/cli": "^20.0.0",
74
+ "@commitlint/config-conventional": "^20.0.0",
75
+ "@eslint/js": "^9.0.0",
76
+ "@playwright/test": "^1.58.2",
77
+ "@tailwindcss/vite": "^4.0.0",
78
+ "@testing-library/jest-dom": "^6.9.1",
79
+ "@testing-library/react": "^16.3.2",
80
+ "@testing-library/user-event": "^14.6.1",
81
+ "@types/better-sqlite3": "^7.6.0",
82
+ "@types/express": "^5.0.0",
83
+ "@types/node": "^22.0.0",
84
+ "@types/react": "^19.0.0",
85
+ "@types/react-dom": "^19.0.0",
86
+ "@types/supertest": "^7.2.0",
87
+ "@types/ws": "^8.5.0",
88
+ "@vitejs/plugin-react": "^4.3.0",
89
+ "@xterm/addon-fit": "^0.10.0",
90
+ "@xterm/addon-web-links": "^0.11.0",
91
+ "@xterm/xterm": "^5.5.0",
92
+ "concurrently": "^9.1.0",
93
+ "conventional-changelog-cli": "^5.0.0",
94
+ "eslint": "^9.0.0",
95
+ "globals": "^16.0.0",
96
+ "husky": "^9.0.0",
97
+ "jsdom": "^28.1.0",
98
+ "lint-staged": "^16.0.0",
99
+ "prettier": "^3.5.0",
100
+ "react": "^19.0.0",
101
+ "react-dom": "^19.0.0",
102
+ "react-router": "^7.0.0",
103
+ "react-router-dom": "^7.0.0",
104
+ "supertest": "^7.2.2",
105
+ "tailwindcss": "^4.0.0",
106
+ "tsup": "^8.5.1",
107
+ "tsx": "^4.19.0",
108
+ "typescript": "^5.7.0",
109
+ "typescript-eslint": "^8.0.0",
110
+ "vite": "^6.0.0",
111
+ "vitest": "^3.2.4"
112
+ }
113
+ }