mob-coordinator 0.2.1
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 +207 -0
- package/bin/mob.js +20 -0
- package/dist/client/assets/index-BE5nxcXU.css +32 -0
- package/dist/client/assets/index-DkdPImCW.js +11 -0
- package/dist/client/index.html +14 -0
- package/dist/server/server/__tests__/sanitize.test.js +154 -0
- package/dist/server/server/discovery.js +36 -0
- package/dist/server/server/express-app.js +173 -0
- package/dist/server/server/index.js +54 -0
- package/dist/server/server/instance-manager.js +434 -0
- package/dist/server/server/jira-client.js +33 -0
- package/dist/server/server/pty-manager.js +98 -0
- package/dist/server/server/scrollback-buffer.js +102 -0
- package/dist/server/server/session-store.js +127 -0
- package/dist/server/server/settings-manager.js +87 -0
- package/dist/server/server/status-reader.js +29 -0
- package/dist/server/server/terminal-state-detector.js +42 -0
- package/dist/server/server/types.js +1 -0
- package/dist/server/server/util/id.js +4 -0
- package/dist/server/server/util/logger.js +34 -0
- package/dist/server/server/util/platform.js +48 -0
- package/dist/server/server/util/sanitize.js +126 -0
- package/dist/server/server/ws-server.js +197 -0
- package/dist/server/shared/constants.js +8 -0
- package/dist/server/shared/protocol.js +1 -0
- package/dist/server/shared/settings.js +54 -0
- package/package.json +68 -0
- package/scripts/postinstall.cjs +68 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
2
|
+
import { validateLaunchPayload } from './util/sanitize.js';
|
|
3
|
+
import { createLogger } from './util/logger.js';
|
|
4
|
+
const log = createLogger('ws');
|
|
5
|
+
export function createWsServer(server, instanceManager, ptyManager) {
|
|
6
|
+
const wss = new WebSocketServer({ server, path: '/mob-ws' });
|
|
7
|
+
let clientCount = 0;
|
|
8
|
+
log.info('WebSocket server created on path /mob-ws');
|
|
9
|
+
function broadcast(msg) {
|
|
10
|
+
const data = JSON.stringify(msg);
|
|
11
|
+
let sent = 0;
|
|
12
|
+
for (const client of wss.clients) {
|
|
13
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
14
|
+
client.send(data);
|
|
15
|
+
sent++;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (msg.type !== 'terminal:output') {
|
|
19
|
+
log.info(`Broadcast ${msg.type} to ${sent} client(s)`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Forward instance events to all clients
|
|
23
|
+
instanceManager.on('update', (info) => {
|
|
24
|
+
log.info(`Instance update: ${info.id} (${info.name}) state=${info.state}`);
|
|
25
|
+
broadcast({ type: 'instance:update', payload: info });
|
|
26
|
+
});
|
|
27
|
+
instanceManager.on('remove', (id) => {
|
|
28
|
+
log.info(`Instance removed: ${id}`);
|
|
29
|
+
broadcast({ type: 'instance:remove', payload: { instanceId: id } });
|
|
30
|
+
});
|
|
31
|
+
// Forward PTY data to subscribed clients
|
|
32
|
+
ptyManager.on('data', (instanceId, data) => {
|
|
33
|
+
const subs = instanceManager.subscribers.get(instanceId);
|
|
34
|
+
if (subs) {
|
|
35
|
+
const msg = JSON.stringify({
|
|
36
|
+
type: 'terminal:output',
|
|
37
|
+
payload: { instanceId, data },
|
|
38
|
+
});
|
|
39
|
+
for (const client of subs) {
|
|
40
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
41
|
+
client.send(msg);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
instanceManager.on('pty:exit', (instanceId, exitCode) => {
|
|
47
|
+
log.info(`PTY exit: ${instanceId} code=${exitCode}`);
|
|
48
|
+
const subs = instanceManager.subscribers.get(instanceId);
|
|
49
|
+
if (subs) {
|
|
50
|
+
const msg = JSON.stringify({
|
|
51
|
+
type: 'terminal:exit',
|
|
52
|
+
payload: { instanceId, exitCode },
|
|
53
|
+
});
|
|
54
|
+
for (const client of subs) {
|
|
55
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
56
|
+
client.send(msg);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
wss.on('connection', (ws, req) => {
|
|
62
|
+
clientCount++;
|
|
63
|
+
const clientId = clientCount;
|
|
64
|
+
log.info(`Client #${clientId} connected from ${req.socket.remoteAddress}`);
|
|
65
|
+
// Send current snapshot
|
|
66
|
+
const snapshot = instanceManager.getAll();
|
|
67
|
+
log.info(`Sending snapshot to #${clientId}: ${snapshot.length} instance(s)`);
|
|
68
|
+
ws.send(JSON.stringify({
|
|
69
|
+
type: 'snapshot',
|
|
70
|
+
payload: { instances: snapshot },
|
|
71
|
+
}));
|
|
72
|
+
ws.on('message', (raw) => {
|
|
73
|
+
let msg;
|
|
74
|
+
try {
|
|
75
|
+
msg = JSON.parse(raw.toString());
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
log.error(`Client #${clientId}: invalid JSON`);
|
|
79
|
+
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Invalid JSON' } }));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
log.info(`Client #${clientId} → ${msg.type}`, msg.type === 'terminal:input' ? '(input data)' : ('payload' in msg ? msg.payload : ''));
|
|
83
|
+
switch (msg.type) {
|
|
84
|
+
case 'sync':
|
|
85
|
+
ws.send(JSON.stringify({
|
|
86
|
+
type: 'snapshot',
|
|
87
|
+
payload: { instances: instanceManager.getAll() },
|
|
88
|
+
}));
|
|
89
|
+
break;
|
|
90
|
+
case 'launch': {
|
|
91
|
+
const validation = validateLaunchPayload(msg.payload);
|
|
92
|
+
if (!validation.valid) {
|
|
93
|
+
log.warn(`Client #${clientId} launch rejected: ${validation.error}`);
|
|
94
|
+
ws.send(JSON.stringify({
|
|
95
|
+
type: 'error',
|
|
96
|
+
payload: { message: validation.error },
|
|
97
|
+
}));
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
log.info(`Client #${clientId} launching: cwd=${validation.data.cwd} name=${validation.data.name || '(auto)'}`);
|
|
101
|
+
const info = instanceManager.launch(validation.data);
|
|
102
|
+
log.info(`Launched instance ${info.id} (${info.name})`);
|
|
103
|
+
// Auto-subscribe the launching client
|
|
104
|
+
const subs = instanceManager.subscribers.get(info.id);
|
|
105
|
+
if (subs)
|
|
106
|
+
subs.add(ws);
|
|
107
|
+
// Tell the launching client to select this instance
|
|
108
|
+
ws.send(JSON.stringify({
|
|
109
|
+
type: 'instance:select',
|
|
110
|
+
payload: { instanceId: info.id },
|
|
111
|
+
}));
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case 'kill':
|
|
115
|
+
log.info(`Client #${clientId} killing instance ${msg.payload.instanceId}`);
|
|
116
|
+
instanceManager.kill(msg.payload.instanceId);
|
|
117
|
+
break;
|
|
118
|
+
case 'resume': {
|
|
119
|
+
log.info(`Client #${clientId} resuming instance ${msg.payload.instanceId}`);
|
|
120
|
+
const resumed = instanceManager.resume(msg.payload.instanceId);
|
|
121
|
+
if (resumed) {
|
|
122
|
+
log.info(`Resumed as new instance ${resumed.id}`);
|
|
123
|
+
const subs = instanceManager.subscribers.get(resumed.id);
|
|
124
|
+
if (subs)
|
|
125
|
+
subs.add(ws);
|
|
126
|
+
ws.send(JSON.stringify({
|
|
127
|
+
type: 'instance:select',
|
|
128
|
+
payload: { instanceId: resumed.id },
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
ws.send(JSON.stringify({
|
|
133
|
+
type: 'error',
|
|
134
|
+
payload: { message: 'Cannot resume instance', context: msg.payload.instanceId },
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case 'dismiss':
|
|
140
|
+
log.info(`Client #${clientId} dismissing instance ${msg.payload.instanceId}`);
|
|
141
|
+
instanceManager.dismiss(msg.payload.instanceId);
|
|
142
|
+
break;
|
|
143
|
+
case 'terminal:subscribe': {
|
|
144
|
+
const { instanceId } = msg.payload;
|
|
145
|
+
log.info(`Client #${clientId} subscribing to ${instanceId}`);
|
|
146
|
+
// Only allow subscribing to existing instances
|
|
147
|
+
if (!instanceManager.get(instanceId)) {
|
|
148
|
+
log.warn(`Client #${clientId} tried to subscribe to non-existent instance ${instanceId}`);
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
let subs = instanceManager.subscribers.get(instanceId);
|
|
152
|
+
if (!subs) {
|
|
153
|
+
subs = new Set();
|
|
154
|
+
instanceManager.subscribers.set(instanceId, subs);
|
|
155
|
+
}
|
|
156
|
+
subs.add(ws);
|
|
157
|
+
// Send scrollback history
|
|
158
|
+
const scrollback = instanceManager.getScrollback(instanceId);
|
|
159
|
+
if (scrollback) {
|
|
160
|
+
ws.send(JSON.stringify({
|
|
161
|
+
type: 'terminal:scrollback',
|
|
162
|
+
payload: { instanceId, data: scrollback },
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case 'terminal:unsubscribe': {
|
|
168
|
+
const { instanceId } = msg.payload;
|
|
169
|
+
instanceManager.subscribers.get(instanceId)?.delete(ws);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
case 'terminal:input':
|
|
173
|
+
ptyManager.write(msg.payload.instanceId, msg.payload.data);
|
|
174
|
+
break;
|
|
175
|
+
case 'terminal:resize':
|
|
176
|
+
ptyManager.resize(msg.payload.instanceId, msg.payload.cols, msg.payload.rows);
|
|
177
|
+
break;
|
|
178
|
+
default:
|
|
179
|
+
log.warn(`Client #${clientId}: unknown message type`);
|
|
180
|
+
ws.send(JSON.stringify({
|
|
181
|
+
type: 'error',
|
|
182
|
+
payload: { message: `Unknown message type` },
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
ws.on('close', (code, reason) => {
|
|
187
|
+
log.info(`Client #${clientId} disconnected (code=${code})`);
|
|
188
|
+
for (const subs of instanceManager.subscribers.values()) {
|
|
189
|
+
subs.delete(ws);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
ws.on('error', (err) => {
|
|
193
|
+
log.error(`Client #${clientId} error:`, err.message);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
return wss;
|
|
197
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const DEFAULT_PORT = 4040;
|
|
2
|
+
export const STALE_THRESHOLD_MS = 30_000;
|
|
3
|
+
export const WS_RECONNECT_BASE_MS = 1000;
|
|
4
|
+
export const WS_RECONNECT_MAX_MS = 10_000;
|
|
5
|
+
export const SCROLLBACK_MAX_BYTES = 512 * 1024;
|
|
6
|
+
export const SCROLLBACK_FLUSH_MS = 2_000;
|
|
7
|
+
export const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
8
|
+
export const HOOK_SILENCE_THRESHOLD_MS = 15_000;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export const DEFAULT_SETTINGS = {
|
|
2
|
+
shortcuts: {
|
|
3
|
+
launchDialog: 'Alt+KeyN',
|
|
4
|
+
toggleSidebar: 'Alt+KeyB',
|
|
5
|
+
cycleInstanceDown: 'Alt+ArrowDown',
|
|
6
|
+
cycleInstanceUp: 'Alt+ArrowUp',
|
|
7
|
+
jumpToInstance1: 'Mod+Digit1',
|
|
8
|
+
jumpToInstance2: 'Mod+Digit2',
|
|
9
|
+
jumpToInstance3: 'Mod+Digit3',
|
|
10
|
+
jumpToInstance4: 'Mod+Digit4',
|
|
11
|
+
jumpToInstance5: 'Mod+Digit5',
|
|
12
|
+
jumpToInstance6: 'Mod+Digit6',
|
|
13
|
+
jumpToInstance7: 'Mod+Digit7',
|
|
14
|
+
jumpToInstance8: 'Mod+Digit8',
|
|
15
|
+
jumpToInstance9: 'Mod+Digit9',
|
|
16
|
+
resumeInstance: 'Alt+KeyR',
|
|
17
|
+
},
|
|
18
|
+
launch: {
|
|
19
|
+
cwd: '',
|
|
20
|
+
model: '',
|
|
21
|
+
permissionMode: '',
|
|
22
|
+
autoName: true,
|
|
23
|
+
},
|
|
24
|
+
terminal: {
|
|
25
|
+
fontSize: 14,
|
|
26
|
+
cursorStyle: 'block',
|
|
27
|
+
scrollbackLines: 5000,
|
|
28
|
+
},
|
|
29
|
+
general: {
|
|
30
|
+
sidebarCollapsed: false,
|
|
31
|
+
maxCachedTerminals: 20,
|
|
32
|
+
staleThresholdSecs: 30,
|
|
33
|
+
notifications: true,
|
|
34
|
+
},
|
|
35
|
+
jira: {
|
|
36
|
+
baseUrl: '',
|
|
37
|
+
email: '',
|
|
38
|
+
apiToken: '',
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
/** Deep merge a partial settings object with defaults, ensuring all keys exist. */
|
|
42
|
+
export function mergeWithDefaults(partial) {
|
|
43
|
+
const result = structuredClone(DEFAULT_SETTINGS);
|
|
44
|
+
for (const section of Object.keys(DEFAULT_SETTINGS)) {
|
|
45
|
+
if (partial[section] && typeof partial[section] === 'object') {
|
|
46
|
+
for (const key of Object.keys(DEFAULT_SETTINGS[section])) {
|
|
47
|
+
if (key in partial[section]) {
|
|
48
|
+
result[section][key] = partial[section][key];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mob-coordinator",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Local web dashboard for coordinating multiple Claude Code CLI sessions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/nickelbob/mob.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": ["claude", "claude-code", "ai", "terminal", "dashboard", "cli"],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"mob": "./bin/mob.js"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"dev": "concurrently \"tsx src/server/index.ts\" \"vite\"",
|
|
20
|
+
"build": "vite build && tsc -p tsconfig.node.json",
|
|
21
|
+
"start": "node dist/server/index.js",
|
|
22
|
+
"install-hooks": "tsx scripts/install-hooks.ts",
|
|
23
|
+
"uninstall-hooks": "tsx scripts/uninstall-hooks.ts",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest",
|
|
26
|
+
"lint": "eslint src/",
|
|
27
|
+
"format": "prettier --write 'src/**/*.{ts,svelte}'",
|
|
28
|
+
"format:check": "prettier --check 'src/**/*.{ts,svelte}'",
|
|
29
|
+
"setup": "node scripts/postinstall.cjs",
|
|
30
|
+
"predev": "node scripts/postinstall.cjs",
|
|
31
|
+
"prebuild": "node scripts/postinstall.cjs",
|
|
32
|
+
"postinstall": "node scripts/postinstall.cjs",
|
|
33
|
+
"prepublishOnly": "npm run build"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"bin/",
|
|
37
|
+
"dist/",
|
|
38
|
+
"scripts/postinstall.cjs",
|
|
39
|
+
"package.json",
|
|
40
|
+
"README.md"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@lydell/node-pty": "^1.0.0",
|
|
44
|
+
"chokidar": "^4.0.0",
|
|
45
|
+
"cors": "^2.8.5",
|
|
46
|
+
"express": "^4.21.0",
|
|
47
|
+
"ws": "^8.18.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@eslint/js": "^10.0.1",
|
|
51
|
+
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
|
52
|
+
"@types/express": "^5.0.0",
|
|
53
|
+
"@types/node": "^22.0.0",
|
|
54
|
+
"@types/ws": "^8.5.0",
|
|
55
|
+
"concurrently": "^9.0.0",
|
|
56
|
+
"eslint": "^10.1.0",
|
|
57
|
+
"eslint-plugin-svelte": "^3.16.0",
|
|
58
|
+
"prettier": "^3.8.1",
|
|
59
|
+
"svelte": "^5.0.0",
|
|
60
|
+
"tsx": "^4.0.0",
|
|
61
|
+
"typescript": "^5.6.0",
|
|
62
|
+
"typescript-eslint": "^8.57.2",
|
|
63
|
+
"vite": "^5.0.0",
|
|
64
|
+
"vitest": "^4.1.2",
|
|
65
|
+
"xterm": "^5.3.0",
|
|
66
|
+
"xterm-addon-fit": "^0.8.0"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Checks for missing platform-specific native dependencies and installs them.
|
|
2
|
+
// npm has a known bug (https://github.com/npm/cli/issues/4828) where optional
|
|
3
|
+
// native binaries sometimes fail to install. This script runs after `npm install`
|
|
4
|
+
// to detect and fix that.
|
|
5
|
+
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const toInstall = [];
|
|
10
|
+
|
|
11
|
+
// Check @lydell/node-pty native binary
|
|
12
|
+
const ptyPlatformPkg = `@lydell/node-pty-${process.platform}-${process.arch}`;
|
|
13
|
+
try {
|
|
14
|
+
require.resolve(`${ptyPlatformPkg}/conpty.node`);
|
|
15
|
+
} catch {
|
|
16
|
+
try {
|
|
17
|
+
// Get the version that @lydell/node-pty expects
|
|
18
|
+
const ptyPkg = require('@lydell/node-pty/package.json');
|
|
19
|
+
const version = ptyPkg.optionalDependencies?.[ptyPlatformPkg];
|
|
20
|
+
if (version) {
|
|
21
|
+
toInstall.push(`${ptyPlatformPkg}@${version}`);
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// @lydell/node-pty itself not installed yet, skip
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check rollup native binary
|
|
29
|
+
try {
|
|
30
|
+
require.resolve('rollup/dist/native.js');
|
|
31
|
+
// If it resolves, try actually loading it to see if the native module works
|
|
32
|
+
require('rollup/dist/native.js');
|
|
33
|
+
} catch (e) {
|
|
34
|
+
if (e.message && e.message.includes('rollup')) {
|
|
35
|
+
try {
|
|
36
|
+
const rollupPkg = require('rollup/package.json');
|
|
37
|
+
const optDeps = rollupPkg.optionalDependencies || {};
|
|
38
|
+
// Find the matching native package for this platform+arch
|
|
39
|
+
// On Windows, prefer msvc over gnu
|
|
40
|
+
const candidates = Object.keys(optDeps).filter(k =>
|
|
41
|
+
k.startsWith('@rollup/rollup-') &&
|
|
42
|
+
k.includes(process.platform) &&
|
|
43
|
+
k.includes(process.arch)
|
|
44
|
+
);
|
|
45
|
+
const match = candidates.find(k => k.includes('msvc')) || candidates[0];
|
|
46
|
+
if (match) {
|
|
47
|
+
toInstall.push(`${match}@${optDeps[match]}`);
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// rollup itself not installed yet, skip
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (toInstall.length > 0) {
|
|
56
|
+
console.log(`\nInstalling missing native dependencies: ${toInstall.join(', ')}`);
|
|
57
|
+
try {
|
|
58
|
+
execSync(`npm install --no-save ${toInstall.join(' ')}`, {
|
|
59
|
+
stdio: 'inherit',
|
|
60
|
+
env: { ...process.env, npm_config_ignore_scripts: 'true' },
|
|
61
|
+
});
|
|
62
|
+
console.log('Native dependencies installed successfully.\n');
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error('\nFailed to auto-install native dependencies.');
|
|
65
|
+
console.error('Try manually: npm install ' + toInstall.join(' '));
|
|
66
|
+
console.error('');
|
|
67
|
+
}
|
|
68
|
+
}
|