devglide 0.1.2 → 0.1.4

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 (38) hide show
  1. package/package.json +5 -1
  2. package/src/apps/kanban/src/index.ts +1 -1
  3. package/src/apps/log/.turbo/turbo-lint.log +2 -2
  4. package/src/apps/log/src/index.ts +1 -1
  5. package/src/apps/prompts/.turbo/turbo-lint.log +3 -4
  6. package/src/apps/prompts/src/index.ts +1 -1
  7. package/src/apps/shell/.turbo/turbo-lint.log +5 -5
  8. package/src/apps/shell/src/index.ts +1 -1
  9. package/src/apps/test/.turbo/turbo-lint.log +2 -2
  10. package/src/apps/test/src/index.ts +1 -1
  11. package/src/apps/vocabulary/.turbo/turbo-lint.log +3 -4
  12. package/src/apps/vocabulary/src/index.ts +1 -1
  13. package/src/apps/voice/.turbo/turbo-lint.log +2 -2
  14. package/src/apps/voice/src/index.ts +1 -1
  15. package/src/apps/workflow/.turbo/turbo-lint.log +3 -4
  16. package/src/apps/workflow/src/index.ts +1 -1
  17. package/src/project-context.ts +36 -0
  18. package/src/public/app.js +701 -0
  19. package/src/public/favicon.svg +7 -0
  20. package/src/public/index.html +78 -0
  21. package/src/public/state.js +84 -0
  22. package/src/public/style.css +1213 -0
  23. package/src/routers/coder.ts +157 -0
  24. package/src/routers/dashboard.ts +158 -0
  25. package/src/routers/kanban.ts +38 -0
  26. package/src/routers/log.ts +42 -0
  27. package/src/routers/prompts.ts +134 -0
  28. package/src/routers/shell/index.ts +47 -0
  29. package/src/routers/shell/pty-manager.ts +107 -0
  30. package/src/routers/shell/shell-config.ts +38 -0
  31. package/src/routers/shell/shell-routes.ts +108 -0
  32. package/src/routers/shell/shell-socket.ts +321 -0
  33. package/src/routers/shell/shell-state.ts +59 -0
  34. package/src/routers/test.ts +254 -0
  35. package/src/routers/vocabulary.ts +149 -0
  36. package/src/routers/voice.ts +10 -0
  37. package/src/routers/workflow.ts +243 -0
  38. package/src/server.ts +325 -0
