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.
@@ -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
+ }