paneful 0.1.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 ADDED
@@ -0,0 +1,93 @@
1
+ # Paneful
2
+
3
+ A browser-based terminal multiplexer. Tmux-style pane management, project workspaces, and a CLI that spawns into a running instance.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm i -g paneful
9
+ ```
10
+
11
+ Or run without installing:
12
+
13
+ ```bash
14
+ npx paneful
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ### Start the server
20
+
21
+ ```bash
22
+ paneful # Start server and open browser
23
+ paneful --port 8080 # Use a specific port
24
+ ```
25
+
26
+ ### Spawn projects from anywhere
27
+
28
+ ```bash
29
+ cd ~/my-project
30
+ paneful --spawn # Adds project to running instance
31
+ ```
32
+
33
+ ### Manage projects
34
+
35
+ ```bash
36
+ paneful --list # List all projects
37
+ paneful --kill my-project # Kill a project by name
38
+ ```
39
+
40
+ ## Keyboard Shortcuts
41
+
42
+ | Shortcut | Action |
43
+ | ----------------- | ------------------------------- |
44
+ | `Cmd+N` | New pane (vertical split) |
45
+ | `Cmd+Shift+N` | New pane (horizontal split) |
46
+ | `Cmd+W` | Close focused pane |
47
+ | `Cmd+1-9` | Focus pane by index |
48
+ | `Cmd+Arrow` | Move focus to adjacent pane |
49
+ | `Cmd+Shift+Arrow` | Swap focused pane with adjacent |
50
+ | `Cmd+D` | Toggle sidebar |
51
+ | `Cmd+T` | Cycle through layout presets |
52
+ | `Cmd+R` | Auto reorganize panes |
53
+
54
+ ## Layout Presets
55
+
56
+ - **Columns** — side by side, equal widths
57
+ - **Rows** — stacked, equal heights
58
+ - **Main + Stack** — 60% left, rest stacked right
59
+ - **Main + Row** — 60% top, rest side by side bottom
60
+ - **Grid** — approximate square grid
61
+
62
+ ## Development
63
+
64
+ ```bash
65
+ npm install && cd web && pnpm install && cd ..
66
+
67
+ # Dev server (Vite frontend + Node.js backend, hot reload)
68
+ npm run dev
69
+
70
+ # Production build
71
+ npm run build
72
+
73
+ # Run locally
74
+ npm start
75
+ ```
76
+
77
+ Vite dev server proxies `/ws` and `/api` to `localhost:3000`. Open `http://localhost:5173` or use Chrome in app mode for full keyboard shortcut support:
78
+
79
+ ```bash
80
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --app=http://localhost:5173
81
+ ```
82
+
83
+ ## Architecture
84
+
85
+ - **Backend**: Node.js (Express + node-pty + ws)
86
+ - **Frontend**: React + TypeScript + xterm.js + Zustand + Tailwind CSS
87
+ - **Protocol**: JSON over a single WebSocket connection
88
+ - **Distribution**: npm package (`npx paneful`)
89
+
90
+ ## Requirements
91
+
92
+ - Node.js 18+
93
+ - macOS or Linux
@@ -0,0 +1,61 @@
1
+ import { execFile } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ export function openBrowser(port) {
4
+ const url = `http://localhost:${port}`;
5
+ console.log(`Opening browser at ${url}`);
6
+ if (tryChromeAppMode(url))
7
+ return;
8
+ // Fallback: open normally
9
+ console.log('No Chromium-based browser found for app mode, falling back to default browser');
10
+ import('open').then(({ default: open }) => {
11
+ open(url).catch((e) => {
12
+ console.warn('Failed to open browser:', e.message);
13
+ });
14
+ });
15
+ console.error("Tip: Install as a PWA (browser menu > 'Install Paneful') for full keyboard shortcut support");
16
+ }
17
+ function tryChromeAppMode(url) {
18
+ const appArg = `--app=${url}`;
19
+ if (process.platform === 'darwin') {
20
+ const browsers = [
21
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
22
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
23
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
24
+ '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
25
+ '/Applications/Arc.app/Contents/MacOS/Arc',
26
+ ];
27
+ for (const browser of browsers) {
28
+ if (fs.existsSync(browser)) {
29
+ execFile(browser, [appArg], (err) => {
30
+ if (err)
31
+ console.warn(`Failed to launch ${browser}:`, err.message);
32
+ });
33
+ console.log(`Opened in app mode via ${browser}`);
34
+ return true;
35
+ }
36
+ }
37
+ }
38
+ else if (process.platform === 'linux') {
39
+ const browsers = [
40
+ 'google-chrome',
41
+ 'google-chrome-stable',
42
+ 'chromium',
43
+ 'chromium-browser',
44
+ 'microsoft-edge',
45
+ 'brave-browser',
46
+ ];
47
+ for (const browser of browsers) {
48
+ try {
49
+ execFile(browser, [appArg], (err) => {
50
+ if (err) { /* browser not found or failed */ }
51
+ });
52
+ console.log(`Opened in app mode via ${browser}`);
53
+ return true;
54
+ }
55
+ catch {
56
+ continue;
57
+ }
58
+ }
59
+ }
60
+ return false;
61
+ }
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander';
3
+ import http from 'node:http';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import express from 'express';
8
+ import { execFile } from 'node:child_process';
9
+ import { v4 as uuidv4 } from 'uuid';
10
+ import { PtyManager } from './pty-manager.js';
11
+ import { ProjectStore } from './project-store.js';
12
+ import { WsHandler } from './ws-handler.js';
13
+ import { startIpcListener, sendIpcCommand } from './ipc.js';
14
+ import { openBrowser } from './browser.js';
15
+ // ── Paths ──
16
+ function dataDir() {
17
+ const dir = path.join(os.homedir(), '.paneful');
18
+ fs.mkdirSync(dir, { recursive: true });
19
+ return dir;
20
+ }
21
+ function lockfilePath() {
22
+ return path.join(dataDir(), 'paneful.lock');
23
+ }
24
+ function socketPath() {
25
+ return path.join(dataDir(), 'paneful.sock');
26
+ }
27
+ function readLockfile() {
28
+ const p = lockfilePath();
29
+ if (!fs.existsSync(p))
30
+ return null;
31
+ try {
32
+ const content = fs.readFileSync(p, 'utf-8');
33
+ const lines = content.trim().split('\n');
34
+ if (lines.length < 2)
35
+ return null;
36
+ const pid = parseInt(lines[0], 10);
37
+ const port = parseInt(lines[1], 10);
38
+ if (isNaN(pid) || isNaN(port))
39
+ return null;
40
+ return { pid, port };
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ function writeLockfile(pid, port) {
47
+ fs.writeFileSync(lockfilePath(), `${pid}\n${port}`);
48
+ }
49
+ function removeLockfile() {
50
+ try {
51
+ fs.unlinkSync(lockfilePath());
52
+ }
53
+ catch { /* ok */ }
54
+ try {
55
+ fs.unlinkSync(socketPath());
56
+ }
57
+ catch { /* ok */ }
58
+ }
59
+ function isProcessAlive(pid) {
60
+ try {
61
+ process.kill(pid, 0);
62
+ return true;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ // ── CLI handlers ──
69
+ async function handleSpawn() {
70
+ const cwd = process.cwd();
71
+ const name = path.basename(cwd) || 'project';
72
+ const lock = readLockfile();
73
+ if (lock && isProcessAlive(lock.pid)) {
74
+ try {
75
+ const resp = await sendIpcCommand(socketPath(), { command: 'spawn', cwd, name });
76
+ if (resp.status === 'ok') {
77
+ console.log(`Project '${name}' spawned in paneful`);
78
+ }
79
+ else {
80
+ console.error('Error:', resp.message);
81
+ process.exit(1);
82
+ }
83
+ }
84
+ catch (e) {
85
+ console.error('Failed to connect:', e.message);
86
+ process.exit(1);
87
+ }
88
+ }
89
+ else {
90
+ console.error('Paneful is not running. Start it with: paneful');
91
+ process.exit(1);
92
+ }
93
+ }
94
+ async function handleList() {
95
+ const lock = readLockfile();
96
+ if (lock && isProcessAlive(lock.pid)) {
97
+ try {
98
+ const resp = await sendIpcCommand(socketPath(), { command: 'list' });
99
+ if (resp.status === 'ok') {
100
+ const data = resp.data;
101
+ if (data && data.length > 0) {
102
+ console.log(data);
103
+ }
104
+ else {
105
+ console.log('No projects');
106
+ }
107
+ }
108
+ else {
109
+ console.error('Error:', resp.message);
110
+ process.exit(1);
111
+ }
112
+ }
113
+ catch (e) {
114
+ console.error('Failed to connect:', e.message);
115
+ process.exit(1);
116
+ }
117
+ }
118
+ else {
119
+ console.log('Paneful is not running');
120
+ }
121
+ }
122
+ async function handleKill(name) {
123
+ const lock = readLockfile();
124
+ if (lock && isProcessAlive(lock.pid)) {
125
+ try {
126
+ const resp = await sendIpcCommand(socketPath(), { command: 'kill', name });
127
+ if (resp.status === 'ok') {
128
+ console.log(`Project '${name}' killed`);
129
+ }
130
+ else {
131
+ console.error('Error:', resp.message);
132
+ process.exit(1);
133
+ }
134
+ }
135
+ catch (e) {
136
+ console.error('Failed to connect:', e.message);
137
+ process.exit(1);
138
+ }
139
+ }
140
+ else {
141
+ console.error('Paneful is not running');
142
+ process.exit(1);
143
+ }
144
+ }
145
+ // ── Server ──
146
+ function startServer(devMode, port) {
147
+ const app = express();
148
+ app.use(express.json());
149
+ const ptyManager = new PtyManager();
150
+ const projectStore = new ProjectStore(dataDir());
151
+ // API routes
152
+ app.get('/api/projects', (_req, res) => {
153
+ res.json(projectStore.list());
154
+ });
155
+ app.post('/api/projects', (req, res) => {
156
+ const { id, name = 'Unnamed', cwd = '/' } = req.body;
157
+ const projectId = id || uuidv4();
158
+ const project = { id: projectId, name, cwd, terminal_ids: [] };
159
+ projectStore.create(project);
160
+ res.status(201).json(project);
161
+ });
162
+ app.delete('/api/projects/:id', (req, res) => {
163
+ ptyManager.killProject(req.params.id);
164
+ projectStore.remove(req.params.id);
165
+ res.status(204).end();
166
+ });
167
+ app.post('/api/projects/:id/kill', (req, res) => {
168
+ const killed = ptyManager.killProject(req.params.id);
169
+ res.json({ killed: killed.length });
170
+ });
171
+ // Resolve a dropped file's full path using OS file index (Spotlight on macOS)
172
+ app.post('/api/resolve-path', (req, res) => {
173
+ const { name, size, lastModified } = req.body;
174
+ if (!name) {
175
+ res.status(400).json({ error: 'name required' });
176
+ return;
177
+ }
178
+ const findBest = (candidates) => {
179
+ if (candidates.length === 0)
180
+ return null;
181
+ if (candidates.length === 1)
182
+ return candidates[0];
183
+ // Score candidates: exact size + mtime match wins, then size-only, then most recent
184
+ let best = null;
185
+ for (const candidate of candidates) {
186
+ try {
187
+ const stat = fs.statSync(candidate);
188
+ let score = 0;
189
+ if (size && stat.size === size)
190
+ score += 10;
191
+ if (lastModified && Math.abs(stat.mtimeMs - lastModified) < 2000)
192
+ score += 5;
193
+ // Exclude node_modules and hidden dirs to prefer "real" files
194
+ if (!candidate.includes('node_modules') && !candidate.includes('/.'))
195
+ score += 1;
196
+ if (!best || score > best.score || (score === best.score && stat.mtimeMs > best.mtime)) {
197
+ best = { path: candidate, score, mtime: stat.mtimeMs };
198
+ }
199
+ }
200
+ catch { /* skip inaccessible */ }
201
+ }
202
+ return best?.path ?? candidates[0];
203
+ };
204
+ if (process.platform === 'darwin') {
205
+ execFile('mdfind', [`kMDItemFSName == '${name.replace(/'/g, "\\'")}'`], (err, stdout) => {
206
+ if (err) {
207
+ res.json({ path: null });
208
+ return;
209
+ }
210
+ const candidates = stdout.trim().split('\n').filter(Boolean);
211
+ res.json({ path: findBest(candidates) });
212
+ });
213
+ }
214
+ else {
215
+ execFile('locate', ['-l', '20', '-b', `\\${name}`], (err, stdout) => {
216
+ if (err) {
217
+ res.json({ path: null });
218
+ return;
219
+ }
220
+ const candidates = stdout.trim().split('\n').filter(Boolean);
221
+ res.json({ path: findBest(candidates) });
222
+ });
223
+ }
224
+ });
225
+ // Serve static frontend (production only)
226
+ if (!devMode) {
227
+ // In production dist: dist/server/index.js -> dist/web/ is sibling
228
+ const webDir = path.resolve(import.meta.dirname, '..', 'web');
229
+ if (fs.existsSync(webDir)) {
230
+ app.use(express.static(webDir));
231
+ // SPA fallback
232
+ app.get('*', (_req, res) => {
233
+ res.sendFile(path.join(webDir, 'index.html'));
234
+ });
235
+ }
236
+ }
237
+ const server = http.createServer(app);
238
+ // WebSocket handler
239
+ const wsHandler = new WsHandler(server, ptyManager, projectStore);
240
+ // IPC listener
241
+ const ipcServer = startIpcListener(socketPath(), ptyManager, projectStore, wsHandler);
242
+ server.listen(port, '127.0.0.1', () => {
243
+ const addr = server.address();
244
+ const actualPort = typeof addr === 'object' && addr ? addr.port : port;
245
+ writeLockfile(process.pid, actualPort);
246
+ console.log(`Paneful running on http://localhost:${actualPort}`);
247
+ if (!devMode) {
248
+ openBrowser(actualPort);
249
+ }
250
+ });
251
+ // Graceful shutdown
252
+ const shutdown = () => {
253
+ console.log('Shutting down...');
254
+ ptyManager.killAll();
255
+ removeLockfile();
256
+ ipcServer.close();
257
+ server.close();
258
+ process.exit(0);
259
+ };
260
+ process.on('SIGINT', shutdown);
261
+ process.on('SIGTERM', shutdown);
262
+ }
263
+ // ── CLI ──
264
+ program
265
+ .name('paneful')
266
+ .description('Browser-based terminal multiplexer')
267
+ .option('--spawn', 'Spawn a new project in the current directory')
268
+ .option('--list', 'List all projects')
269
+ .option('--kill <name>', 'Kill a project by name')
270
+ .option('--dev', 'Run in development mode (proxy to Vite dev server)')
271
+ .option('--port <number>', 'Port to listen on (default: random available)', parseInt)
272
+ .action(async (opts) => {
273
+ if (opts.list) {
274
+ await handleList();
275
+ return;
276
+ }
277
+ if (opts.kill) {
278
+ await handleKill(opts.kill);
279
+ return;
280
+ }
281
+ if (opts.spawn) {
282
+ await handleSpawn();
283
+ return;
284
+ }
285
+ // Default: start server (or open browser if already running)
286
+ const lock = readLockfile();
287
+ if (lock && isProcessAlive(lock.pid)) {
288
+ console.log(`Paneful already running on port ${lock.port}`);
289
+ openBrowser(lock.port);
290
+ return;
291
+ }
292
+ if (lock) {
293
+ removeLockfile();
294
+ }
295
+ startServer(opts.dev || false, opts.port || 0);
296
+ });
297
+ program.parse();
@@ -0,0 +1,96 @@
1
+ import net from 'node:net';
2
+ import fs from 'node:fs';
3
+ import { v4 as uuidv4 } from 'uuid';
4
+ import { newProject } from './project-store.js';
5
+ export function startIpcListener(socketPath, ptyManager, projectStore, wsHandler) {
6
+ // Remove stale socket file
7
+ try {
8
+ fs.unlinkSync(socketPath);
9
+ }
10
+ catch { /* doesn't exist */ }
11
+ const server = net.createServer((conn) => {
12
+ let buffer = '';
13
+ conn.on('data', (chunk) => {
14
+ buffer += chunk.toString();
15
+ const newlineIdx = buffer.indexOf('\n');
16
+ if (newlineIdx === -1)
17
+ return;
18
+ const line = buffer.slice(0, newlineIdx).trim();
19
+ buffer = buffer.slice(newlineIdx + 1);
20
+ let request;
21
+ try {
22
+ request = JSON.parse(line);
23
+ }
24
+ catch {
25
+ const resp = { status: 'error', message: 'Invalid request' };
26
+ conn.write(JSON.stringify(resp) + '\n');
27
+ conn.end();
28
+ return;
29
+ }
30
+ const response = handleIpcRequest(request, ptyManager, projectStore, wsHandler);
31
+ conn.write(JSON.stringify(response) + '\n');
32
+ conn.end();
33
+ });
34
+ conn.on('error', () => { });
35
+ });
36
+ server.listen(socketPath, () => {
37
+ console.log(`IPC listener started at ${socketPath}`);
38
+ });
39
+ return server;
40
+ }
41
+ function handleIpcRequest(request, ptyManager, projectStore, wsHandler) {
42
+ switch (request.command) {
43
+ case 'spawn': {
44
+ const id = uuidv4();
45
+ const project = newProject(id, request.name, request.cwd);
46
+ projectStore.create(project);
47
+ // Notify the frontend
48
+ wsHandler.send({
49
+ type: 'project:spawned',
50
+ projectId: id,
51
+ name: request.name,
52
+ cwd: request.cwd,
53
+ });
54
+ return { status: 'ok' };
55
+ }
56
+ case 'list': {
57
+ const projects = projectStore.list();
58
+ const lines = projects.map((p) => `${p.name} (${p.cwd}) - ${p.terminal_ids.length} terminals`);
59
+ return { status: 'ok', data: lines.join('\n') };
60
+ }
61
+ case 'kill': {
62
+ const project = projectStore.findByName(request.name);
63
+ if (project) {
64
+ ptyManager.killProject(project.id);
65
+ projectStore.remove(project.id);
66
+ return { status: 'ok' };
67
+ }
68
+ return { status: 'error', message: `Project '${request.name}' not found` };
69
+ }
70
+ }
71
+ }
72
+ export async function sendIpcCommand(socketPath, request) {
73
+ return new Promise((resolve, reject) => {
74
+ const client = net.createConnection(socketPath, () => {
75
+ client.write(JSON.stringify(request) + '\n');
76
+ });
77
+ let buffer = '';
78
+ client.on('data', (chunk) => {
79
+ buffer += chunk.toString();
80
+ const newlineIdx = buffer.indexOf('\n');
81
+ if (newlineIdx !== -1) {
82
+ const line = buffer.slice(0, newlineIdx).trim();
83
+ try {
84
+ resolve(JSON.parse(line));
85
+ }
86
+ catch {
87
+ reject(new Error('Invalid IPC response'));
88
+ }
89
+ client.end();
90
+ }
91
+ });
92
+ client.on('error', (err) => {
93
+ reject(new Error(`Failed to connect to paneful: ${err.message}`));
94
+ });
95
+ });
96
+ }
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export class ProjectStore {
4
+ projects;
5
+ filePath;
6
+ constructor(dataDir) {
7
+ this.filePath = path.join(dataDir, 'projects.json');
8
+ this.projects = new Map();
9
+ if (fs.existsSync(this.filePath)) {
10
+ try {
11
+ const contents = fs.readFileSync(this.filePath, 'utf-8');
12
+ const list = JSON.parse(contents);
13
+ for (const p of list) {
14
+ this.projects.set(p.id, p);
15
+ }
16
+ }
17
+ catch {
18
+ // Corrupted file, start fresh
19
+ }
20
+ }
21
+ }
22
+ create(project) {
23
+ this.projects.set(project.id, project);
24
+ this.persist();
25
+ }
26
+ remove(projectId) {
27
+ const project = this.projects.get(projectId);
28
+ if (project) {
29
+ this.projects.delete(projectId);
30
+ this.persist();
31
+ }
32
+ return project;
33
+ }
34
+ get(projectId) {
35
+ return this.projects.get(projectId);
36
+ }
37
+ list() {
38
+ return Array.from(this.projects.values());
39
+ }
40
+ findByName(name) {
41
+ for (const p of this.projects.values()) {
42
+ if (p.name === name)
43
+ return p;
44
+ }
45
+ return undefined;
46
+ }
47
+ addTerminal(projectId, terminalId) {
48
+ const project = this.projects.get(projectId);
49
+ if (project && !project.terminal_ids.includes(terminalId)) {
50
+ project.terminal_ids.push(terminalId);
51
+ this.persist();
52
+ }
53
+ }
54
+ removeTerminal(projectId, terminalId) {
55
+ const project = this.projects.get(projectId);
56
+ if (project) {
57
+ project.terminal_ids = project.terminal_ids.filter(id => id !== terminalId);
58
+ this.persist();
59
+ }
60
+ }
61
+ getTerminalIds(projectId) {
62
+ return this.projects.get(projectId)?.terminal_ids ?? [];
63
+ }
64
+ persist() {
65
+ const list = Array.from(this.projects.values());
66
+ try {
67
+ const dir = path.dirname(this.filePath);
68
+ if (!fs.existsSync(dir)) {
69
+ fs.mkdirSync(dir, { recursive: true });
70
+ }
71
+ fs.writeFileSync(this.filePath, JSON.stringify(list, null, 2));
72
+ }
73
+ catch {
74
+ console.error('Failed to persist projects');
75
+ }
76
+ }
77
+ }
78
+ export function newProject(id, name, cwd) {
79
+ return { id, name, cwd, terminal_ids: [] };
80
+ }
@@ -0,0 +1,75 @@
1
+ import * as pty from 'node-pty';
2
+ import os from 'node:os';
3
+ export class PtyManager {
4
+ sessions = new Map();
5
+ spawn(terminalId, projectId, cwd, onOutput, onExit) {
6
+ const shell = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : '/bin/bash');
7
+ // Filter out undefined values from process.env before spreading
8
+ const env = {};
9
+ for (const [k, v] of Object.entries(process.env)) {
10
+ if (v !== undefined)
11
+ env[k] = v;
12
+ }
13
+ env.TERM = 'xterm-256color';
14
+ env.LANG = 'en_US.UTF-8';
15
+ env.LC_ALL = 'en_US.UTF-8';
16
+ const proc = pty.spawn(shell, [], {
17
+ name: 'xterm-256color',
18
+ cols: 80,
19
+ rows: 24,
20
+ cwd,
21
+ env,
22
+ });
23
+ proc.onData((data) => {
24
+ onOutput(terminalId, data);
25
+ });
26
+ proc.onExit(({ exitCode }) => {
27
+ this.sessions.delete(terminalId);
28
+ onExit(terminalId, exitCode);
29
+ });
30
+ this.sessions.set(terminalId, { process: proc, projectId });
31
+ }
32
+ write(terminalId, data) {
33
+ const managed = this.sessions.get(terminalId);
34
+ if (managed) {
35
+ managed.process.write(data);
36
+ }
37
+ }
38
+ resize(terminalId, cols, rows) {
39
+ const managed = this.sessions.get(terminalId);
40
+ if (managed) {
41
+ managed.process.resize(cols, rows);
42
+ }
43
+ }
44
+ kill(terminalId) {
45
+ const managed = this.sessions.get(terminalId);
46
+ if (managed) {
47
+ managed.process.kill();
48
+ this.sessions.delete(terminalId);
49
+ return managed.projectId;
50
+ }
51
+ return undefined;
52
+ }
53
+ killProject(projectId) {
54
+ const killed = [];
55
+ for (const [id, managed] of this.sessions) {
56
+ if (managed.projectId === projectId) {
57
+ managed.process.kill();
58
+ killed.push(id);
59
+ }
60
+ }
61
+ for (const id of killed) {
62
+ this.sessions.delete(id);
63
+ }
64
+ return killed;
65
+ }
66
+ killAll() {
67
+ for (const [, managed] of this.sessions) {
68
+ managed.process.kill();
69
+ }
70
+ this.sessions.clear();
71
+ }
72
+ terminalExists(terminalId) {
73
+ return this.sessions.has(terminalId);
74
+ }
75
+ }