@@ -0,0 +1,38 @@
1
+ import fs from 'fs';
2
+ import type { ShellConfig } from '../../apps/shell/src/shell-types.js';
3
+
4
+ export type { ShellConfig };
5
+
6
+ // ── Env helpers ──────────────────────────────────────────────────────────────
7
+
8
+ const ENV_ALLOWLIST: string[] = ['HOME', 'PATH', 'USER', 'SHELL', 'LANG', 'LC_ALL', 'SSH_AUTH_SOCK'];
9
+
10
+ export function safeEnv(extra: Record<string, string> = {}): Record<string, string> {
11
+ const env: Record<string, string> = { TERM: 'xterm-256color' };
12
+ for (const key of ENV_ALLOWLIST) {
13
+ if (process.env[key] !== undefined) env[key] = process.env[key]!;
14
+ }
15
+ return { ...env, ...extra };
16
+ }
17
+
18
+ // ── Shell configs ────────────────────────────────────────────────────────────
19
+
20
+ /** Resolve the user's default shell from $SHELL, falling back to bash. */
21
+ function resolveDefaultShell(): string {
22
+ const userShell = process.env.SHELL;
23
+ if (userShell && fs.existsSync(userShell)) return userShell;
24
+ return 'bash';
25
+ }
26
+
27
+ export const SHELL_CONFIGS: Record<string, ShellConfig> = {
28
+ default: {
29
+ get command(): string { return resolveDefaultShell(); },
30
+ args: [],
31
+ env: safeEnv()
32
+ },
33
+ bash: {
34
+ command: 'bash',
35
+ args: [],
36
+ env: safeEnv()
37
+ },
38
+ };
@@ -0,0 +1,108 @@
1
+ import { Router } from 'express';
2
+ import type { Request, Response, NextFunction } from 'express';
3
+ import path from 'path';
4
+ import fs from 'fs';
5
+ import { getActiveProject } from '../../project-context.js';
6
+
7
+ // ── Preview helpers ──────────────────────────────────────────────────────────
8
+
9
+ const PREVIEW_ENTRY_POINTS: string[] = [
10
+ 'public/index.html',
11
+ 'dist/index.html',
12
+ 'index.html',
13
+ 'build/index.html',
14
+ 'src/index.html',
15
+ ];
16
+
17
+ export function detectEntryPoint(projectPath: string): { file: string; base: string } | null {
18
+ for (const entry of PREVIEW_ENTRY_POINTS) {
19
+ const full = path.join(projectPath, entry);
20
+ if (fs.existsSync(full)) return { file: entry, base: path.dirname(entry) };
21
+ }
22
+ return null;
23
+ }
24
+
25
+ // ── Proxy SSRF protection ────────────────────────────────────────────────────
26
+
27
+ const BLOCKED_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '0.0.0.0', 'metadata.google.internal']);
28
+
29
+ function isBlockedUrl(urlStr: string): string | null {
30
+ let parsed: URL;
31
+ try { parsed = new URL(urlStr); } catch { return 'Invalid URL'; }
32
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return 'Only HTTP/HTTPS allowed';
33
+ const hostname = parsed.hostname.toLowerCase();
34
+ if (BLOCKED_HOSTS.has(hostname)) return 'Blocked host';
35
+ // Block private/internal IP ranges
36
+ const parts = hostname.split('.').map(Number);
37
+ if (parts.length === 4 && parts.every((n) => !isNaN(n))) {
38
+ const [a, b] = parts;
39
+ if (a === 10) return 'Private IP blocked';
40
+ if (a === 172 && b >= 16 && b <= 31) return 'Private IP blocked';
41
+ if (a === 192 && b === 168) return 'Private IP blocked';
42
+ if (a === 169 && b === 254) return 'Link-local IP blocked';
43
+ if (a === 127) return 'Loopback IP blocked';
44
+ if (a === 0) return 'Invalid IP blocked';
45
+ }
46
+ return null;
47
+ }
48
+
49
+ // ── HTTP Router ──────────────────────────────────────────────────────────────
50
+
51
+ export const router: Router = Router();
52
+
53
+ // ── Preview route — serve static files from active project ─────────────────
54
+
55
+ router.use('/preview', (req: Request, res: Response, next: NextFunction) => {
56
+ const projectPath = getActiveProject()?.path;
57
+ if (!projectPath) return res.status(404).json({ error: 'No active project' });
58
+
59
+ const reqPath = decodeURIComponent(req.path).replace(/^\//, '') || 'index.html';
60
+ if (reqPath.includes('\0') || /\.\.[\\/]/.test(reqPath)) {
61
+ return res.status(400).json({ error: 'Invalid path' });
62
+ }
63
+
64
+ let resolved = path.resolve(projectPath, reqPath);
65
+ if (!resolved.startsWith(projectPath)) {
66
+ return res.status(403).json({ error: 'Path traversal denied' });
67
+ }
68
+
69
+ // Directory requests: try serving index.html from within
70
+ try {
71
+ if (fs.statSync(resolved).isDirectory()) {
72
+ resolved = path.join(resolved, 'index.html');
73
+ }
74
+ } catch {}
75
+
76
+ res.sendFile(resolved, (err: Error | null) => {
77
+ if (err) next();
78
+ });
79
+ });
80
+
81
+ // ── Proxy route — fetch relay for browser pane ──────────────────────────────
82
+ // Minimal fetch relay — client uses srcdoc to render HTML (bypasses X-Frame-Options).
83
+
84
+ router.get('/proxy', async (req: Request, res: Response) => {
85
+ const targetUrl = req.query.url as string | undefined;
86
+ if (!targetUrl) return res.status(400).json({ error: 'Missing url parameter' });
87
+
88
+ const blocked = isBlockedUrl(targetUrl);
89
+ if (blocked) return res.status(403).json({ error: blocked });
90
+
91
+ try {
92
+ const upstream = await fetch(targetUrl, {
93
+ headers: {
94
+ 'User-Agent': (req.headers['user-agent'] as string) || 'Mozilla/5.0',
95
+ 'Accept': 'text/html,*/*',
96
+ 'Accept-Language': (req.headers['accept-language'] as string) || 'en-US,en;q=0.9',
97
+ },
98
+ redirect: 'follow',
99
+ });
100
+
101
+ const html: string = await upstream.text();
102
+ res.setHeader('X-Final-URL', upstream.url);
103
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
104
+ res.send(html);
105
+ } catch (err: unknown) {
106
+ res.status(502).json({ error: (err as Error).message });
107
+ }
108
+ });
@@ -0,0 +1,321 @@
1
+ import type { Namespace } from 'socket.io';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import { getActiveProject } from '../../project-context.js';
5
+ import type { PaneInfo, ShellConfig } from '../../apps/shell/src/shell-types.js';
6
+ import {
7
+ globalPtys,
8
+ dashboardState,
9
+ MAX_PANES,
10
+ nextPaneId,
11
+ panesForProject,
12
+ nextNumForProject,
13
+ renumberPanes,
14
+ paneActiveSocket,
15
+ socketDimensions,
16
+ setShellNsp,
17
+ } from './shell-state.js';
18
+ import { SHELL_CONFIGS, safeEnv } from './shell-config.js';
19
+ import { spawnGlobalPty, killPty } from './pty-manager.js';
20
+ import { detectEntryPoint } from './shell-routes.js';
21
+
22
+ // ── Socket.io namespace initializer ──────────────────────────────────────────
23
+
24
+ export function initShell(nsp: Namespace): void {
25
+ setShellNsp(nsp);
26
+
27
+ nsp.on('connection', (socket) => {
28
+ console.log(`[shell:connect] ${socket.id}`);
29
+
30
+ // Send full state snapshot to every newly connected client
31
+ const scrollbacks: Record<string, string> = {};
32
+ for (const [id, entry] of globalPtys) {
33
+ scrollbacks[id] = entry.chunks.join('');
34
+ socket.join(`pane:${id}`);
35
+ }
36
+ // Also join rooms for browser panes (no PTY)
37
+ for (const p of dashboardState.panes) {
38
+ if (!globalPtys.has(p.id)) socket.join(`pane:${p.id}`);
39
+ }
40
+ socket.emit('state:snapshot', { ...dashboardState, scrollbacks, activeProject: getActiveProject() || null });
41
+
42
+ // Re-send snapshot on demand (for SPA page modules that mount after socket is already connected)
43
+ socket.on('state:request-snapshot', () => {
44
+ const sb: Record<string, string> = {};
45
+ for (const [id, entry] of globalPtys) {
46
+ sb[id] = entry.chunks.join('');
47
+ socket.join(`pane:${id}`);
48
+ }
49
+ for (const p of dashboardState.panes) {
50
+ if (!globalPtys.has(p.id)) socket.join(`pane:${p.id}`);
51
+ }
52
+ socket.emit('state:snapshot', { ...dashboardState, scrollbacks: sb, activeProject: getActiveProject() || null });
53
+ });
54
+
55
+ // ── Create browser pane ─────────────────────────────────────────────────
56
+ socket.on('browser:create', ({ url, currentTab }: { url?: string; currentTab?: string }) => {
57
+ const currentProjectId = getActiveProject()?.id || null;
58
+ if (panesForProject(currentProjectId) >= MAX_PANES) {
59
+ socket.emit('terminal:error', { message: `Maximum pane limit (${MAX_PANES}) per project reached` });
60
+ return;
61
+ }
62
+
63
+ // Auto-detect index.html in active project when no URL is provided
64
+ let resolvedUrl: string = url || '';
65
+ if (!resolvedUrl && getActiveProject()?.path) {
66
+ const entry = detectEntryPoint(getActiveProject().path);
67
+ if (entry) {
68
+ resolvedUrl = `/api/shell/preview/${entry.file}`;
69
+ }
70
+ }
71
+
72
+ const id: string = nextPaneId();
73
+ const projectId: string | null = getActiveProject()?.id || null;
74
+ const num: number = nextNumForProject(projectId);
75
+ const title: string = String(num);
76
+
77
+ const paneInfo: PaneInfo = { id, shellType: 'browser', title, num, cwd: null, url: resolvedUrl, projectId };
78
+ const switchTab: boolean = currentTab !== 'grid';
79
+
80
+ dashboardState.panes.push(paneInfo);
81
+ dashboardState.activePaneId = id;
82
+ if (switchTab) dashboardState.activeTab = id;
83
+
84
+ nsp.emit('state:pane-added', paneInfo);
85
+ if (switchTab) nsp.emit('state:active-tab', { tabId: id });
86
+ nsp.emit('state:active-pane', { paneId: id });
87
+ });
88
+
89
+ // ── Create terminal ───────────────────────────────────────────────────────
90
+ socket.on('terminal:create', ({ shellType, cwd, cols, rows, currentTab }: { shellType: string; cwd?: string; cols?: number; rows?: number; currentTab?: string }) => {
91
+ const currentProjectId = getActiveProject()?.id || null;
92
+ if (panesForProject(currentProjectId) >= MAX_PANES) {
93
+ socket.emit('terminal:error', { message: `Maximum pane limit (${MAX_PANES}) per project reached` });
94
+ return;
95
+ }
96
+
97
+ const id: string = nextPaneId();
98
+ const num: number = nextNumForProject(currentProjectId);
99
+ const title: string = String(num);
100
+ const config: ShellConfig = SHELL_CONFIGS[shellType] || SHELL_CONFIGS.default;
101
+ let args: string[] = config.args;
102
+ let startCwd: string = getActiveProject()?.path || process.env.HOME || '/';
103
+
104
+ if (cwd) {
105
+ if (!path.isAbsolute(cwd) || cwd.includes('\0') || /\.\.[\\/]/.test(cwd)) {
106
+ socket.emit('terminal:error', { message: 'Invalid CWD path: must be absolute without traversal' });
107
+ return;
108
+ }
109
+ if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
110
+ socket.emit('terminal:error', { message: 'CWD path does not exist or is not a directory' });
111
+ return;
112
+ }
113
+ startCwd = cwd;
114
+ }
115
+
116
+ try {
117
+ // Join all connected sockets to the new pane room BEFORE spawning the PTY.
118
+ // The PTY emits data on the next event-loop tick via nsp.to(`pane:${id}`),
119
+ // so sockets must already be in the room to receive the initial prompt.
120
+ nsp.socketsJoin(`pane:${id}`);
121
+
122
+ spawnGlobalPty(id, config.command, args, config.env, cols ?? 80, rows ?? 24,
123
+ true, false, startCwd);
124
+
125
+ const paneInfo: PaneInfo = { id, shellType, title, num, cwd: startCwd, projectId: getActiveProject()?.id || null };
126
+ const switchTab: boolean = currentTab !== 'grid';
127
+
128
+ paneActiveSocket.set(id, socket.id);
129
+ dashboardState.panes.push(paneInfo);
130
+ dashboardState.activePaneId = id;
131
+ if (switchTab) dashboardState.activeTab = id;
132
+
133
+ nsp.emit('state:pane-added', paneInfo);
134
+ if (switchTab) nsp.emit('state:active-tab', { tabId: id });
135
+ nsp.emit('state:active-pane', { paneId: id });
136
+ } catch (err: unknown) {
137
+ socket.emit('terminal:data', { id, data: `\r\nFailed to start ${shellType}: ${(err as Error).message}\r\n` });
138
+ socket.emit('terminal:exit', { id, code: 1 });
139
+ }
140
+ });
141
+
142
+ // ── SSH ──────────────────────────────────────────────────────────────────
143
+ socket.on('ssh:connect', ({ host, user, port, keyPath: kp, cols, rows }: { host: string; user: string; port?: number; keyPath?: string; cols?: number; rows?: number }) => {
144
+ const currentProjectId = getActiveProject()?.id || null;
145
+ if (panesForProject(currentProjectId) >= MAX_PANES) {
146
+ socket.emit('terminal:error', { message: `Maximum pane limit (${MAX_PANES}) per project reached` });
147
+ return;
148
+ }
149
+
150
+ if (typeof host !== 'string' || !host || typeof user !== 'string' || !user) {
151
+ socket.emit('terminal:error', { message: 'SSH requires valid host and user' });
152
+ return;
153
+ }
154
+ // Validate hostname (RFC 952) and username format
155
+ if (!/^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$/.test(host) || host.length > 253) {
156
+ socket.emit('terminal:error', { message: 'Invalid hostname format' });
157
+ return;
158
+ }
159
+ if (!/^[a-zA-Z_][a-zA-Z0-9_\-\.]*$/.test(user) || user.length > 64) {
160
+ socket.emit('terminal:error', { message: 'Invalid username format' });
161
+ return;
162
+ }
163
+ const sshPort: number = Number(port);
164
+ if (port !== undefined && (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65535)) {
165
+ socket.emit('terminal:error', { message: 'Invalid SSH port' });
166
+ return;
167
+ }
168
+ if (kp !== undefined && (typeof kp !== 'string' || kp.includes('..') || kp.includes('\0'))) {
169
+ socket.emit('terminal:error', { message: 'Invalid key path' });
170
+ return;
171
+ }
172
+
173
+ const id: string = nextPaneId();
174
+ const num: number = nextNumForProject(currentProjectId);
175
+ const title: string = `${num}: ${user}@${host}`;
176
+
177
+ const sshArgs: string[] = ['-tt'];
178
+ if (port) sshArgs.push('-p', String(port));
179
+ if (kp) sshArgs.push('-i', kp);
180
+ sshArgs.push(`${user}@${host}`);
181
+
182
+ try {
183
+ spawnGlobalPty(id, 'ssh', sshArgs, safeEnv(), cols ?? 80, rows ?? 24, false, false, null);
184
+
185
+ const paneInfo: PaneInfo = { id, shellType: 'ssh', title, num, cwd: null, projectId: getActiveProject()?.id || null };
186
+ paneActiveSocket.set(id, socket.id);
187
+ dashboardState.panes.push(paneInfo);
188
+ dashboardState.activePaneId = id;
189
+
190
+ nsp.emit('state:pane-added', paneInfo);
191
+ nsp.emit('state:active-pane', { paneId: id });
192
+ } catch (err: unknown) {
193
+ socket.emit('terminal:data', { id, data: `\r\nSSH failed: ${(err as Error).message}\r\n` });
194
+ socket.emit('terminal:exit', { id, code: 1 });
195
+ }
196
+ });
197
+
198
+ // ── Subscribe / unsubscribe ──────────────────────────────────────────────
199
+ socket.on('terminal:subscribe', ({ id }: { id: string }) => {
200
+ if (globalPtys.has(id) || dashboardState.panes.some((p: PaneInfo) => p.id === id)) {
201
+ socket.join(`pane:${id}`);
202
+ }
203
+ });
204
+
205
+ socket.on('terminal:unsubscribe', ({ id }: { id: string }) => {
206
+ socket.leave(`pane:${id}`);
207
+ });
208
+
209
+ // ── Input ────────────────────────────────────────────────────────────────
210
+ socket.on('terminal:input', ({ id, data }: { id: string; data: string }) => {
211
+ if (typeof data !== 'string' || data.length > 65536) return;
212
+
213
+ const entry = globalPtys.get(id);
214
+ if (!entry) return;
215
+
216
+ // Auto-join room on first interaction
217
+ socket.join(`pane:${id}`);
218
+
219
+ // If this socket wasn't the active typer, take ownership and resize PTY
220
+ // to its own dimensions first — prevents SIGWINCH corruption on the prev device
221
+ if (paneActiveSocket.get(id) !== socket.id) {
222
+ paneActiveSocket.set(id, socket.id);
223
+ const dims = socketDimensions.get(socket.id)?.get(id);
224
+ if (dims) {
225
+ try { entry.ptyProcess.resize(dims.cols, dims.rows); } catch (e: unknown) { console.warn(`[resize] ${id}:`, (e as Error).message); }
226
+ }
227
+ }
228
+
229
+ try { entry.ptyProcess.write(data); } catch (e: unknown) { console.warn(`[write] ${id}:`, (e as Error).message); }
230
+ });
231
+
232
+ // ── Resize ───────────────────────────────────────────────────────────────
233
+ socket.on('terminal:resize', ({ id, cols, rows }: { id: string; cols: number; rows: number }) => {
234
+ if (!Number.isInteger(cols) || !Number.isInteger(rows)) return;
235
+ cols = Math.max(1, Math.min(500, cols));
236
+ rows = Math.max(1, Math.min(500, rows));
237
+
238
+ // Always record this socket's current dimensions
239
+ if (!socketDimensions.has(socket.id)) socketDimensions.set(socket.id, new Map());
240
+ socketDimensions.get(socket.id)!.set(id, { cols, rows });
241
+
242
+ // Only apply to PTY if this socket is the active typer (or pane is brand new)
243
+ const active = paneActiveSocket.get(id);
244
+ if (!active || active === socket.id) {
245
+ const entry = globalPtys.get(id);
246
+ if (entry) {
247
+ try { entry.ptyProcess.resize(cols, rows); } catch (e: unknown) { console.warn(`[resize] ${id}:`, (e as Error).message); }
248
+ }
249
+ }
250
+ });
251
+
252
+ // ── Close terminal ────────────────────────────────────────────────────────
253
+ socket.on('terminal:close', ({ id }: { id: string }) => {
254
+ const entry = globalPtys.get(id);
255
+ const existed: boolean = dashboardState.panes.some((p: PaneInfo) => p.id === id);
256
+ if (!entry && !existed) return; // unknown pane — ignore
257
+
258
+ if (entry) {
259
+ killPty(entry.ptyProcess);
260
+ globalPtys.delete(id);
261
+ }
262
+
263
+ // Find index of closing pane before removal so we can select the previous one
264
+ const closedIdx: number = dashboardState.panes.findIndex((p: PaneInfo) => p.id === id);
265
+
266
+ dashboardState.panes = dashboardState.panes.filter((p: PaneInfo) => p.id !== id);
267
+ nsp.emit('state:pane-removed', { id });
268
+
269
+ // Clean up resize arbitration state for this pane
270
+ paneActiveSocket.delete(id);
271
+ for (const dims of socketDimensions.values()) dims.delete(id);
272
+
273
+ // Renumber remaining panes per-project (1-based sequential within each project)
274
+ renumberPanes();
275
+ if (dashboardState.panes.length > 0) {
276
+ nsp.emit('state:panes-renumbered', dashboardState.panes.map(({ id: pid, num }: { id: string; num: number }) => ({ id: pid, num })));
277
+ }
278
+
279
+ // Select the previous pane (or next if closing the first one)
280
+ const prevIdx: number = Math.max(0, closedIdx - 1);
281
+ const nextPane: string | null = dashboardState.panes.length > 0 ? dashboardState.panes[prevIdx].id : null;
282
+
283
+ if (dashboardState.activeTab === id) {
284
+ // The closed pane was the focused tab — navigate to previous pane or back to grid
285
+ const next: string = nextPane ?? 'grid';
286
+ dashboardState.activeTab = next;
287
+ dashboardState.activePaneId = nextPane;
288
+ nsp.emit('state:active-tab', { tabId: next });
289
+ }
290
+
291
+ // Always update active pane highlight (covers both tab view and grid/dashboard view)
292
+ dashboardState.activePaneId = nextPane;
293
+ nsp.emit('state:active-pane', { paneId: nextPane });
294
+ });
295
+
296
+ // ── Active tab / pane sync ────────────────────────────────────────────────
297
+ // Use socket.broadcast so the sender (who already applied locally) is excluded.
298
+ socket.on('state:set-active-tab', ({ tabId }: { tabId: string }) => {
299
+ if (tabId !== 'grid' && !dashboardState.panes.some((p: PaneInfo) => p.id === tabId)) return;
300
+ dashboardState.activeTab = tabId;
301
+ socket.broadcast.emit('state:active-tab', { tabId });
302
+ });
303
+
304
+ socket.on('state:set-active-pane', ({ paneId }: { paneId: string | null }) => {
305
+ if (paneId !== null && !dashboardState.panes.some((p: PaneInfo) => p.id === paneId)) return;
306
+ dashboardState.activePaneId = paneId;
307
+ socket.broadcast.emit('state:active-pane', { paneId });
308
+ });
309
+
310
+ // ── Disconnect ────────────────────────────────────────────────────────────
311
+ socket.on('disconnect', () => {
312
+ console.log(`[shell:disconnect] ${socket.id}`);
313
+ // PTY processes outlive individual socket connections.
314
+ // Release resize ownership so the next active socket can take over.
315
+ socketDimensions.delete(socket.id);
316
+ for (const [paneId, activeSocketId] of paneActiveSocket) {
317
+ if (activeSocketId === socket.id) paneActiveSocket.delete(paneId);
318
+ }
319
+ });
320
+ });
321
+ }
@@ -0,0 +1,59 @@
1
+ import type { Namespace } from 'socket.io';
2
+ import type { PtyEntry, PaneInfo, DashboardState } from '../../apps/shell/src/shell-types.js';
3
+
4
+ export type { PtyEntry, PaneInfo, DashboardState };
5
+
6
+ // ── Global shared state (survives individual socket disconnects) ────────────
7
+
8
+ export const globalPtys: Map<string, PtyEntry> = new Map();
9
+
10
+ export const dashboardState: DashboardState = {
11
+ panes: [],
12
+ activeTab: 'grid',
13
+ activePaneId: null,
14
+ };
15
+
16
+ let paneIdCounter: number = 0;
17
+ export function nextPaneId(): string { return `pane-${++paneIdCounter}`; }
18
+
19
+ export const SCROLLBACK_LIMIT: number = 200_000;
20
+ export const MAX_PANES: number = 9; // per project context
21
+
22
+ /** Count panes belonging to the given project (null = no project). */
23
+ export function panesForProject(projectId: string | null): number {
24
+ return dashboardState.panes.filter((p: PaneInfo) => p.projectId === projectId).length;
25
+ }
26
+
27
+ /** Next sequential number for a pane within its project context. */
28
+ export function nextNumForProject(projectId: string | null): number {
29
+ return panesForProject(projectId) + 1;
30
+ }
31
+
32
+ /** Renumber panes per-project (1-based sequential within each project). */
33
+ export function renumberPanes(): void {
34
+ const counters = new Map<string, number>();
35
+ for (const p of dashboardState.panes) {
36
+ const key = p.projectId || '__none__';
37
+ const next = (counters.get(key) || 0) + 1;
38
+ counters.set(key, next);
39
+ p.num = next;
40
+ p.title = String(next);
41
+ }
42
+ }
43
+
44
+ // ── Multi-client resize arbitration ─────────────────────────────────────────
45
+ // Each PTY has one "active" socket — the one that last sent input.
46
+ // Only that socket's resize events are forwarded to the PTY.
47
+ // When a different socket starts typing it takes over and immediately
48
+ // resizes the PTY to its own dimensions, preventing SIGWINCH corruption.
49
+ export const paneActiveSocket: Map<string, string> = new Map(); // paneId -> socketId
50
+ export const socketDimensions: Map<string, Map<string, { cols: number; rows: number }>> = new Map(); // socketId -> Map<paneId, {cols, rows}>
51
+
52
+ // ── Module-level namespace reference (set by initShell) ─────────────────────
53
+ let shellNsp: Namespace | null = null;
54
+
55
+ export function getShellNsp(): Namespace | null { return shellNsp; }
56
+
57
+ export function setShellNsp(nsp: Namespace): void {
58
+ shellNsp = nsp;
59
+ }