@polderlabs/bizar 3.7.2 → 3.8.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/cli/update.mjs CHANGED
@@ -1,36 +1,180 @@
1
1
  /**
2
2
  * update.mjs — `bizar update` subcommand.
3
3
  *
4
- * Updates the opencode CLI, the @polderlabs/bizar package, and/or the
5
- * @polderlabs/bizar-plugin package. By default, prompts for each
6
- * component individually. With `--all`, updates everything without
7
- * prompting. With explicit subcommands (`opencode`, `bizar`, `plugin`),
8
- * runs only the named update.
4
+ * Updates the opencode CLI, the @polderlabs/bizar package, the
5
+ * @polderlabs/bizar-dash package, and/or the @polderlabs/bizar-plugin
6
+ * package. By default, prompts for each component individually. With
7
+ * `--all`, updates everything without prompting.
9
8
  *
10
- * Each update is a thin wrapper around the underlying installer:
11
- * - opencode: `opencode upgrade` (or `npm install -g opencode-ai@latest` as a fallback)
12
- * - bizar: `npm install -g @polderlabs/bizar@latest`
13
- * - plugin: `npm install -g @polderlabs/bizar-plugin@latest`
9
+ * Before any install, the command:
10
+ * 1. Detects running instances (background service daemon, dashboard
11
+ * server, TUI dashboard) by reading the PID files at
12
+ * ~/.config/bizar/{service,dashboard}.pid and cleaning up any
13
+ * stale or empty ones.
14
+ * 2. Warns the user explicitly about each running instance, lists
15
+ * what will be killed, and asks for confirmation. Skipped only
16
+ * with `--yes` (or `--force`).
17
+ * 3. Sends SIGTERM to each live instance, waits up to 5s for
18
+ * graceful shutdown, then escalates to SIGKILL for anything
19
+ * still alive.
14
20
  *
15
- * After the package updates, re-runs the install script so the locally
16
- * deployed plugin source matches the just-upgraded npm version. Without
17
- * this, the plugin in `~/.config/opencode/plugins/bizar/` would be one
18
- * version behind the npm registry, and the BUGS.md "version skew"
21
+ * After a successful update of `bizar` or `bizar-dash`, the dashboard
22
+ * is automatically restarted (using its own `POST /api/restart`
23
+ * endpoint when reachable, or by spawning a new process when not).
24
+ * Use `--no-restart` to skip the restart.
25
+ *
26
+ * After the npm packages are updated, the install script is re-run so
27
+ * the locally deployed plugin source matches the just-upgraded npm
28
+ * version. Without this, the plugin in `~/.config/opencode/plugins/bizar/`
29
+ * would lag one version behind npm and the BUGS.md "version skew"
19
30
  * trap would bite.
20
31
  *
21
32
  * Exit codes:
22
33
  * 0 — all requested updates succeeded (or none were requested)
23
- * 1 — at least one update failed
34
+ * 1 — at least one update failed, or the user cancelled the kill
24
35
  */
25
36
 
26
37
  import chalk from 'chalk';
27
- import { execSync, spawnSync } from 'node:child_process';
28
- import { existsSync } from 'node:fs';
29
- import { join } from 'node:path';
38
+ import { execSync, spawn, spawnSync } from 'node:child_process';
39
+ import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
40
+ import { homedir } from 'node:os';
41
+ import { join, dirname } from 'node:path';
42
+ import { fileURLToPath } from 'node:url';
43
+
44
+ const __filename = fileURLToPath(import.meta.url);
45
+ const __dirname = dirname(__filename);
30
46
 
31
47
  const PKG_MAIN = '@polderlabs/bizar';
48
+ const PKG_DASH = '@polderlabs/bizar-dash';
32
49
  const PKG_PLUGIN = '@polderlabs/bizar-plugin';
33
50
 
