@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.
- package/bizar-dash/dist/assets/{EnvVarsSection-L413CQEI.js → EnvVarsSection-DoIzqrlb.js} +3 -3
- package/bizar-dash/dist/assets/{EnvVarsSection-L413CQEI.js.map → EnvVarsSection-DoIzqrlb.js.map} +1 -1
- package/bizar-dash/dist/assets/{MobileChat-0kGQb1S6.js → MobileChat-CoUCUsbr.js} +1 -1
- package/bizar-dash/dist/assets/{MobileChat-0kGQb1S6.js.map → MobileChat-CoUCUsbr.js.map} +1 -1
- package/bizar-dash/dist/assets/MobileSettings-BeO04g52.js +1 -0
- package/bizar-dash/dist/assets/MobileSettings-BeO04g52.js.map +1 -0
- package/bizar-dash/dist/assets/{Toast-C6f1zFJ2.js → Toast-D9nv5N6m.js} +1 -1
- package/bizar-dash/dist/assets/{Toast-C6f1zFJ2.js.map → Toast-D9nv5N6m.js.map} +1 -1
- package/bizar-dash/dist/assets/{icons-BsWXWkEF.js → icons-Btipv2pp.js} +100 -90
- package/bizar-dash/dist/assets/icons-Btipv2pp.js.map +1 -0
- package/bizar-dash/dist/assets/main-DHXPuddY.js +18 -0
- package/bizar-dash/dist/assets/main-DHXPuddY.js.map +1 -0
- package/bizar-dash/dist/assets/{main-BklYy02e.css → main-UG4jKK-F.css} +1 -1
- package/bizar-dash/dist/assets/mobile-H5KH9cTL.js +1 -0
- package/bizar-dash/dist/assets/{mobile-CsaN4CxH.js.map → mobile-H5KH9cTL.js.map} +1 -1
- package/bizar-dash/dist/assets/{mobile-layout-iFBdjEE4.js → mobile-layout-BqHxVb6_.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-layout-iFBdjEE4.js.map → mobile-layout-BqHxVb6_.js.map} +1 -1
- package/bizar-dash/dist/assets/{useSlashCommands-DGJcC9iQ.js → useSlashCommands-C24-O1e7.js} +2 -2
- package/bizar-dash/dist/assets/{useSlashCommands-DGJcC9iQ.js.map → useSlashCommands-C24-O1e7.js.map} +1 -1
- package/bizar-dash/dist/index.html +7 -7
- package/bizar-dash/dist/mobile.html +3 -3
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/server/bg-spawner.mjs +522 -305
- package/bizar-dash/src/server/headroom.mjs +3 -1
- package/bizar-dash/src/server/memory-lightrag.mjs +26 -2
- package/bizar-dash/src/server/memory-store.mjs +74 -28
- package/bizar-dash/src/server/opencode-sdk.mjs +63 -3
- package/bizar-dash/src/server/plugins/registry.mjs +1 -1
- package/bizar-dash/src/server/routes/activity.mjs +85 -0
- package/bizar-dash/src/server/routes/background.mjs +31 -13
- package/bizar-dash/src/server/routes/memory.mjs +91 -38
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +1 -1
- package/bizar-dash/src/server/server.mjs +4 -1
- package/bizar-dash/src/web/components/Sidebar.tsx +0 -1
- package/bizar-dash/src/web/styles/main.css +52 -13
- package/bizar-dash/src/web/styles/memory.css +17 -0
- package/bizar-dash/src/web/styles/settings.css +6 -56
- package/bizar-dash/src/web/views/Marketplace.tsx +192 -31
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +157 -7
- package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +11 -0
- package/bizar-dash/src/web/views/settings/MemorySection.tsx +65 -11
- package/bizar-dash/tests/background-sdk-session.test.mjs +94 -0
- package/bizar-dash/tests/background-session-events.test.mjs +74 -0
- package/bizar-dash/tests/background-steer-sdk.test.mjs +71 -0
- package/bizar-dash/tests/background-steer.test.mjs +25 -7
- package/bizar-dash/tests/memory-namespace.test.mjs +2 -2
- package/bizar-dash/tests/memory-status-root.test.mjs +144 -0
- package/bizar-dash/tests/memory-store.test.mjs +5 -4
- package/bizar-dash/tests/memory-vault-config-endpoint.test.mjs +173 -0
- package/bizar-dash/tests/settings-mode-wiring.test.tsx +1 -1
- package/bizar-dash/tests/settings-nav.test.tsx +0 -1
- package/bizar-dash/tests/views/Marketplace.test.tsx +200 -0
- package/bizar-dash/tests/views/Memory.test.tsx +274 -0
- package/bizar-dash/tests/views/sidebar.test.tsx +116 -0
- package/bizar-dash/tests/views/topbar.test.tsx +87 -0
- package/cli/commands/marketplace.mjs +1 -1
- package/cli/memory.mjs +1 -1
- package/package.json +1 -1
- package/plugins/bizar/src/background-state.ts +11 -0
- package/plugins/bizar/src/background.ts +212 -34
- package/plugins/bizar/src/opencode-runner.ts +80 -353
- package/plugins/bizar/src/tools/bg-send-message.ts +160 -61
- package/plugins/bizar/src/tools/bg-spawn.ts +235 -156
- package/plugins/bizar/tests/tools/bg-send-message.test.ts +84 -11
- package/plugins/bizar/tests/tools/bg-spawn-http.test.ts +223 -0
- package/bizar-dash/dist/assets/MobileSettings-Cq1oFIdO.js +0 -1
- package/bizar-dash/dist/assets/MobileSettings-Cq1oFIdO.js.map +0 -1
- package/bizar-dash/dist/assets/icons-BsWXWkEF.js.map +0 -1
- package/bizar-dash/dist/assets/main-CBTgYSeG.js +0 -18
- package/bizar-dash/dist/assets/main-CBTgYSeG.js.map +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
249
|
-
*
|
|
250
|
-
* `
|
|
251
|
-
*
|
|
252
|
-
*
|
|
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
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
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.
|
|
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 {
|
|
385
|
-
const root = opts.root ||
|
|
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
|
|
429
|
-
|
|
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(
|
|
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
|
|
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
|
|
542
|
-
|
|
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
|
|
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
|
|
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 (!
|
|
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(
|
|
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
|
-
|
|
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-
|
|
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
|
|
12
|
-
* /api/background/:id/resume (POST) — resume
|
|
13
|
-
* /api/background/:id/steer (POST) —
|
|
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.
|
|
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: '
|
|
166
|
-
|
|
180
|
+
status: 'running',
|
|
181
|
+
action: 'steer',
|
|
182
|
+
steerCount: result.steerCount,
|
|
167
183
|
});
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|