@polderlabs/bizar 5.5.0 → 5.5.2

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 (71) hide show
  1. package/bizar-dash/dist/assets/{EnvVarsSection-L413CQEI.js → EnvVarsSection-DoIzqrlb.js} +3 -3
  2. package/bizar-dash/dist/assets/{EnvVarsSection-L413CQEI.js.map → EnvVarsSection-DoIzqrlb.js.map} +1 -1
  3. package/bizar-dash/dist/assets/{MobileChat-0kGQb1S6.js → MobileChat-CoUCUsbr.js} +1 -1
  4. package/bizar-dash/dist/assets/{MobileChat-0kGQb1S6.js.map → MobileChat-CoUCUsbr.js.map} +1 -1
  5. package/bizar-dash/dist/assets/MobileSettings-BeO04g52.js +1 -0
  6. package/bizar-dash/dist/assets/MobileSettings-BeO04g52.js.map +1 -0
  7. package/bizar-dash/dist/assets/{Toast-C6f1zFJ2.js → Toast-D9nv5N6m.js} +1 -1
  8. package/bizar-dash/dist/assets/{Toast-C6f1zFJ2.js.map → Toast-D9nv5N6m.js.map} +1 -1
  9. package/bizar-dash/dist/assets/{icons-BsWXWkEF.js → icons-Btipv2pp.js} +100 -90
  10. package/bizar-dash/dist/assets/icons-Btipv2pp.js.map +1 -0
  11. package/bizar-dash/dist/assets/main-DHXPuddY.js +18 -0
  12. package/bizar-dash/dist/assets/main-DHXPuddY.js.map +1 -0
  13. package/bizar-dash/dist/assets/{main-BklYy02e.css → main-UG4jKK-F.css} +1 -1
  14. package/bizar-dash/dist/assets/mobile-H5KH9cTL.js +1 -0
  15. package/bizar-dash/dist/assets/{mobile-CsaN4CxH.js.map → mobile-H5KH9cTL.js.map} +1 -1
  16. package/bizar-dash/dist/assets/{mobile-layout-iFBdjEE4.js → mobile-layout-BqHxVb6_.js} +2 -2
  17. package/bizar-dash/dist/assets/{mobile-layout-iFBdjEE4.js.map → mobile-layout-BqHxVb6_.js.map} +1 -1
  18. package/bizar-dash/dist/assets/{useSlashCommands-DGJcC9iQ.js → useSlashCommands-C24-O1e7.js} +2 -2
  19. package/bizar-dash/dist/assets/{useSlashCommands-DGJcC9iQ.js.map → useSlashCommands-C24-O1e7.js.map} +1 -1
  20. package/bizar-dash/dist/index.html +7 -7
  21. package/bizar-dash/dist/mobile.html +3 -3
  22. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  23. package/bizar-dash/src/server/bg-spawner.mjs +522 -305
  24. package/bizar-dash/src/server/headroom.mjs +3 -1
  25. package/bizar-dash/src/server/memory-lightrag.mjs +26 -2
  26. package/bizar-dash/src/server/memory-store.mjs +74 -28
  27. package/bizar-dash/src/server/opencode-sdk.mjs +63 -3
  28. package/bizar-dash/src/server/plugins/registry.mjs +1 -1
  29. package/bizar-dash/src/server/routes/activity.mjs +85 -0
  30. package/bizar-dash/src/server/routes/background.mjs +31 -13
  31. package/bizar-dash/src/server/routes/memory.mjs +91 -38
  32. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +1 -1
  33. package/bizar-dash/src/server/server.mjs +4 -1
  34. package/bizar-dash/src/web/components/Sidebar.tsx +0 -1
  35. package/bizar-dash/src/web/styles/main.css +52 -13
  36. package/bizar-dash/src/web/styles/memory.css +17 -0
  37. package/bizar-dash/src/web/styles/settings.css +6 -56
  38. package/bizar-dash/src/web/views/Marketplace.tsx +192 -31
  39. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +157 -7
  40. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +11 -0
  41. package/bizar-dash/src/web/views/settings/MemorySection.tsx +65 -11
  42. package/bizar-dash/tests/background-sdk-session.test.mjs +94 -0
  43. package/bizar-dash/tests/background-session-events.test.mjs +74 -0
  44. package/bizar-dash/tests/background-steer-sdk.test.mjs +71 -0
  45. package/bizar-dash/tests/background-steer.test.mjs +25 -7
  46. package/bizar-dash/tests/memory-namespace.test.mjs +2 -2
  47. package/bizar-dash/tests/memory-status-root.test.mjs +144 -0
  48. package/bizar-dash/tests/memory-store.test.mjs +5 -4
  49. package/bizar-dash/tests/memory-vault-config-endpoint.test.mjs +173 -0
  50. package/bizar-dash/tests/settings-mode-wiring.test.tsx +1 -1
  51. package/bizar-dash/tests/settings-nav.test.tsx +0 -1
  52. package/bizar-dash/tests/views/Marketplace.test.tsx +200 -0
  53. package/bizar-dash/tests/views/Memory.test.tsx +274 -0
  54. package/bizar-dash/tests/views/sidebar.test.tsx +116 -0
  55. package/bizar-dash/tests/views/topbar.test.tsx +87 -0
  56. package/cli/commands/marketplace.mjs +1 -1
  57. package/cli/memory.mjs +1 -1
  58. package/package.json +1 -1
  59. package/plugins/bizar/src/background-state.ts +11 -0
  60. package/plugins/bizar/src/background.ts +212 -34
  61. package/plugins/bizar/src/opencode-runner.ts +80 -353
  62. package/plugins/bizar/src/tools/bg-send-message.ts +160 -61
  63. package/plugins/bizar/src/tools/bg-spawn.ts +235 -156
  64. package/plugins/bizar/tests/tools/bg-send-message.test.ts +84 -11
  65. package/plugins/bizar/tests/tools/bg-spawn-http.test.ts +223 -0
  66. package/bizar-dash/dist/assets/MobileSettings-Cq1oFIdO.js +0 -1
  67. package/bizar-dash/dist/assets/MobileSettings-Cq1oFIdO.js.map +0 -1
  68. package/bizar-dash/dist/assets/icons-BsWXWkEF.js.map +0 -1
  69. package/bizar-dash/dist/assets/main-CBTgYSeG.js +0 -18
  70. package/bizar-dash/dist/assets/main-CBTgYSeG.js.map +0 -1
  71. package/bizar-dash/dist/assets/mobile-CsaN4CxH.js +0 -1
