beads-ui 0.11.3 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beads-ui",
3
- "version": "0.11.3",
3
+ "version": "0.12.1",
4
4
  "description": "Local UI for Beads — Collaborate on issues with your coding agent.",
5
5
  "keywords": [
6
6
  "agent",
package/server/app.js CHANGED
@@ -4,7 +4,10 @@
4
4
  import express from 'express';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
- import { registerWorkspace } from './registry-watcher.js';
7
+ import {
8
+ getAvailableWorkspaces,
9
+ registerWorkspace
10
+ } from './registry-watcher.js';
8
11
 
9
12
  /**
10
13
  * Create and configure the Express application.
@@ -51,6 +54,12 @@ export function createApp(config) {
51
54
  res.status(200).json({ ok: true, registered: workspace_path });
52
55
  });
53
56
 
57
+ // List all known workspaces (file-based registry + in-memory)
58
+ app.get('/api/workspaces', (_req, res) => {
59
+ const workspaces = getAvailableWorkspaces();
60
+ res.status(200).json({ ok: true, workspaces });
61
+ });
62
+
54
63
  if (
55
64
  !fs.statSync(path.resolve(config.app_dir, 'main.bundle.js'), {
56
65
  throwIfNoEntry: false
@@ -1,6 +1,8 @@
1
1
  import { getConfig } from '../config.js';
2
2
  import { resolveWorkspaceDatabase } from '../db.js';
3
3
  import {
4
+ detectListeningPort,
5
+ findAvailablePort,
4
6
  isProcessRunning,
5
7
  printServerUrl,
6
8
  readPidFile,
@@ -8,7 +10,14 @@ import {
8
10
  startDaemon,
9
11
  terminateProcess
10
12
  } from './daemon.js';
11
- import { openUrl, registerWorkspaceWithServer, waitForServer } from './open.js';
13
+ import {
14
+ fetchWorkspacesFromServer,
15
+ openUrl,
16
+ registerWorkspaceWithServer,
17
+ waitForServer
18
+ } from './open.js';
19
+
20
+ const RESTART_SERVER_READY_MS = 400;
12
21
 
13
22
  const STARTUP_SETTLE_MS = 200;
14
23
  const REGISTER_RETRY_ATTEMPTS = 5;
@@ -45,6 +54,7 @@ export async function handleStart(options) {
45
54
  console.log('Workspace registered: %s', cwd);
46
55
  }
47
56
  console.warn('Server is already running.');
57
+ console.log('beads ui listening on %s', url);
48
58
  if (should_open) {
49
59
  await openUrl(url);
50
60
  }
@@ -55,12 +65,51 @@ export async function handleStart(options) {
55
65
  removePidFile();
56
66
  }
57
67
 
68
+ const { port: config_port, host: config_host } = getConfig();
69
+
70
+ // When the user did not pass an explicit --port, check whether the default
71
+ // port is already in use. If something is already listening, try to register
72
+ // with it first — it may be an existing bdui instance we can reuse.
73
+ // Only auto-increment to the next port if registration fails.
74
+ let effective_port = options?.port;
75
+ if (!effective_port) {
76
+ const available = await findAvailablePort(config_port, config_host);
77
+ if (available === null) {
78
+ console.error(
79
+ 'No available port found (tried %d–%d).',
80
+ config_port,
81
+ config_port + 9
82
+ );
83
+ return 1;
84
+ }
85
+ if (available !== config_port) {
86
+ // Default port is busy — try to register with whatever is there.
87
+ const existing_url = `http://${config_host}:${config_port}`;
88
+ const registered = await registerCurrentWorkspace(existing_url, cwd);
89
+ if (registered) {
90
+ console.log('Workspace registered with existing server: %s', cwd);
91
+ console.log('beads ui listening on %s', existing_url);
92
+ if (should_open) {
93
+ await openUrl(existing_url);
94
+ }
95
+ return 0;
96
+ }
97
+ // Not a bdui instance — auto-increment to the next available port.
98
+ console.log('Port %d in use, using %d instead.', config_port, available);
99
+ effective_port = available;
100
+ }
101
+ }
102
+
103
+ // Set PORT env so getConfig() returns the correct URL for registration
104
+ if (effective_port) {
105
+ process.env.PORT = String(effective_port);
106
+ }
58
107
  const { url } = getConfig();
59
108
 
60
109
  const started = startDaemon({
61
110
  is_debug: options?.is_debug,
62
111
  host: options?.host,
63
- port: options?.port
112
+ port: effective_port
64
113
  });
65
114
  if (started && started.pid > 0) {
66
115
  // Give the spawned daemon a brief moment to fail fast (for example EADDRINUSE).
@@ -77,6 +126,7 @@ export async function handleStart(options) {
77
126
  'Daemon exited early; registered workspace with existing server: %s',
78
127
  cwd
79
128
  );
129
+ console.log('beads ui listening on %s', url);
80
130
  return 0;
81
131
  }
82
132
  return 1;
@@ -179,25 +229,60 @@ export async function handleStop() {
179
229
  return 1;
180
230
  }
181
231
 
182
- /**
183
- * Handle `restart` command: stop (ignore not-running) then start.
184
- *
185
- * @returns {Promise<number>} Exit code (0 on success)
186
- */
187
232
  /**
188
233
  * Handle `restart` command: stop (ignore not-running) then start.
189
234
  * Accepts the same options as `handleStart` and passes them through,
190
235
  * so restart only opens a browser when `open` is explicitly true.
191
236
  *
192
- * @param {{ open?: boolean }} [options]
237
+ * When the user does not pass explicit `--port`, the restart detects the
238
+ * port the running daemon is listening on and reuses it.
239
+ *
240
+ * @param {{ open?: boolean, host?: string, port?: number }} [options]
193
241
  * @returns {Promise<number>}
194
242
  */
195
243
  export async function handleRestart(options) {
244
+ // Capture state from the running server before stopping it.
245
+ let detected_port = null;
246
+ /** @type {Array<{ path: string, database: string }>} */
247
+ let saved_workspaces = [];
248
+ const existing_pid = readPidFile();
249
+ if (existing_pid && isProcessRunning(existing_pid)) {
250
+ detected_port = detectListeningPort(existing_pid);
251
+
252
+ const { url } = getConfig();
253
+ saved_workspaces = await fetchWorkspacesFromServer(url);
254
+ }
255
+
196
256
  const stop_code = await handleStop();
197
257
  // 0 = stopped, 2 = not running; both are acceptable to proceed
198
258
  if (stop_code !== 0 && stop_code !== 2) {
199
259
  return 1;
200
260
  }
201
- const start_code = await handleStart(options);
202
- return start_code === 0 ? 0 : 1;
261
+
262
+ // Reuse detected port unless the user explicitly passed one.
263
+ const merged_options = { ...options };
264
+ if (!merged_options.port && detected_port) {
265
+ merged_options.port = detected_port;
266
+ }
267
+
268
+ const start_code = await handleStart(merged_options);
269
+ if (start_code !== 0) {
270
+ return 1;
271
+ }
272
+
273
+ // Re-register workspaces from the previous server.
274
+ if (saved_workspaces.length > 0) {
275
+ const { url } = getConfig();
276
+ await waitForServer(url, RESTART_SERVER_READY_MS);
277
+ for (const ws of saved_workspaces) {
278
+ if (ws.path && ws.database) {
279
+ await registerWorkspaceWithServer(url, {
280
+ path: ws.path,
281
+ database: ws.database
282
+ });
283
+ }
284
+ }
285
+ }
286
+
287
+ return 0;
203
288
  }
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * @import { SpawnOptions } from 'node:child_process'
3
3
  */
4
- import { spawn } from 'node:child_process';
4
+ import { execFileSync, spawn } from 'node:child_process';
5
5
  import fs from 'node:fs';
6
+ import net from 'node:net';
6
7
  import os from 'node:os';
7
8
  import path from 'node:path';
8
9
  import { fileURLToPath } from 'node:url';
@@ -256,6 +257,74 @@ function sleep(ms) {
256
257
  });
