beads-ui 0.9.2 → 0.10.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/CHANGES.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changes
2
2
 
3
+ ## 0.10.0
4
+
5
+ - [`998f256`](https://github.com/mantoni/beads-ui/commit/998f2562b3ad3203c9dd1f627d44b1c2d5ef03a4)
6
+ Do not wrap issue IDs
7
+ - [`e3c3345`](https://github.com/mantoni/beads-ui/commit/e3c3345db41cd874db8e33ec79c904cc314e6bf8)
8
+ Improve workspace resolution and fallback db
9
+ - [`6de4652`](https://github.com/mantoni/beads-ui/commit/6de4652c336f77c8d8ec9cc13f5a47e9ba1b3857)
10
+ Avoid concurrent DB access to work around dolt panic
11
+ - [`011fe9e`](https://github.com/mantoni/beads-ui/commit/011fe9e3dfaa475f744b69ff6b44c3cc23283ad1)
12
+ Support dolt backend
13
+ - [`63ed3c3`](https://github.com/mantoni/beads-ui/commit/63ed3c3f3f98aa2c6d621537887d98701289dac6)
14
+ Update beads
15
+ - [`cd0a4c5`](https://github.com/mantoni/beads-ui/commit/cd0a4c59fcfe2c9a655ed2079a2a059a242906c5)
16
+ docs: highlight multi-workspace feature in README (#47) (Pablo LION)
17
+
18
+ _Released by [Maximilian Antoni](https://github.com/mantoni) on 2026-02-25._
19
+
20
+ ## 0.9.3
21
+
22
+ - [`2e04bc1`](https://github.com/mantoni/beads-ui/commit/2e04bc1eeb5c43e6934d858cd017d80f745a38bb)
23
+ Add -v/—version flag to CLI (#46) (Brent Traut)
24
+
25
+ _Released by [Maximilian Antoni](https://github.com/mantoni) on 2026-01-23._
26
+
3
27
  ## 0.9.2
4
28
 
5
29
  - [`ffa376c`](https://github.com/mantoni/beads-ui/commit/ffa376cab432b0e321232e8bc0de2caca20a6b17)
package/README.md CHANGED
@@ -22,6 +22,8 @@
22
22
  - ⛰️ **Epics view** – Show progress per epic, expand rows, edit inline
23
23
  - 🏂 **Board view** – Blocked / Ready / In progress / Closed columns
24
24
  - ⌨️ **Keyboard navigation** – Navigate and edit without touching the mouse
25
+ - 🔀 **Multi-workspace** – Switch between projects via dropdown, auto-registers
26
+ workspaces
25
27
 
26
28
  ## Setup
27
29
 
package/app/styles.css CHANGED
@@ -523,6 +523,7 @@ button.id-copy {
523
523
  margin: 0;
524
524
  line-height: inherit;
525
525
  font: inherit;
526
+ white-space: nowrap;
526
527
  }
527
528
  button.id-copy:hover {
528
529
  text-decoration: underline;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beads-ui",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Local UI for Beads — Collaborate on issues with your coding agent.",
5
5
  "keywords": [
6
6
  "agent",
package/server/bd.js CHANGED
@@ -3,6 +3,8 @@ import { resolveDbPath } from './db.js';
3
3
  import { debug } from './logging.js';
4
4
 
5
5
  const log = debug('bd');
6
+ /** @type {Promise<void>} */
7
+ let bd_run_queue = Promise.resolve();
6
8
 
7
9
  /**
8
10
  * Get the git user name from git config.
@@ -59,17 +61,30 @@ export function getBdBin() {
59
61
  * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
60
62
  */
61
63
  export function runBd(args, options = {}) {
64
+ return withBdRunQueue(async () => runBdUnlocked(args, options));
65
+ }
66
+
67
+ /**
68
+ * Run the `bd` CLI with provided arguments without queueing.
69
+ *
70
+ * @param {string[]} args
71
+ * @param {{ cwd?: string, env?: Record<string, string | undefined>, timeout_ms?: number }} [options]
72
+ * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
73
+ */
74
+ function runBdUnlocked(args, options = {}) {
62
75
  const bin = getBdBin();
63
76
 
64
- // Ensure a consistent DB by setting BEADS_DB environment variable
77
+ // Set BEADS_DB only when the workspace has a local SQLite DB.
78
+ // Do not force BEADS_DB from global fallback paths; this can override
79
+ // backend autodetection in non-SQLite workspaces (for example Dolt).
65
80
  const db_path = resolveDbPath({
66
81
  cwd: options.cwd || process.cwd(),
67
82
  env: options.env || process.env
68
83
  });
69
- const env_with_db = {
70
- ...(options.env || process.env),
71
- BEADS_DB: db_path.path
72
- };
84
+ const env_with_db = { ...(options.env || process.env) };
85
+ if (db_path.source === 'nearest' && db_path.exists) {
86
+ env_with_db.BEADS_DB = db_path.path;
87
+ }
73
88
 
74
89
  const spawn_opts = {
75
90
  cwd: options.cwd || process.cwd(),
@@ -136,6 +151,31 @@ export function runBd(args, options = {}) {
136
151
  });
137
152
  }
138
153
 
154
+ /**
155
+ * Serialize `bd` invocations.
156
+ * Dolt embedded mode can crash when multiple `bd` processes run concurrently
157
+ * against the same workspace.
158
+ *
159
+ * @template T
160
+ * @param {() => Promise<T>} operation
161
+ * @returns {Promise<T>}
162
+ */
163
+ async function withBdRunQueue(operation) {
164
+ const previous = bd_run_queue;
165
+ /** @type {() => void} */
166
+ let release = () => {};
167
+ bd_run_queue = new Promise((resolve) => {
168
+ release = resolve;
169
+ });
170
+
171
+ await previous.catch(() => {});
172
+ try {
173
+ return await operation();
174
+ } finally {
175
+ release();
176
+ }
177
+ }
178
+
139
179
  /**
140
180
  * Run `bd` and parse JSON from stdout if exit code is 0.
141
181
  *
@@ -1,5 +1,5 @@
1
1
  import { getConfig } from '../config.js';
2
- import { resolveDbPath } from '../db.js';
2
+ import { resolveWorkspaceDatabase } from '../db.js';
3
3
  import {
4
4
  isProcessRunning,
5
5
  printServerUrl,
@@ -25,12 +25,15 @@ export async function handleStart(options) {
25
25
  if (existing_pid && isProcessRunning(existing_pid)) {
26
26
  // Server is already running - register this workspace dynamically
27
27
  const cwd = process.cwd();
28
- const db_info = resolveDbPath({ cwd });
29
- if (db_info.exists) {
28
+ const workspace_database = resolveWorkspaceDatabase({ cwd });
29
+ if (
30
+ workspace_database.source !== 'home-default' &&
31
+ workspace_database.exists
32
+ ) {
30
33
  const { url } = getConfig();
31
34
  const registered = await registerWorkspaceWithServer(url, {
32
35
  path: cwd,
33
- database: db_info.path
36
+ database: workspace_database.path
34
37
  });
35
38
  if (registered) {
36
39
  console.log('Workspace registered: %s', cwd);
@@ -7,7 +7,7 @@ import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { getConfig } from '../config.js';
10
- import { resolveDbPath } from '../db.js';
10
+ import { resolveWorkspaceDatabase } from '../db.js';
11
11
 
12
12
  /**
13
13
  * Resolve the runtime directory used for PID and log files.
@@ -171,6 +171,7 @@ export function startDaemon(options = {}) {
171
171
 
172
172
  /** @type {SpawnOptions} */
173
173
  const opts = {
174
+ cwd: process.cwd(),
174
175
  detached: true,
175
176
  env: spawn_env,
176
177
  stdio: log_fd >= 0 ? ['ignore', log_fd, log_fd] : 'ignore',
@@ -260,7 +261,7 @@ function sleep(ms) {
260
261
  */
261
262
  export function printServerUrl() {
262
263
  // Resolve from the caller's working directory by default
263
- const resolved_db = resolveDbPath();
264
+ const resolved_db = resolveWorkspaceDatabase();
264
265
  console.log(
265
266
  `beads db ${resolved_db.path} (${resolved_db.source}${resolved_db.exists ? '' : ', missing'})`
266
267
  );
@@ -1,3 +1,4 @@
1
+ import { readFile } from 'node:fs/promises';
1
2
  import { enableAllDebug } from '../logging.js';
2
3
  import { handleRestart, handleStart, handleStop } from './commands.js';
3
4
  import { printUsage } from './usage.js';
@@ -30,6 +31,10 @@ export function parseArgs(args) {
30
31
  flags.push('open');
31
32
  continue;
32
33
  }
34
+ if (token === '--version' || token === '-v') {
35
+ flags.push('version');
36
+ continue;
37
+ }
33
38
  if (token === '--host' && i + 1 < args.length) {
34
39
  options.host = args[++i];
35
40
  continue;
@@ -54,6 +59,22 @@ export function parseArgs(args) {
54
59
  return { command, flags, options };
55
60
  }
56
61
 
62
+ /**
63
+ * Load the package.json version string.
64
+ *
65
+ * @returns {Promise<string>}
66
+ */
67
+ async function loadVersion() {
68
+ const package_url = new URL('../../package.json', import.meta.url);
69
+ const package_text = await readFile(package_url, 'utf8');
70
+ const package_data = JSON.parse(package_text);
71
+ const version = package_data.version;
72
+ if (typeof version !== 'string') {
73
+ throw new Error('Invalid package.json version');
74
+ }
75
+ return version;
76
+ }
77
+
57
78
  /**
58
79
  * CLI main entry. Returns an exit code and prints usage on `--help` or errors.
59
80
  * No side effects beyond invoking stub handlers.
@@ -69,6 +90,11 @@ export async function main(args) {
69
90
  enableAllDebug();
70
91
  }
71
92
 
93
+ if (flags.includes('version')) {
94
+ const version = await loadVersion();
95
+ process.stdout.write(`${version}\n`);
96
+ return 0;
97
+ }
72
98
  if (flags.includes('help')) {
73
99
  printUsage(process.stdout);
74
100
  return 0;
@@ -14,6 +14,7 @@ export function printUsage(out_stream) {
14
14
  '',
15
15
  'Options:',
16
16
  ' -h, --help Show this help message',
17
+ ' -v, --version Show the CLI version',
17
18
  ' -d, --debug Enable debug logging',
18
19
  ' --open Open the browser after start/restart',
19
20
  ' --host <addr> Bind to a specific host (default: 127.0.0.1)',
package/server/db.js CHANGED
@@ -6,7 +6,8 @@ import path from 'node:path';
6
6
  * Resolve the SQLite DB path used by beads according to precedence:
7
7
  * 1) explicit --db flag (provided via options.explicit_db)
8
8
  * 2) BEADS_DB environment variable
9
- * 3) nearest ".beads/*.db" by walking up from cwd
9
+ * 3) nearest ".beads/*.db" by walking up from cwd (excluding
10
+ * "~/.beads/default.db", which is reserved for fallback)
10
11
  * 4) "~/.beads/default.db" fallback
11
12
  *
12
13
  * Returns a normalized absolute path and a `source` indicator. Existence is
@@ -18,6 +19,7 @@ import path from 'node:path';
18
19
  export function resolveDbPath(options = {}) {
19
20
  const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
20
21
  const env = options.env || process.env;
22
+ const home_default = path.join(os.homedir(), '.beads', 'default.db');
21
23
 
22
24
  // 1) explicit flag
23
25
  if (options.explicit_db && options.explicit_db.length > 0) {
@@ -33,12 +35,11 @@ export function resolveDbPath(options = {}) {
33
35
 
34
36
  // 3) nearest .beads/*.db walking up
35
37
  const nearest = findNearestBeadsDb(cwd);
36
- if (nearest) {
38
+ if (nearest && path.normalize(nearest) !== path.normalize(home_default)) {
37
39
  return { path: nearest, source: 'nearest', exists: fileExists(nearest) };
38
40
  }
39
41
 
40
42
  // 4) ~/.beads/default.db
41
- const home_default = path.join(os.homedir(), '.beads', 'default.db');
42
43
  return {
43
44
  path: home_default,
44
45
  source: 'home-default',
@@ -46,6 +47,57 @@ export function resolveDbPath(options = {}) {
46
47
  };
47
48
  }
48
49
 
50
+ /**
51
+ * Resolve the workspace database location used by the UI/server.
52
+ *
53
+ * For non-SQLite backends (for example Dolt), this returns the nearest
54
+ * workspace `.beads` directory when metadata exists. This avoids collapsing
55
+ * all such workspaces onto the `~/.beads/default.db` fallback.
56
+ *
57
+ * @param {{ cwd?: string, env?: Record<string, string | undefined>, explicit_db?: string }} [options]
58
+ * @returns {{ path: string, source: 'flag'|'env'|'nearest'|'metadata'|'home-default', exists: boolean }}
59
+ */
60
+ export function resolveWorkspaceDatabase(options = {}) {
61
+ const sqlite_db = resolveDbPath(options);
62
+ if (sqlite_db.source !== 'home-default') {
63
+ return sqlite_db;
64
+ }
65
+
66
+ const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
67
+ const metadata_path = findNearestBeadsMetadata(cwd);
68
+ if (metadata_path) {
69
+ return {
70
+ path: path.dirname(metadata_path),
71
+ source: 'metadata',
72
+ exists: true
73
+ };
74
+ }
75
+
76
+ return sqlite_db;
77
+ }
78
+
79
+ /**
80
+ * Find nearest `.beads/metadata.json` by walking up from start.
81
+ *
82
+ * @param {string} start
83
+ * @returns {string | null}
84
+ */
85
+ export function findNearestBeadsMetadata(start) {
86
+ let dir = path.resolve(start);
87
+ for (let i = 0; i < 100; i++) {
88
+ const metadata_path = path.join(dir, '.beads', 'metadata.json');
89
+ if (fileExists(metadata_path)) {
90
+ return metadata_path;
91
+ }
92
+ const parent = path.dirname(dir);
93
+ if (parent === dir) {
94
+ break;
95
+ }
96
+ dir = parent;
97
+ }
98
+ return null;
99
+ }
100
+
49
101
  /**
50
102
  * Find nearest .beads/*.db by walking up from start.
51
103
  * First alphabetical .db.
package/server/index.js CHANGED
@@ -2,7 +2,7 @@ import { createServer } from 'node:http';
2
2
  import { createApp } from './app.js';
3
3
  import { printServerUrl } from './cli/daemon.js';
4
4
  import { getConfig } from './config.js';
5
- import { resolveDbPath } from './db.js';
5
+ import { resolveWorkspaceDatabase } from './db.js';
6
6
  import { debug, enableAllDebug } from './logging.js';
7
7
  import { registerWorkspace, watchRegistry } from './registry-watcher.js';
8
8
  import { watchDb } from './watcher.js';
@@ -29,9 +29,12 @@ const log = debug('server');
29
29
 
30
30
  // Register the initial workspace (from cwd) so it appears in the workspace picker
31
31
  // even without the beads daemon running
32
- const db_info = resolveDbPath({ cwd: config.root_dir });
33
- if (db_info.exists) {
34
- registerWorkspace({ path: config.root_dir, database: db_info.path });
32
+ const workspace_database = resolveWorkspaceDatabase({ cwd: config.root_dir });
33
+ if (workspace_database.source !== 'home-default' && workspace_database.exists) {
34
+ registerWorkspace({
35
+ path: config.root_dir,
36
+ database: workspace_database.path
37
+ });
35
38
  }
36
39
 
37
40
  // Watch the active beads DB and schedule subscription refresh for active lists
@@ -29,7 +29,7 @@ export function mapSubscriptionToBdArgs(spec) {
29
29
  return ['list', '--json', '--status', 'in_progress'];
30
30
  }
31
31
  case 'closed-issues': {
32
- return ['list', '--json', '--status', 'closed'];
32
+ return ['list', '--json', '--status', 'closed', '--limit', '1000'];
33
33
  }
34
34
  case 'issue-detail': {
35
35
  const p = spec.params || {};
package/server/watcher.js CHANGED
@@ -1,11 +1,15 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { resolveDbPath } from './db.js';
3
+ import { resolveWorkspaceDatabase } from './db.js';
4
4
  import { debug } from './logging.js';
5
5
 
6
6
  /**
7
- * Watch the resolved beads SQLite DB file and invoke a callback after a debounce window.
8
- * The DB path is resolved following beads precedence and can be overridden via options.
7
+ * Watch the resolved workspace database target and invoke a callback after a
8
+ * debounce window.
9
+ *
10
+ * For SQLite workspaces this watches the DB file's parent directory and filters
11
+ * by file name. For non-SQLite backends (for example Dolt), this watches the
12
+ * workspace `.beads` directory.
9
13
  *
10
14
  * @param {string} root_dir - Project root directory (starting point for resolution).
11
15
  * @param {() => void} onChange - Called when changes are detected.
@@ -47,13 +51,18 @@ export function watchDb(root_dir, onChange, options = {}) {
47
51
  * @param {string | undefined} explicit_db
48
52
  */
49
53
  const bind = (base_dir, explicit_db) => {
50
- const resolved = resolveDbPath({ cwd: base_dir, explicit_db });
54
+ const resolved = resolveWorkspaceDatabase({ cwd: base_dir, explicit_db });
51
55
  current_path = resolved.path;
52
- current_dir = path.dirname(current_path);
53
- current_file = path.basename(current_path);
56
+ if (pathIsDirectory(current_path)) {
57
+ current_dir = current_path;
58
+ current_file = '';
59
+ } else {
60
+ current_dir = path.dirname(current_path);
61
+ current_file = path.basename(current_path);
62
+ }
54
63
  if (!resolved.exists) {
55
64
  log(
56
- 'resolved DB missing: %s – Hint: set --db, export BEADS_DB, or run `bd init` in your workspace.',
65
+ 'resolved workspace database missing: %s – Hint: set --db, export BEADS_DB, or run `bd init` in your workspace.',
57
66
  current_path
58
67
  );
59
68
  }
@@ -64,7 +73,7 @@ export function watchDb(root_dir, onChange, options = {}) {
64
73
  current_dir,
65
74
  { persistent: true },
66
75
  (event_type, filename) => {
67
- if (filename && String(filename) !== current_file) {
76
+ if (current_file && filename && String(filename) !== current_file) {
68
77
  return;
69
78
  }
70
79
  if (event_type === 'change' || event_type === 'rename') {
@@ -103,7 +112,7 @@ export function watchDb(root_dir, onChange, options = {}) {
103
112
  rebind(opts = {}) {
104
113
  const next_root = opts.root_dir ? String(opts.root_dir) : root_dir;
105
114
  const next_explicit = opts.explicit_db ?? options.explicit_db;
106
- const next_resolved = resolveDbPath({
115
+ const next_resolved = resolveWorkspaceDatabase({
107
116
  cwd: next_root,
108
117
  explicit_db: next_explicit
109
118
  });
@@ -117,3 +126,14 @@ export function watchDb(root_dir, onChange, options = {}) {
117
126
  }
118
127
  };
119
128
  }
129
+
130
+ /**
131
+ * @param {string} file_path
132
+ */
133
+ function pathIsDirectory(file_path) {
134
+ try {
135
+ return fs.statSync(file_path).isDirectory();
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
package/server/ws.js CHANGED
@@ -7,7 +7,7 @@ import path from 'node:path';
7
7
  import { WebSocketServer } from 'ws';
8
8
  import { isRequest, makeError, makeOk } from '../app/protocol.js';
9
9
  import { getGitUserName, runBd, runBdJson } from './bd.js';
10
- import { resolveDbPath } from './db.js';
10
+ import { resolveWorkspaceDatabase } from './db.js';
11
11
  import { fetchListForSubscription } from './list-adapters.js';
12
12
  import { debug } from './logging.js';
13
13
  import { getAvailableWorkspaces } from './registry-watcher.js';
@@ -427,7 +427,7 @@ export function attachWsServer(http_server, options = {}) {
427
427
 
428
428
  // Initialize workspace state
429
429
  const initial_root = options.root_dir || process.cwd();
430
- const initial_db = resolveDbPath({ cwd: initial_root });
430
+ const initial_db = resolveWorkspaceDatabase({ cwd: initial_root });
431
431
  CURRENT_WORKSPACE = {
432
432
  root_dir: initial_root,
433
433
  db_path: initial_db.path
@@ -521,7 +521,7 @@ export function attachWsServer(http_server, options = {}) {
521
521
  */
522
522
  function setWorkspace(new_root_dir) {
523
523
  const resolved_root = path.resolve(new_root_dir);
524
- const new_db = resolveDbPath({ cwd: resolved_root });
524
+ const new_db = resolveWorkspaceDatabase({ cwd: resolved_root });
525
525
  const old_path = CURRENT_WORKSPACE?.db_path || '';
526
526
 
527
527
  CURRENT_WORKSPACE = {
@@ -1298,7 +1298,7 @@ export async function handleMessage(ws, data) {
1298
1298
  const resolved = path.resolve(workspace_path);
1299
1299
 
1300
1300
  // Update workspace (this will rebind watcher, clear registry, broadcast change)
1301
- const new_db = resolveDbPath({ cwd: resolved });
1301
+ const new_db = resolveWorkspaceDatabase({ cwd: resolved });
1302
1302
  const old_path = CURRENT_WORKSPACE?.db_path || '';
1303
1303
 
1304
1304
  CURRENT_WORKSPACE = {