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.
- package/README.md +60 -0
- package/bin/octomux.js +139 -0
- package/cli/dist/client.js +56 -0
- package/cli/dist/commands/add-agent.js +20 -0
- package/cli/dist/commands/cancel-task.js +10 -0
- package/cli/dist/commands/close-task.js +16 -0
- package/cli/dist/commands/create-task.js +35 -0
- package/cli/dist/commands/delete-task.js +16 -0
- package/cli/dist/commands/get-task.js +37 -0
- package/cli/dist/commands/list-tasks.js +31 -0
- package/cli/dist/commands/resume-task.js +18 -0
- package/cli/dist/commands/send-message.js +18 -0
- package/cli/dist/format.js +40 -0
- package/cli/dist/index.js +36 -0
- package/cli/package.json +9 -0
- package/dist/assets/TaskDetail-GGGQ2C1J.js +1 -0
- package/dist/assets/TerminalView-CguHyqU9.js +3 -0
- package/dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/dist/assets/index-Br9dLOzs.css +1 -0
- package/dist/assets/index-Bsj_BLLM.js +2 -0
- package/dist/assets/vendor-react-BZ8ItZjw.js +49 -0
- package/dist/assets/vendor-router-DRLGqALp.js +12 -0
- package/dist/assets/vendor-ui-CWZtXYLx.js +31 -0
- package/dist/assets/vendor-xterm-DYP7pi_n.css +32 -0
- package/dist/assets/vendor-xterm-DvXGiZvM.js +9 -0
- package/dist/index.html +17 -0
- package/dist/logo.png +0 -0
- package/dist-server/api.d.ts +2 -0
- package/dist-server/api.js +447 -0
- package/dist-server/app.d.ts +2 -0
- package/dist-server/app.js +13 -0
- package/dist-server/db.d.ts +7 -0
- package/dist-server/db.js +107 -0
- package/dist-server/events.d.ts +13 -0
- package/dist-server/events.js +35 -0
- package/dist-server/hook-settings.d.ts +5 -0
- package/dist-server/hook-settings.js +195 -0
- package/dist-server/hooks.d.ts +2 -0
- package/dist-server/hooks.js +118 -0
- package/dist-server/index.d.ts +5 -0
- package/dist-server/index.js +81 -0
- package/dist-server/orchestrator.d.ts +4 -0
- package/dist-server/orchestrator.js +37 -0
- package/dist-server/poller.d.ts +17 -0
- package/dist-server/poller.js +170 -0
- package/dist-server/pr-template.d.ts +7 -0
- package/dist-server/pr-template.js +29 -0
- package/dist-server/task-runner.d.ts +28 -0
- package/dist-server/task-runner.js +359 -0
- package/dist-server/terminal.d.ts +13 -0
- package/dist-server/terminal.js +173 -0
- package/dist-server/types.d.ts +73 -0
- package/dist-server/types.js +1 -0
- 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
|
+
}
|