257
258
  }
258
259
 
260
+ /**
261
+ * Detect the TCP port a process is listening on by inspecting OS state.
262
+ * Returns the first LISTEN port found for the given PID, or null.
263
+ *
264
+ * @param {number} pid
265
+ * @returns {number | null}
266
+ */
267
+ export function detectListeningPort(pid) {
268
+ try {
269
+ const output = execFileSync(
270
+ 'lsof',
271
+ ['-iTCP', '-sTCP:LISTEN', '-a', '-p', String(pid), '-Fn', '-P'],
272
+ { encoding: 'utf8', timeout: 3000 }
273
+ );
274
+
275
+ // lsof -Fn outputs lines like "n*:3000" or "n127.0.0.1:4000"
276
+ for (const line of output.split('\n')) {
277
+ if (line.startsWith('n')) {
278
+ const colon_index = line.lastIndexOf(':');
279
+ if (colon_index >= 0) {
280
+ const port_value = Number.parseInt(line.slice(colon_index + 1), 10);
281
+ if (Number.isFinite(port_value) && port_value > 0) {
282
+ return port_value;
283
+ }
284
+ }
285
+ }
286
+ }
287
+ } catch {
288
+ // lsof not available or process gone — fall through
289
+ }
290
+ return null;
291
+ }
292
+
293
+ /**
294
+ * Check whether a TCP port is available on the given host.
295
+ *
296
+ * @param {number} port
297
+ * @param {string} host
298
+ * @returns {Promise<boolean>}
299
+ */
300
+ export function isPortAvailable(port, host) {
301
+ return new Promise((resolve) => {
302
+ const server = net.createServer();
303
+ server.once('error', () => resolve(false));
304
+ server.listen(port, host, () => {
305
+ server.close(() => resolve(true));
306
+ });
307
+ });
308
+ }
309
+
310
+ /**
311
+ * Starting from `port`, find the first available port on `host`.
312
+ * Tries up to `max_attempts` consecutive ports.
313
+ *
314
+ * @param {number} port
315
+ * @param {string} host
316
+ * @param {number} [max_attempts]
317
+ * @returns {Promise<number | null>}
318
+ */
319
+ export async function findAvailablePort(port, host, max_attempts = 10) {
320
+ for (let i = 0; i < max_attempts; i++) {
321
+ if (await isPortAvailable(port + i, host)) {
322
+ return port + i;
323
+ }
324
+ }
325
+ return null;
326
+ }
327
+
259
328
  /**
260
329
  * Print the server URL derived from current config.
261
330
  */