@@ -48,7 +48,9 @@ const DEFAULT_HEADROOM_HOST = '127.0.0.1';
48
48
  function runCmd(cmd, args, opts = {}) {
49
49
  return new Promise((resolve) => {
50
50
  const { timeout = 30000, cwd = HOME, env = process.env } = opts;
51
- const child = spawn(cmd, args, { cwd, env, shell: true });
51
+ // shell:false avoids DEP0190 deprecation warning and is safe because
52
+ // args are already a proper array (not interpolated into a command string).
53
+ const child = spawn(cmd, args, { cwd, env, shell: false });
52
54
  let stdout = '';
53
55
  let stderr = '';
54
56
  const timer = setTimeout(() => {
@@ -365,7 +365,9 @@ export function findLightragBinary() {
365
365
  }
366
366
 
367
367
  export async function isInstalled() {
368
- return findLightragBinary() !== null;
368
+ const found = findLightragBinary() !== null;
369
+ if (found) _lightragNotInstalled = false;
370
+ return found;
369
371
  }
370
372
 
371
373
  function httpGet(url, timeoutMs) {
@@ -421,12 +423,31 @@ function httpPost(url, body, timeoutMs) {
421
423
  });
422
424
  }
423
425
 
426
+ /**
427
+ * v5.x — Flag set when LightRAG is confirmed not installed or explicitly
428
+ * disabled. When true, isRunning() returns false immediately without
429
+ * making any network request, preventing ECONNREFUSED log spam from a
430
+ * binary that will never be present.
431
+ *
432
+ * Reset by findLightragBinary() succeeding (e.g. after user installs
433
+ * the binary without restarting the dashboard).
434
+ */
435
+ let _lightragNotInstalled = false;
436
+
424
437
  export async function isRunning(config) {
438
+ // Fast path: skip network check if we already know LightRAG is absent.
439
+ // This suppresses the ECONNREFUSED log spam that otherwise occurs on every
440
+ // poll when the binary is not on PATH.
441
+ if (_lightragNotInstalled) return false;
425
442
  try {
426
443
  const res = await httpGet(`http://${config.host}:${config.port}/health`, 3000);
427
444
  return res.status === 200;
428
445
  } catch (err) {
429
- console.warn('[lightrag] swallowed in isRunning.healthCheck:', err?.message || err);
446
+ // Rate-limit: only log once per 60s to avoid flooding the console
447
+ // when LightRAG health-check fails repeatedly.
448
+ if (_rateLimit('isRunning.healthCheck', 60_000)) {
449
+ console.warn('[lightrag] isRunning healthCheck failed:', err?.message || err);
450
+ }
430
451
  return false;
431
452
  }
432
453
  }
@@ -484,6 +505,7 @@ export async function startServer(config, { logger } = {}) {
484
505
  }
485
506
 
486
507
  if (!findLightragBinary()) {
508
+ _lightragNotInstalled = true;
487
509
  return {
488
510
  ok: false,
489
511
  error:
@@ -682,6 +704,7 @@ export async function lightragStartupHook(projectRoot, opts = {}) {
682
704
 
683
705
  // 3. Check lightrag.enabled flag.
684
706
  if (config.enabled === false) {
707
+ _lightragNotInstalled = true;
685
708
  log('startup hook: lightrag disabled in .bizar/memory.json, skipping');
686
709
  return { ok: true, started: false, reason: 'disabled' };
687
710
  }
@@ -689,6 +712,7 @@ export async function lightragStartupHook(projectRoot, opts = {}) {
689
712
  // 4. Check env-var override.
690
713
  const envAuto = process.env.BIZAR_LIGHTRAG_AUTOSTART;
691
714
  if (typeof envAuto === 'string' && /^(0|false|no|off)$/i.test(envAuto.trim())) {
715
+ _lightragNotInstalled = true;
692
716
  log(`startup hook: BIZAR_LIGHTRAG_AUTOSTART=${envAuto}, skipping`);
693
717
  return { ok: true, started: false, reason: 'env-disabled' };
694
718
  }
@@ -207,8 +207,14 @@ export function saveConfig(projectRoot, config) {
207
207
  /**
208
208
  * Resolve the effective vault root, namespaces, and related paths based on mode.
209
209
  *
210
+ * v6.x — `vaultRoot` is now always the GENERAL vault root (e.g. `~/.bizar_memory`).
211
+ * A new `projectVaultRoot` field carries the project-specific subdirectory for
212
+ * note storage in managed/linked mode (`~/.bizar_memory/projects/<projectId>`).
213
+ * This distinction lets the UI display the general root while the store still
214
+ * writes notes to the correct project subdirectory.
215
+ *
210
216
  * @param {string} projectRoot
211
- * @returns {{ mode: string, projectId: string, vaultRoot: string, repoPath: string | null, configDir: string, gitRemote: string | null, branch: string, lightragDir: string, namespaces: object }}
217
+ * @returns {{ mode: string, projectId: string, vaultRoot: string, projectVaultRoot: string, repoPath: string | null, configDir: string, gitRemote: string | null, branch: string, lightragDir: string, namespaces: object }}
212
218
  */
213
219
  export function resolveVault(projectRoot) {
214
220
  const { config } = loadConfig(projectRoot);
@@ -219,16 +225,19 @@ export function resolveVault(projectRoot) {
219
225
  const gitRemote = mr.remote || config.gitRemote || null;
220
226
 
221
227
  let vaultRoot;
228
+ let projectVaultRoot;
222
229
  let repoPath;
223
230
  if (mode === 'local-only') {
224
231
  vaultRoot = join(projectRoot, '.obsidian');
232
+ projectVaultRoot = vaultRoot;
225
233
  repoPath = null;
226
234
  } else {
227
235
  // managed or linked — path may be absolute, ~-relative, or a sibling of projectRoot
228
236
  const rawPath = mr.path || config.repoName || join(projectRoot, '.bizar', 'memory');
229
237
  const expanded = rawPath.startsWith('~') ? join(HOME, rawPath.slice(1)) : rawPath;
230
238
  repoPath = expanded;
231
- vaultRoot = join(expanded, 'projects', projectId);
239
+ vaultRoot = expanded; // v6.x — general root, e.g. ~/.bizar_memory
240
+ projectVaultRoot = join(expanded, 'projects', projectId); // project-specific subdirectory
232
241
  }
233
242
 
234
243
  const configDir = join(projectRoot, '.bizar');
@@ -239,29 +248,28 @@ export function resolveVault(projectRoot) {
239
248
  user: `users/${process.env.USER || process.env.USERNAME || 'local'}`,
240
249
  };
241
250
 
242
- return { mode, projectId, vaultRoot, repoPath, configDir, gitRemote, branch, lightragDir, namespaces };
251
+ return { mode, projectId, vaultRoot, projectVaultRoot, repoPath, configDir, gitRemote, branch, lightragDir, namespaces };
243
252
  }
244
253
 
245
254
  /**
246
255
  * Resolve the absolute path of a namespace root.
247
256
  *
248
- * For `project`, returns vaultRoot (the existing default). For `global` and
249
- * `user`, returns `<vaultRoot>/<namespaces.global|user>` in both modes
250
- * `writeNote('global/bizar/foo.md', …)` already places files there in both
251
- * local-only and managed mode, so this matches the on-disk layout that
252
- * existing callers rely on.
257
+ * For `project`, returns projectVaultRoot (the directory where project notes
258
+ * are stored: `<vaultRoot>/projects/<projectId>` in managed mode, or
259
+ * `<projectRoot>/.obsidian` in local-only mode). For `global` and `user`,
260
+ * returns `<vaultRoot>/<namespaces.X>` these namespaces live at the vault
261
+ * root level, not inside the project subdirectory.
253
262
  *
254
- * (Note: an earlier draft distinguished local-only vs managed, putting
255
- * global under `<repoPath>/global/bizar` in managed mode. That diverged from
256
- * where writeNote actually creates the file, which is always under
257
- * vaultRoot. This implementation matches the on-disk reality.)
263
+ * Routing: writeNote/readNote/deleteNote use this to determine where files
264
+ * live. Project relPaths go to projectVaultRoot; global/ and users/ relPaths
265
+ * go to vaultRoot-based paths.
258
266
  *
259
- * @param {{ mode: string, vaultRoot: string, namespaces: object }} vaultInfo
267
+ * @param {{ mode: string, vaultRoot: string, projectVaultRoot: string, namespaces: object }} vaultInfo
260
268
  * @param {'project'|'global'|'user'} namespace
261
269
  * @returns {string|null} absolute path, or null if the namespace is unknown
262
270
  */
263
271
  export function resolveNamespaceRoot(vaultInfo, namespace) {
264
- if (namespace === 'project') return vaultInfo.vaultRoot;
272
+ if (namespace === 'project') return vaultInfo.projectVaultRoot;
265
273
  const ns = vaultInfo.namespaces?.[namespace];
266
274
  if (!ns) return null;
267
275
  return join(vaultInfo.vaultRoot, ns);
@@ -381,8 +389,8 @@ export function initVault(projectRoot) {
381
389
  * @returns {Array<{ relPath: string, mtime: number, size: number, frontmatter: Record<string, unknown>, body: string, schemaValid: boolean }>}
382
390
  */
383
391
  export function listNotes(projectRoot, opts = {}) {
384
- const { vaultRoot } = resolveVault(projectRoot);
385
- const root = opts.root || vaultRoot;
392
+ const { projectVaultRoot } = resolveVault(projectRoot);
393
+ const root = opts.root || projectVaultRoot;
386
394
  if (!existsSync(root)) return [];
387
395
 
388
396
  const namespace = opts.namespace || '';
@@ -425,8 +433,16 @@ export function listNotes(projectRoot, opts = {}) {
425
433
  * @returns {{ relPath: string, frontmatter: Record<string, unknown>, body: string, raw: string, mtime: number, size: number, schemaValid: boolean } | null}
426
434
  */
427
435
  export function readNote(projectRoot, relPath, opts = {}) {
428
- const vaultInfo = resolveVault(projectRoot);
429
- const root = opts.root || vaultInfo.vaultRoot;
436
+ const { vaultRoot, projectVaultRoot } = resolveVault(projectRoot);
437
+ // When opts.root is explicitly provided, use it. Otherwise route based on
438
+ // namespace so reads find notes where writeNote placed them.
439
+ let root;
440
+ if (opts.root) {
441
+ root = opts.root;
442
+ } else {
443
+ const isProjectNamespace = !relPath.startsWith('global/') && !relPath.startsWith('users/');
444
+ root = isProjectNamespace ? projectVaultRoot : vaultRoot;
445
+ }
430
446
  const filePath = resolveSafe(root, relPath);
431
447
  if (!filePath || !existsSync(filePath)) return null;
432
448
  try {
@@ -450,7 +466,7 @@ export function readNote(projectRoot, relPath, opts = {}) {
450
466
  * @throws {Error} if schema validation fails or HIGH-severity secret found
451
467
  */
452
468
  export function writeNote(projectRoot, relPath, { frontmatter, body }) {
453
- const { vaultRoot } = resolveVault(projectRoot);
469
+ const { vaultRoot, projectVaultRoot } = resolveVault(projectRoot);
454
470
 
455
471
  // Validate frontmatter
456
472
  const { valid, errors, warnings } = validateNote(frontmatter, body);
@@ -473,13 +489,20 @@ export function writeNote(projectRoot, relPath, { frontmatter, body }) {
473
489
  throw err;
474
490
  }
475
491
 
492
+ // Determine the effective vault root based on namespace.
493
+ // Project notes (relPaths that don't start with global/ or users/) go under
494
+ // projectVaultRoot. Global and user notes go under vaultRoot.
495
+ // This matches the layout resolveNamespaceRoot returns for each namespace.
496
+ const isProjectNamespace = !relPath.startsWith('global/') && !relPath.startsWith('users/');
497
+ const noteVaultRoot = isProjectNamespace ? projectVaultRoot : vaultRoot;
498
+
476
499
  // Build file content
477
- const filePath = resolveSafe(vaultRoot, relPath);
500
+ const filePath = resolveSafe(noteVaultRoot, relPath);
478
501
  if (!filePath) {
479
502
  throw new Error(`Invalid note path: ${relPath}`);
480
503
  }
481
504
 
482
- // Lazily create the project namespace directory (F7 — not created at initVault)
505
+ // Lazily create the namespace directory (not created at initVault)
483
506
  const nsDir = dirname(filePath);
484
507
  if (!existsSync(nsDir)) {
485
508
  mkdirSync(nsDir, { recursive: true });
@@ -538,8 +561,16 @@ export function writeNote(projectRoot, relPath, { frontmatter, body }) {
538
561
  * @returns {boolean}
539
562
  */
540
563
  export function deleteNote(projectRoot, relPath, opts = {}) {
541
- const vaultInfo = resolveVault(projectRoot);
542
- const root = opts.root || vaultInfo.vaultRoot;
564
+ const { vaultRoot, projectVaultRoot } = resolveVault(projectRoot);
565
+ // When opts.root is explicitly provided, use it. Otherwise route based on
566
+ // namespace so deletes find notes where writeNote placed them.
567
+ let root;
568
+ if (opts.root) {
569
+ root = opts.root;
570
+ } else {
571
+ const isProjectNamespace = !relPath.startsWith('global/') && !relPath.startsWith('users/');
572
+ root = isProjectNamespace ? projectVaultRoot : vaultRoot;
573
+ }
543
574
  const filePath = resolveSafe(root, relPath);
544
575
  if (!filePath || !existsSync(filePath)) return false;
545
576
  unlinkSync(filePath);
@@ -560,8 +591,23 @@ export function searchVault(projectRoot, query, { limit = 25 } = {}) {
560
591
  const tokens = q.split(/\s+/).filter((t) => t.length >= 2);
561
592
  if (tokens.length === 0) return [];
562
593
 
594
+ const { vaultRoot, projectVaultRoot } = resolveVault(projectRoot);
595
+
596
+ // Collect notes from all namespaces by walking vaultRoot (which contains
597
+ // projects/<id>/, global/, and users/ as siblings). This finds project,
598
+ // global, and user notes in a single scan. relPaths returned are vaultRoot-
599
+ // relative (e.g. "projects/<id>/decisions/foo.md" or "global/bizar/foo.md").
600
+ // For project notes, strip the "projects/<id>/" prefix so the relPath matches
601
+ // what writeNote uses (projectVaultRoot-relative).
602
+ const projectPrefix = join('projects', projectVaultRoot.split('/').pop());
603
+ const allNotes = listNotes(projectRoot, { root: vaultRoot });
604
+
563
605
  const results = [];
564
- for (const note of listNotes(projectRoot)) {
606
+ for (const note of allNotes) {
607
+ let relPath = note.relPath;
608
+ if (relPath.startsWith(projectPrefix)) {
609
+ relPath = relPath.slice(projectPrefix.length + 1);
610
+ }
565
611
  const lower = (note.body + ' ' + JSON.stringify(note.frontmatter)).toLowerCase();
566
612
  let score = 0;
567
613
  for (const t of tokens) {
@@ -572,7 +618,7 @@ export function searchVault(projectRoot, query, { limit = 25 } = {}) {
572
618
  const start = Math.max(0, idx - 60);
573
619
  const end = Math.min(lower.length, idx + 160);
574
620
  const snippet = (start > 0 ? '…' : '') + lower.slice(start, end).replace(/\s+/g, ' ').trim();
575
- results.push({ relPath: note.relPath, snippet, score, mtime: note.mtime });
621
+ results.push({ relPath, snippet, score, mtime: note.mtime });
576
622
  }
577
623
 
578
624
  return results.sort((a, b) => b.score - a.score).slice(0, limit);
@@ -707,8 +753,8 @@ export function vaultStats(projectRoot) {
707
753
  };
708
754
  }
709
755
 
710
- const { vaultRoot, mode, branch } = resolveVault(projectRoot);
711
- if (!vaultRoot || !existsSync(vaultRoot)) {
756
+ const { vaultRoot, projectVaultRoot, mode, branch } = resolveVault(projectRoot);
757
+ if (!projectVaultRoot || !existsSync(projectVaultRoot)) {
712
758
  return {
713
759
  exists: true,
714
760
  vaultRoot: vaultRoot || '',
@@ -737,7 +783,7 @@ export function vaultStats(projectRoot) {
737
783
  try {
738
784
  const { isGitInstalled, status: gitStatus } = memoryGit;
739
785
  if (isGitInstalled()) {
740
- const gs = gitStatus(vaultRoot);
786
+ const gs = gitStatus(projectVaultRoot);
741
787
  gitClean = gs.clean;
742
788
  gitBranch = gs.branch;
743
789
  }
@@ -9,6 +9,8 @@
9
9
  * is kept as a backwards-compatible alias).
10
10
  *
11
11
  * v0.1.0 — initial implementation
12
+ * v5.5.1 — added `getOpencodeSdkOrThrow()` and `subscribeToSession()`
13
+ * for the SDK-based bg spawner (see bg-spawner.mjs).
12
14
  */
13
15
 
14
16
  import { readServeInfo, pingOpencodeServe as _pingOpencodeServe } from "./serve-info.mjs";
@@ -18,7 +20,10 @@ let _sdkInfo = null;
18
20
 
19
21
  /**
20
22
  * Lazily create and cache an opencode SDK instance from the active
21
- * serve-info. Returns `null` when no serve-info is available.
23
+ * serve-info. Returns `null` when no serve-info is available OR when
24
+ * the SDK package is not installed (e.g. in a fresh checkout that
25
+ * hasn't run `npm install` yet — the dashboard's serve-info read
26
+ * succeeds but the dynamic import fails).
22
27
  *
23
28
  * @returns {Promise<import('@polderlabs/bizar-sdk').OpencodeSdk | null>}
24
29
  */
@@ -31,7 +36,19 @@ export async function getOpencodeSdk() {
31
36
  return _sdk;
32
37
  }
33
38
 
34
- const { createOpencodeSdk } = await import("@polderlabs/bizar-sdk/opencode");
39
+ let createOpencodeSdk;
40
+ try {
41
+ const mod = await import("@polderlabs/bizar-sdk/opencode");
42
+ createOpencodeSdk = mod.createOpencodeSdk;
43
+ if (typeof createOpencodeSdk !== "function") createOpencodeSdk = null;
44
+ } catch {
45
+ // SDK package not installed / not built. Return null so the caller
46
+ // can degrade gracefully. The bg-spawner converts this into an
47
+ // `opencode_serve_unavailable` error.
48
+ return null;
49
+ }
50
+ if (!createOpencodeSdk) return null;
51
+
35
52
  const sdk = await createOpencodeSdk({
36
53
  baseUrl: info.baseUrl,
37
54
  password: info.password,
@@ -43,6 +60,49 @@ export async function getOpencodeSdk() {
43
60
  return sdk;
44
61
  }
45
62
 
63
+ /**
64
+ * v5.5.1 — Same as {@link getOpencodeSdk} but throws when no SDK is
65
+ * available. Use this from code paths that REQUIRE the opencode serve
66
+ * (e.g. the SDK-based bg spawner) — callers want a clear error rather
67
+ * than a silent `null` they have to remember to check.
68
+ *
69
+ * @returns {Promise<import('@polderlabs/bizar-sdk').OpencodeSdk>}
70
+ */
71
+ export async function getOpencodeSdkOrThrow() {
72
+ const sdk = await getOpencodeSdk();
73
+ if (!sdk) {
74
+ throw new Error(
75
+ "opencode_serve_unavailable: no serve-info file found. " +
76
+ "The opencode serve child is not running. Start it via `bizar serve` " +
77
+ "or run an opencode session so the plugin can write serve-info.",
78
+ );
79
+ }
80
+ return sdk;
81
+ }
82
+
83
+ /**
84
+ * v5.5.1 — Subscribe to opencode SSE events for a single session. Thin
85
+ * wrapper over `sdk.events.subscribe({ sessionID })` that closes the
86
+ * subscription cleanly when the returned async iterable is broken.
87
+ *
88
+ * Returns `{ stream, close }` — `stream` is an `AsyncIterable` of
89
+ * `OpencodeEventEnvelope` objects; `close()` aborts the underlying SSE
90
+ * connection.
91
+ *
92
+ * @param {string} sessionId
93
+ * @returns {Promise<AsyncIterable<unknown> & { close: () => void } | null>}
94
+ */
95
+ export async function subscribeToSession(sessionId) {
96
+ const sdk = await getOpencodeSdk();
97
+ if (!sdk || !sessionId) return null;
98
+ try {
99
+ const sub = await sdk.events.subscribe({ sessionID: sessionId });
100
+ return sub;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
46
106
  /**
47
107
  * Ping the opencode serve child using the SDK's health endpoint.
48
108
  * Falls back to TCP-connect ping if the SDK health check fails.
@@ -69,4 +129,4 @@ export async function pingOpencodeSdk(info) {
69
129
  * Backwards-compatible alias for `pingOpencodeSdk`.
70
130
  * @deprecated Use `pingOpencodeSdk` instead.
71
131
  */
72
- export const pingOpencodeServe = pingOpencodeSdk;
132
+ export const pingOpencodeServe = pingOpencodeSdk;
@@ -44,7 +44,7 @@ import * as logger from '../logger.mjs';
44
44
  * deployments, point this at an internal CDN or S3 bucket.
45
45
  */
46
46
  const DEFAULT_REGISTRY_URL =
47
- 'https://raw.githubusercontent.com/DrB0rk/bizar-plugins/main/registry.json';
47
+ 'https://raw.githubusercontent.com/DrB0rk/bizar-mods/main/registry.json';
48
48
 
49
49
  /**
50
50
  * Fallback registry URLs tried in order when the primary URL fails.
@@ -2,6 +2,7 @@
2
2
  * src/server/routes/activity.mjs
3
3
  *
4
4
  * /api/activity — full activity log (newest first)
5
+ * /api/activity/stream — SSE stream of activity events
5
6
  * /api/activity/hidden — list of hidden event keys (for overview hide)
6
7
  * /api/activity/hide — POST { keys: [...] } add to hidden
7
8
  * /api/activity/hide — DELETE clear all hidden
@@ -106,5 +107,89 @@ export function createActivityRouter({ state } = {}) {
106
107
  res.json(data);
107
108
  }));
108
109
 
110
+ // GET /activity/stream — SSE stream of activity events.
111
+ //
112
+ // The Overview tab (Overview.tsx) opens this SSE connection to get live
113
+ // activity updates without polling. Two event types are sent:
114
+ // event: snapshot\ndata: { events: ActivityItem[], generatedAt: string }\n\n
115
+ // event: activity\ndata: ActivityItem\n\n
116
+ //
117
+ // The SSE lifetime is unbounded; the client auto-reconnects on drop.
118
+ // We send a 25s heartbeat to keep the connection alive through proxies.
119
+ router.get('/activity/stream', (req, res) => {
120
+ res.status(200);
121
+ res.setHeader('Content-Type', 'text/event-stream');
122
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
123
+ res.setHeader('Connection', 'keep-alive');
124
+ res.setHeader('X-Accel-Buffering', 'no');
125
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
126
+
127
+ let closed = false;
128
+
129
+ // Send current snapshot immediately.
130
+ try {
131
+ const overview = state?.getOverview?.();
132
+ const events = Array.isArray(overview?.recentActivity)
133
+ ? overview.recentActivity.slice(0, 50)
134
+ : [];
135
+ res.write(`event: snapshot\ndata: ${JSON.stringify({ events, generatedAt: overview?.generatedAt || new Date().toISOString() })}\n\n`);
136
+ } catch {
137
+ res.write(`event: snapshot\ndata: ${JSON.stringify({ events: [], generatedAt: new Date().toISOString() })}\n\n`);
138
+ }
139
+
140
+ // Poll for new activity every 5 seconds.
141
+ // Reads recentActivity from state and emits only genuinely new entries
142
+ // (identified by ts, which is set at write time).
143
+ let knownLastTs = null;
144
+ const pollInterval = setInterval(() => {
145
+ if (closed || res.writableEnded || res.destroyed) {
146
+ clearInterval(pollInterval);
147
+ return;
148
+ }
149
+ try {
150
+ const overview = state?.getOverview?.();
151
+ const events = Array.isArray(overview?.recentActivity)
152
+ ? overview.recentActivity
153
+ : [];
154
+ if (events.length === 0) return;
155
+
156
+ const newestTs = events[0].ts;
157
+ // Only emit if there's a genuinely newer event (newer timestamp than
158
+ // what we last sent, or a new entry with the same timestamp but
159
+ // different content).
160
+ if (knownLastTs === null || newestTs !== knownLastTs) {
161
+ const newEvents = knownLastTs === null
162
+ ? events
163
+ : events.filter((e) => e.ts !== knownLastTs);
164
+ for (const entry of newEvents) {
165
+ res.write(`event: activity\ndata: ${JSON.stringify(entry)}\n\n`);
166
+ }
167
+ knownLastTs = newestTs;
168
+ }
169
+ } catch {
170
+ /* ignore poll errors */
171
+ }
172
+ }, 5_000);
173
+
174
+ // Heartbeat to keep the connection alive through reverse proxies.
175
+ const heartbeat = setInterval(() => {
176
+ if (closed || res.writableEnded || res.destroyed) {
177
+ clearInterval(heartbeat);
178
+ return;
179
+ }
180
+ try {
181
+ res.write(': keepalive\n\n');
182
+ } catch {
183
+ clearInterval(heartbeat);
184
+ }
185
+ }, 25_000);
186
+
187
+ req.on('close', () => {
188
+ closed = true;
189
+ clearInterval(pollInterval);
190
+ clearInterval(heartbeat);
191
+ });
192
+ });
193
+
109
194
  return router;
110
195
  }
@@ -2,20 +2,26 @@
2
2
  * src/server/routes/background.mjs
3
3
  *
4
4
  * /api/background — list
5
- * /api/background (POST) — spawn from UI (v5.x)
5
+ * /api/background (POST) — spawn from UI (v5.x; v5.5.1 SDK-backed)
6
6
  * /api/background/:id — single instance
7
7
  * /api/background/:id/output — tail captured output
8
8
  * /api/background/:id/tool-calls (GET) — tool call history (v5.x)
9
9
  * /api/background/:id/tmux — tmux attach metadata
10
10
  * /api/background/:id/message (POST) — send a follow-up message
11
- * /api/background/:id/pause (POST) — pause subprocess (v5.x)
12
- * /api/background/:id/resume (POST) — resume subprocess (v5.x)
13
- * /api/background/:id/steer (POST) — kill+respawn with new prompt (v5.x)
11
+ * /api/background/:id/pause (POST) — pause (v5.x SIGSTOP / v5.5.1 output pause)
12
+ * /api/background/:id/resume (POST) — resume (v5.x SIGCONT / v5.5.1 drain)
13
+ * /api/background/:id/steer (POST) — TRUE mid-flight prompt (v5.5.1)
14
14
  * /api/background/:id/retry (POST) — manual unstick (v3.11.0)
15
15
  * /api/background/:id (DELETE) — kill
16
16
  *
17
17
  * Backed by the opencode-plugin's bg instance store. Imports the
18
18
  * store lazily so this module loads even when the plugin is offline.
19
+ *
20
+ * v5.5.1: the spawner is now SDK-based (see ../bg-spawner.mjs).
21
+ * `POST /api/background/:id/steer` performs a true mid-flight prompt
22
+ * (no kill+respawn); the response shape stays backwards compatible with
23
+ * the v5.5.0 contract except `newInstanceId` is `null` (same instance
24
+ * is reused) and a new `mode: "true_midflight"` field is added.
19
25
  */
20
26
  import { Router } from 'express';
21
27
  import { wrap } from './_shared.mjs';
@@ -146,7 +152,16 @@ export function createBackgroundRouter({ broadcast }) {
146
152
  res.json({ ok: true, status: 'running' });
147
153
  }));
148
154
 
149
- // v5.x — Steer (kill+restart with appended prompt). Body: { message }.
155
+ // v5.5.1 — Steer is now TRUE mid-flight: the dashboard calls
156
+ // sdk.sessions.prompt() on the live opencode session. No kill+respawn.
157
+ // The response shape is the same `{ ok, newInstanceId?, processId? }` —
158
+ // `newInstanceId` is omitted (the same instance is reused); the
159
+ // `mode` field tells the dashboard this was a true mid-flight steer.
160
+ //
161
+ // Backwards compat: clients that read `newInstanceId` from the response
162
+ // see `null` in v5.5.1 (the field is dropped, not set to the same id —
163
+ // explicitly set to `null` so a test can tell the new behavior apart
164
+ // from the old kill+respawn path).
150
165
  router.post('/background/:id/steer', wrap(async (req, res) => {
151
166
  const message = String((req.body && req.body.message) || '').trim();
152
167
  if (!message) {
@@ -162,16 +177,19 @@ export function createBackgroundRouter({ broadcast }) {
162
177
  broadcast({
163
178
  type: 'background:change',
164
179
  id: req.params.id,
165
- status: 'steered',
166
- newInstanceId: result.newInstanceId,
180
+ status: 'running',
181
+ action: 'steer',
182
+ steerCount: result.steerCount,
167
183
  });
168
- broadcast({
169
- type: 'background:change',
170
- id: result.newInstanceId,
171
- status: 'pending',
172
- parentInstanceId: req.params.id,
184
+ res.json({
185
+ ok: true,
186
+ mode: result.mode || 'true_midflight',
187
+ // Same instance — explicitly null so dashboards/tests can detect
188
+ // the new behavior (the v5.5.0 contract returned a new id here).
189
+ newInstanceId: null,
190
+ instanceId: req.params.id,
191
+ steerCount: result.steerCount,
173
192
  });
174
- res.json({ ok: true, status: 'steered', newInstanceId: result.newInstanceId, processId: result.processId });
175
193
  }));
176
194
 
177
195
  // v3.5.5 — Tmux session metadata. The UI uses this to render an