51
+ // All known components, in the order they should be prompted + updated.
52
+ const COMPONENTS = ['opencode', 'bizar', 'dash', 'plugin'];
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Config paths (mirror the dashboard + service for consistency)
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function bizarConfigDir() {
59
+ if (process.platform === 'win32') {
60
+ return process.env.APPDATA
61
+ ? join(process.env.APPDATA, 'bizar')
62
+ : join(homedir(), '.config', 'bizar');
63
+ }
64
+ return process.env.XDG_CONFIG_HOME
65
+ ? join(process.env.XDG_CONFIG_HOME, 'bizar')
66
+ : join(homedir(), '.config', 'bizar');
67
+ }
68
+
69
+ const BIZAR_HOME = bizarConfigDir();
70
+ const SERVICE_PID_FILE = join(BIZAR_HOME, 'service.pid');
71
+ const DASHBOARD_PID_FILE = join(BIZAR_HOME, 'dashboard.pid');
72
+ const DASHBOARD_PORT_FILE = join(BIZAR_HOME, 'dashboard.port');
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // PID helpers (exported for testability)
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * Read and validate a PID file. Returns a live integer PID, or `null`
80
+ * if the file is missing / empty / contains a non-numeric value / the
81
+ * process is not running. Stale or corrupt PID files are removed.
82
+ *
83
+ * Exported so tests can exercise the cleanup behavior without going
84
+ * through the full update flow.
85
+ */
86
+ export function readLivePid(pidFile) {
87
+ if (!existsSync(pidFile)) return null;
88
+ let raw;
89
+ try {
90
+ raw = readFileSync(pidFile, 'utf8').trim();
91
+ } catch {
92
+ return null;
93
+ }
94
+ if (!raw) {
95
+ // Empty / corrupt — clean it up so future checks are accurate.
96
+ try { rmSync(pidFile, { force: true }); } catch { /* ignore */ }
97
+ return null;
98
+ }
99
+ const pid = parseInt(raw, 10);
100
+ if (!Number.isFinite(pid) || pid <= 0) {
101
+ try { rmSync(pidFile, { force: true }); } catch { /* ignore */ }
102
+ return null;
103
+ }
104
+ try {
105
+ process.kill(pid, 0);
106
+ return pid;
107
+ } catch {
108
+ // PID no longer alive — stale PID file.
109
+ try { rmSync(pidFile, { force: true }); } catch { /* ignore */ }
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Send SIGTERM, wait up to `timeoutMs`, then SIGKILL if still alive.
116
+ * Returns true if a signal was successfully delivered (or never needed).
117
+ *
118
+ * Implementation note: Linux PIDs are recycled immediately after a
119
+ * process dies, so `process.kill(pid, 0)` after a kill is an unreliable
120
+ * liveness check — the PID may already belong to a brand-new process.
121
+ * We treat any successful signal delivery as success; if SIGKILL is
122
+ * sent, the original process is certainly dead (uncatchable).
123
+ *
124
+ * Exported for testability.
125
+ */
126
+ export function killAndWait(pid, { timeoutMs = 5000, label = 'process' } = {}) {
127
+ if (!pid) return true;
128
+
129
+ // Phase 1: graceful SIGTERM
130
+ let sigtermOk = false;
131
+ try {
132
+ process.kill(pid, 'SIGTERM');
133
+ sigtermOk = true;
134
+ } catch (err) {
135
+ if (err.code === 'ESRCH') return true; // already dead
136
+ console.log(chalk.yellow(` ! could not SIGTERM ${label} (pid ${pid}): ${err.message}`));
137
+ return false;
138
+ }
139
+
140
+ // Best-effort poll for graceful exit. Note: a SIGTERM-handling process
141
+ // (Express dashboard, Node sleeper) usually exits within ~100ms. We
142
+ // poll for up to `timeoutMs`; if anything responds to kill -0 it may be
143
+ // a recycled PID, so we don't treat that as "still our process".
144
+ const start = Date.now();
145
+ let sawExit = false;
146
+ while (Date.now() - start < timeoutMs) {
147
+ try {
148
+ process.kill(pid, 0);
149
+ } catch (err) {
150
+ if (err.code === 'ESRCH') { sawExit = true; break; }
151
+ }
152
+ spawnSync('sleep', ['0.1']);
153
+ }
154
+
155
+ if (sawExit) {
156
+ // Confirmed gone via ESRCH.
157
+ return true;
158
+ }
159
+
160
+ // Phase 2: escalate to SIGKILL. Even if `kill -0` still succeeds (PID
161
+ // recycled or process truly stuck), SIGKILL is uncatchable and the
162
+ // original process — if it was still our PID — is now dead.
163
+ try {
164
+ process.kill(pid, 'SIGKILL');
165
+ if (sigtermOk) {
166
+ console.log(chalk.yellow(` ! ${label} (pid ${pid}) did not exit gracefully; sent SIGKILL`));
167
+ }
168
+ // Brief settle for the kernel.
169
+ spawnSync('sleep', ['0.2']);
170
+ return true;
171
+ } catch (err) {
172
+ if (err.code === 'ESRCH') return true;
173
+ console.log(chalk.red(` ✗ could not SIGKILL ${label} (pid ${pid}): ${err.message}`));
174
+ return false;
175
+ }
176
+ }
177
+
34
178
  // ---------------------------------------------------------------------------
35
179
  // Version helpers
36
180
  // ---------------------------------------------------------------------------
@@ -41,8 +185,10 @@ const PKG_PLUGIN = '@polderlabs/bizar-plugin';
41
185
  */
42
186
  function currentVersion(pkg) {
43
187
  try {
44
- const out = execSync(`npm ls -g ${pkg} --depth=0 --json`, { stdio: ['ignore', 'pipe', 'ignore'] })
45
- .toString();
188
+ const out = execSync(`npm ls -g ${pkg} --depth=0 --json`, {
189
+ stdio: ['ignore', 'pipe', 'ignore'],
190
+ timeout: 15000,
191
+ }).toString();
46
192
  const parsed = JSON.parse(out);
47
193
  const deps = parsed.dependencies ?? {};
48
194
  return deps[pkg]?.version ?? null;
@@ -57,9 +203,10 @@ function currentVersion(pkg) {
57
203
  */
58
204
  function latestVersion(pkg) {
59
205
  try {
60
- const out = execSync(`npm view ${pkg} version`, { stdio: ['ignore', 'pipe', 'ignore'] })
61
- .toString()
62
- .trim();
206
+ const out = execSync(`npm view ${pkg} version`, {
207
+ stdio: ['ignore', 'pipe', 'ignore'],
208
+ timeout: 15000,
209
+ }).toString().trim();
63
210
  return out || null;
64
211
  } catch {
65
212
  return null;
@@ -76,12 +223,10 @@ function latestVersion(pkg) {
76
223
  * Returns `{ ok: boolean, message: string }`.
77
224
  */
78
225
  function updateOpencode() {
79
- // Try `opencode upgrade` first — that's the canonical updater.
80
226
  const r1 = spawnSync('opencode', ['upgrade'], { stdio: 'inherit' });
81
227
  if (r1.status === 0) {
82
228
  return { ok: true, message: 'opencode updated via `opencode upgrade`' };
83
229
  }
84
- // Fall back to npm. `opencode-ai` is the npm package name.
85
230
  console.log(chalk.dim(' opencode upgrade not available; falling back to npm'));
86
231
  const r2 = spawnSync('npm', ['install', '-g', 'opencode-ai@latest'], { stdio: 'inherit' });
87
232
  if (r2.status === 0) {
@@ -90,26 +235,12 @@ function updateOpencode() {
90
235
  return { ok: false, message: 'opencode update failed — try `opencode upgrade` manually' };
91
236
  }
92
237
 
93
- /**
94
- * Update @polderlabs/bizar globally. Returns `{ ok, message }`.
95
- */
96
- function updateBizar() {
97
- const r = spawnSync('npm', ['install', '-g', `${PKG_MAIN}@latest`], { stdio: 'inherit' });
238
+ function updatePackage(pkg) {
239
+ const r = spawnSync('npm', ['install', '-g', `${pkg}@latest`], { stdio: 'inherit' });
98
240
  if (r.status !== 0) {
99
- return { ok: false, message: `${PKG_MAIN} update failed` };
241
+ return { ok: false, message: `${pkg} update failed` };
100
242
  }
101
- return { ok: true, message: `${PKG_MAIN} updated` };
102
- }
103
-
104
- /**
105
- * Update @polderlabs/bizar-plugin globally. Returns `{ ok, message }`.
106
- */
107
- function updateBizarPlugin() {
108
- const r = spawnSync('npm', ['install', '-g', `${PKG_PLUGIN}@latest`], { stdio: 'inherit' });
109
- if (r.status !== 0) {
110
- return { ok: false, message: `${PKG_PLUGIN} update failed` };
111
- }
112
- return { ok: true, message: `${PKG_PLUGIN} updated` };
243
+ return { ok: true, message: `${pkg} updated` };
113
244
  }
114
245
 
115
246
  // ---------------------------------------------------------------------------
@@ -122,8 +253,6 @@ function updateBizarPlugin() {
122
253
  * fix for the BUGS.md "version skew" trap.
123
254
  */
124
255
  function rerunInstallScript() {
125
- // Find the package root via `npm root -g` + package name. This works
126
- // whether the user installed via `npm install -g` or `npx`.
127
256
  let globalRoot;
128
257
  try {
129
258
  globalRoot = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] })
@@ -142,12 +271,10 @@ function rerunInstallScript() {
142
271
  }
143
272
  return { ok: false, message: 'setup rerun failed' };
144
273
  }
145
-
146
274
  const installSh = join(pkgRoot, 'install.sh');
147
275
  if (process.platform === 'win32' || !existsSync(installSh)) {
148
276
  return { ok: false, message: 'could not locate a compatible setup script to re-run' };
149
277
  }
150
-
151
278
  console.log(chalk.dim(`\n Re-running install script at ${installSh}...`));
152
279
  const r = spawnSync('bash', [installSh], { stdio: 'inherit' });
153
280
  if (r.status !== 0) {
@@ -157,19 +284,141 @@ function rerunInstallScript() {
157
284
  }
158
285
 
159
286
  // ---------------------------------------------------------------------------
160
- // Prompt helpers
287
+ // Instance detection + kill
161
288
  // ---------------------------------------------------------------------------
162
289
 
163
290
  /**
164
- * Interactive prompt for which components to update. Uses inquirer.
165
- * Returns a Set of `opencode`, `bizar`, `plugin` strings.
291
+ * Detect live Bizar instances by reading the well-known PID files.
292
+ * Returns:
293
+ * { service: { pid, label } | null,
294
+ * dashboard: { pid, port } | null,
295
+ * other: Array<{ pid, cmd }> }
166
296
  */
167
- async function promptForUpdates(forceAll) {
168
- if (forceAll) {
169
- return new Set(['opencode', 'bizar', 'plugin']);
297
+ function detectInstances() {
298
+ const servicePid = readLivePid(SERVICE_PID_FILE);
299
+ const dashboardPid = readLivePid(DASHBOARD_PID_FILE);
300
+ let port = null;
301
+ if (dashboardPid && existsSync(DASHBOARD_PORT_FILE)) {
302
+ try {
303
+ port = parseInt(readFileSync(DASHBOARD_PORT_FILE, 'utf8').trim(), 10) || null;
304
+ } catch { /* ignore */ }
170
305
  }
171
- // Dynamic import so the rest of the file can be loaded even if
172
- // inquirer is unavailable for some reason.
306
+ return {
307
+ service: servicePid ? { pid: servicePid, label: `bizar service (pid ${servicePid})` } : null,
308
+ dashboard: dashboardPid ? { pid: dashboardPid, port, label: `bizar-dash web dashboard (pid ${dashboardPid}${port ? `, port ${port}` : ''})` } : null,
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Ask the user to confirm killing the listed instances. Returns true if
314
+ * they confirmed, false if they cancelled. Skipped entirely with `assumeYes`.
315
+ */
316
+ async function confirmKill(instances, { assumeYes } = {}) {
317
+ const lines = [];
318
+ if (instances.service) lines.push(` • ${instances.service.label} — background schedule runner`);
319
+ if (instances.dashboard) lines.push(` • ${instances.dashboard.label} — web UI + API`);
320
+ if (lines.length === 0) return true;
321
+ if (assumeYes) return true;
322
+ console.log('');
323
+ console.log(chalk.bold.yellow(' ⚠ Running Bizar instances detected:'));
324
+ for (const l of lines) console.log(chalk.yellow(l));
325
+ console.log('');
326
+ console.log(chalk.yellow(' These must be stopped before npm can replace the on-disk files.'));
327
+ console.log(chalk.yellow(' Killing them will close any open web tabs / TUI sessions.'));
328
+ console.log('');
329
+ // Best-effort interactive confirmation. Non-TTY (CI / piped input) skips the
330
+ // prompt and aborts — callers should pass `--yes` explicitly in that case.
331
+ if (!process.stdin.isTTY) {
332
+ console.log(chalk.red(' Non-interactive shell detected; rerun with --yes to confirm the kill, or stop the processes manually.'));
333
+ return false;
334
+ }
335
+ try {
336
+ const inquirer = await import('inquirer');
337
+ const { ok } = await inquirer.default.prompt([
338
+ {
339
+ type: 'confirm',
340
+ name: 'ok',
341
+ message: 'Kill the running instance(s) and continue?',
342
+ default: true,
343
+ },
344
+ ]);
345
+ return Boolean(ok);
346
+ } catch (err) {
347
+ console.log(chalk.red(` ! inquirer unavailable: ${err.message}`));
348
+ return false;
349
+ }
350
+ }
351
+
352
+ function killInstances(instances) {
353
+ const out = [];
354
+ if (instances.service) {
355
+ const ok = killAndWait(instances.service.pid, { label: 'bizar service' });
356
+ out.push({ name: 'service', ok });
357
+ if (ok) {
358
+ try { rmSync(SERVICE_PID_FILE, { force: true }); } catch { /* ignore */ }
359
+ }
360
+ }
361
+ if (instances.dashboard) {
362
+ const ok = killAndWait(instances.dashboard.pid, { label: 'bizar-dash' });
363
+ out.push({ name: 'dashboard', ok });
364
+ if (ok) {
365
+ try { rmSync(DASHBOARD_PID_FILE, { force: true }); } catch { /* ignore */ }
366
+ try { rmSync(DASHBOARD_PORT_FILE, { force: true }); } catch { /* ignore */ }
367
+ }
368
+ }
369
+ return out;
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Restart the dashboard after the update completes
374
+ // ---------------------------------------------------------------------------
375
+
376
+ /**
377
+ * Spawn a fresh dashboard process detached, returning the new PID. The
378
+ * dashboard will use the just-updated @polderlabs/bizar-dash code from
379
+ * the global npm install.
380
+ */
381
+ function spawnFreshDashboard({ port } = {}) {
382
+ let globalRoot;
383
+ try {
384
+ globalRoot = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] })
385
+ .toString()
386
+ .trim();
387
+ } catch {
388
+ return { ok: false, message: 'could not locate npm global root' };
389
+ }
390
+ const dashBin = join(globalRoot, ...PKG_DASH.split('/'), 'src', 'cli.mjs');
391
+ if (!existsSync(dashBin)) {
392
+ return {
393
+ ok: false,
394
+ message: `dashboard binary not found at ${dashBin} (was @polderlabs/bizar-dash installed?)`,
395
+ };
396
+ }
397
+ try {
398
+ mkdirSync(BIZAR_HOME, { recursive: true });
399
+ } catch { /* ignore */ }
400
+ const args = [dashBin, 'start', '--bg'];
401
+ if (port) args.push(`--port=${port}`);
402
+ try {
403
+ const child = spawn(process.execPath, args, {
404
+ detached: true,
405
+ stdio: 'ignore',
406
+ env: { ...process.env, BIZAR_AUTO_RESPAWN: '1' },
407
+ });
408
+ child.on('error', () => { /* ignore */ });
409
+ child.unref();
410
+ return { ok: true, message: `dashboard re-spawned (pid ${child.pid})` };
411
+ } catch (err) {
412
+ return { ok: false, message: `dashboard re-spawn failed: ${err.message}` };
413
+ }
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Prompt helpers
418
+ // ---------------------------------------------------------------------------
419
+
420
+ async function promptForUpdates(forceAll) {
421
+ if (forceAll) return new Set(COMPONENTS);
173
422
  const inquirer = await import('inquirer');
174
423
  const { selections } = await inquirer.default.prompt([
175
424
  {
@@ -179,6 +428,7 @@ async function promptForUpdates(forceAll) {
179
428
  choices: [
180
429
  { name: 'opencode (the opencode CLI itself)', value: 'opencode', checked: true },
181
430
  { name: `bizar (${PKG_MAIN})`, value: 'bizar', checked: true },
431
+ { name: `bizar-dash (${PKG_DASH}) — web dashboard`, value: 'dash', checked: true },
182
432
  { name: `plugin (${PKG_PLUGIN})`, value: 'plugin', checked: true },
183
433
  ],
184
434
  },
@@ -190,29 +440,60 @@ async function promptForUpdates(forceAll) {
190
440
  // Main entry point
191
441
  // ---------------------------------------------------------------------------
192
442
 
193
- /**
194
- * Run the update subcommand.
195
- *
196
- * @param {string[]} subargs - arguments after `bizar update`
197
- */
198
443
  export async function runUpdate(subargs = []) {
199
444
  console.log(chalk.bold.hex('#a855f7')('\n ᚦ BIZAR UPDATE ᚦ\n'));
200
445
 
201
- // Show what we know before asking.
446
+ // Parse flags
447
+ const assumeYes = subargs.includes('--yes') || subargs.includes('-y') || subargs.includes('--force');
448
+ const restartAfter = !subargs.includes('--no-restart');
449
+ const forceAll = subargs.includes('--all');
450
+
451
+ // 1. Detect running instances BEFORE doing anything else.
452
+ const instances = detectInstances();
453
+ const runningCount = (instances.service ? 1 : 0) + (instances.dashboard ? 1 : 0);
454
+
455
+ if (runningCount > 0) {
456
+ const ok = await confirmKill(instances, { assumeYes });
457
+ if (!ok) {
458
+ console.log(chalk.yellow('\n Update cancelled — instances still running.'));
459
+ console.log(chalk.dim(' Stop them with `bizar service stop` and `bizar dashboard stop`, then retry.'));
460
+ process.exit(1);
461
+ }
462
+ console.log(chalk.cyan('\n Stopping running instances...'));
463
+ const kills = killInstances(instances);
464
+ for (const k of kills) {
465
+ const marker = k.ok ? chalk.green('✓') : chalk.red('✗');
466
+ console.log(` ${marker} ${k.name} stopped`);
467
+ }
468
+ // Give the kernel a moment to release any open file handles on the
469
+ // npm-global directory before npm tries to replace files.
470
+ spawnSync('sleep', ['0.5']);
471
+ } else {
472
+ console.log(chalk.dim(' No running Bizar instances detected.'));
473
+ }
474
+
475
+ // 2. Show installed vs. latest versions.
202
476
  const cur = {
203
477
  opencode: currentVersion('opencode-ai'),
204
478
  bizar: currentVersion(PKG_MAIN),
479
+ dash: currentVersion(PKG_DASH),
205
480
  plugin: currentVersion(PKG_PLUGIN),
206
481
  };
207
482
  const latest = {
208
483
  opencode: latestVersion('opencode-ai'),
209
484
  bizar: latestVersion(PKG_MAIN),
485
+ dash: latestVersion(PKG_DASH),
210
486
  plugin: latestVersion(PKG_PLUGIN),
211
487
  };
212
488
 
489
+ console.log('');
213
490
  console.log(' Installed vs. latest:');
214
- for (const k of Object.keys(cur)) {
215
- const label = k === 'plugin' ? PKG_PLUGIN : k === 'bizar' ? PKG_MAIN : 'opencode-ai';
491
+ for (const k of COMPONENTS) {
492
+ const label =
493
+ k === 'plugin' ? PKG_PLUGIN :
494
+ k === 'dash' ? PKG_DASH :
495
+ k === 'bizar' ? PKG_MAIN :
496
+ 'opencode-ai';
216
497
  const c = cur[k] ?? '(not installed)';
217
498
  const l = latest[k] ?? '(unknown)';
218
499
  const same = c === l;
@@ -220,19 +501,18 @@ export async function runUpdate(subargs = []) {
220
501
  }
221
502
  console.log('');
222
503
 
223
- // Decide what to update.
504
+ // 3. Decide what to update.
224
505
  let selected;
225
- if (subargs.includes('--all')) {
226
- selected = new Set(['opencode', 'bizar', 'plugin']);
506
+ if (forceAll || assumeYes) {
507
+ selected = new Set(COMPONENTS);
227
508
  } else if (subargs.length === 0) {
228
509
  selected = await promptForUpdates(false);
229
510
  } else {
230
- // Explicit subcommands.
231
- const valid = new Set(['opencode', 'bizar', 'plugin']);
232
- selected = new Set(subargs.filter((a) => valid.has(a)));
511
+ const valid = new Set(COMPONENTS);
512
+ selected = new Set(subargs.filter((a) => !a.startsWith('-') && valid.has(a)));
233
513
  if (selected.size === 0) {
234
514
  console.log(chalk.yellow(` No valid components selected from: ${subargs.join(' ')}`));
235
- console.log(chalk.dim(' Valid components: opencode, bizar, plugin'));
515
+ console.log(chalk.dim(' Valid components: opencode, bizar, dash, plugin'));
236
516
  console.log(chalk.dim(' Run `bizar update --help` for usage.'));
237
517
  process.exit(1);
238
518
  }
@@ -252,15 +532,18 @@ export async function runUpdate(subargs = []) {
252
532
  }
253
533
  if (selected.has('bizar')) {
254
534
  console.log(chalk.bold(` → ${PKG_MAIN}`));
255
- results.push(['bizar', updateBizar()]);
535
+ results.push(['bizar', updatePackage(PKG_MAIN)]);
536
+ }
537
+ if (selected.has('dash')) {
538
+ console.log(chalk.bold(` → ${PKG_DASH}`));
539
+ results.push(['dash', updatePackage(PKG_DASH)]);
256
540
  }
257
541
  if (selected.has('plugin')) {
258
542
  console.log(chalk.bold(` → ${PKG_PLUGIN}`));
259
- results.push(['plugin', updateBizarPlugin()]);
543
+ results.push(['plugin', updatePackage(PKG_PLUGIN)]);
260
544
  }
261
545
 
262
- // Re-run the install script if anything was updated, so the deployed
263
- // plugin source matches the registry.
546
+ // 4. Re-run the install script if anything relevant changed.
264
547
  const anySuccess = results.some(([, r]) => r.ok);
265
548
  if (anySuccess && (selected.has('bizar') || selected.has('plugin'))) {
266
549
  console.log('');
@@ -273,6 +556,20 @@ export async function runUpdate(subargs = []) {
273
556
  }
274
557
  }
275
558
 
559
+ // 5. Restart the dashboard if it was running before the update.
560
+ if (restartAfter && instances.dashboard && (selected.has('bizar') || selected.has('dash'))) {
561
+ console.log('');
562
+ console.log(chalk.cyan(' Restarting dashboard with the new code...'));
563
+ const res = spawnFreshDashboard({ port: instances.dashboard.port || undefined });
564
+ if (res.ok) {
565
+ console.log(chalk.green(` ✓ ${res.message}`));
566
+ } else {
567
+ console.log(chalk.yellow(` ⚠ ${res.message}`));
568
+ console.log(chalk.dim(' Start it manually with `bizar-dash start --bg`.'));
569
+ }
570
+ }
571
+
572
+ // 6. Summary
276
573
  console.log('');
277
574
  console.log(' Summary:');
278
575
  for (const [name, r] of results) {
@@ -286,4 +583,4 @@ export async function runUpdate(subargs = []) {
286
583
  process.exit(1);
287
584
  }
288
585
  console.log(chalk.green('\n ✓ Update complete\n'));
289
- }
586
+ }
package/config/AGENTS.md CHANGED
@@ -283,6 +283,36 @@ The Hindsight MCP server is already configured. All agents interact with it thro
283
283
 
284
284
  ---
285
285
 
286
+ ## Graph Query (bizar graph)
287
+
288
+ Bizar integrates [graphify](https://github.com/safishamsi/graphify) for per-project knowledge graphs. When investigating a Bizar project, **query the graph before grepping raw files** — it's faster and surfaces structural relationships grep can't see.
289
+
290
+ ### Where the graph lives
291
+ `.bizar/graph/` inside the project (git-trackable JSON + Markdown; cache and per-machine interpreter path are gitignored).
292
+
293
+ ### How to query
294
+ From the project root, the user (or heimdall via `/init` or any other agent prompted by Odin) can run:
295
+ - `bizar graph status` — confirm the graph exists; print node/edge/community counts
296
+ - `bizar graph query "<concept>"` — find nodes related to a concept (BFS traversal)
297
+ - `bizar graph path "<A>" "<B>"` — shortest path between two concepts
298
+ - `bizar graph explain "<X>"` — all nodes related to X
299
+ - `bizar graph update` — incremental rebuild after editing source files
300
+ - `bizar graph build` — full rebuild (overwrites existing graph)
301
+ - `bizar graph watch` — foreground watcher (Ctrl-C to stop)
302
+
303
+ ### When to use the graph
304
+ - Before reading a large file: `bizar graph explain "<module-name>"` to see what calls/uses it
305
+ - When mapping unfamiliar code: `bizar graph query "<feature>"` to find related concepts
306
+ - When debugging cross-module interactions: `bizar graph path "<symptom>" "<root-cause>"`
307
+ - Before grep: `bizar graph query "<term>"` first — the graph may already point you to the right file
308
+
309
+ ### When NOT to use the graph
310
+ - The graph is stale (run `bizar graph update` first)
311
+ - graphify is not installed (init skipped graph step; user can install with `pip install graphifyy` then `bizar graph build`)
312
+ - The question is about runtime behavior, not source structure
313
+
314
+ ---
315
+
286
316
  ---
287
317
 
288
318
  ## General Agent Baseline — Always-On Behavior
@@ -548,3 +578,26 @@ For Bizar-internal claims (citing files, lines, tool results), use file:line ref
548
578
 
549
579
  This baseline covers: identity, refusal, tone, formatting, lists, user wellbeing, evenhandedness, mistakes, knowledge cutoff and research-first, MCP servers and skills, mandatory skill-read, file creation, file handling, search, copyright, harmful content, citations, images, memory privacy, execution, clarification, and communication. Every Bizar agent — Odin, Frigg, Vör, Mimir, Heimdall, Hermod, Thor, Baldr, Tyr, Vidarr, Forseti — must follow it.
550
580
 
581
+ ---
582
+
583
+ ## Parallel Execution Awareness
584
+
585
+ You may be dispatched by Odin as one of several agents running concurrently against the same working directory and the same git repository. Your sibling agents **cannot see you** and you **cannot see them**. Without discipline this leads to silent file overwrites, `.git/index.lock` collisions, lockfile corruption, and lost work.
586
+
587
+ ### Hard rules when you have siblings (Odin tells you in the prompt)
588
+
589
+ 1. **File scope is sacred.** Odin assigns you a scope. Only modify files inside it. If you need to touch something outside, STOP and report — do not improvise.
590
+ 2. **No write-level git.** `git commit`, `push`, `merge`, `rebase`, `reset`, `clean`, `stash`, branch-switching `checkout`, and `pull --rebase` are FORBIDDEN for every agent except @hermod. Use `git status`, `git diff`, `git log`, and `git add` (scope files only) for context.
591
+ 3. **Detect conflicts before they happen.** Before writing a file, run `git diff --name-only` and confirm the file is not in a sibling's scope. If it has changed since you started, STOP and report.
592
+ 4. **`.git/index.lock` is a sibling's signal.** If you see it, wait 2-3 seconds and retry. If it persists, STOP and report. Do not delete the lock file.
593
+ 5. **Lockfiles and root configs are shared.** `package.json`, `package-lock.json`, `tsconfig.json`, `vite.config.*`, `Dockerfile`, CI configs — only ONE agent in a batch should touch these. If Odin did not assign them to you, treat as READ-ONLY.
594
+ 6. **Report parallel context in your final summary.** State "siblings: ..." and any conflicts observed.
595
+
596
+ ### Default behavior when Odin does NOT mention siblings
597
+
598
+ - You may work normally.
599
+ - Still avoid `git commit`/`push`/`merge`/`rebase`/`reset`/`clean`/`stash` unless explicitly asked. Default to read-only git unless the user/Odin explicitly requests a write operation. When in doubt, leave git work to @hermod.
600
+
601
+ ### Why this exists
602
+ The harness shares one `.git/` directory across all parallel sessions. Two simultaneous `git commit` calls race on the index lock. Two agents writing the same file = silent last-writer-wins data loss. Discipline now is cheaper than recovery later.
603
+