@@ -98,6 +98,45 @@ function sleep(ms) {
98
98
  return new Promise((resolve) => setTimeout(resolve, ms));
99
99
  }
100
100
 
101
+ /**
102
+ * Fetch the list of workspaces from the running server.
103
+ *
104
+ * @param {string} base_url - Server base URL (e.g., "http://127.0.0.1:3000")
105
+ * @returns {Promise<Array<{ path: string, database: string }>>}
106
+ */
107
+ export async function fetchWorkspacesFromServer(base_url) {
108
+ return new Promise((resolve) => {
109
+ const url = new URL('/api/workspaces', base_url);
110
+ const req = http.get(url, (res) => {
111
+ let data = '';
112
+ res.on('data', (chunk) => {
113
+ data += chunk;
114
+ });
115
+ res.on('end', () => {
116
+ try {
117
+ const parsed = JSON.parse(data);
118
+ if (parsed.ok && Array.isArray(parsed.workspaces)) {
119
+ resolve(parsed.workspaces);
120
+ } else {
121
+ resolve([]);
122
+ }
123
+ } catch {
124
+ resolve([]);
125
+ }
126
+ });
127
+ });
128
+ req.on('error', () => resolve([]));
129
+ req.setTimeout(2000, () => {
130
+ try {
131
+ req.destroy();
132
+ } catch {
133
+ void 0;
134
+ }
135
+ resolve([]);
136
+ });
137
+ });
138
+ }
139
+
101
140
  /**
102
141
  * Register a workspace with the running server.
103
142
  * Makes a POST request to /api/register-workspace.