@polderlabs/bizar 5.4.0 → 5.5.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.
Files changed (99) hide show
  1. package/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js → EnvVarsSection-L413CQEI.js} +3 -3
  2. package/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js.map → EnvVarsSection-L413CQEI.js.map} +1 -1
  3. package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js → MobileChat-0kGQb1S6.js} +1 -1
  4. package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js.map → MobileChat-0kGQb1S6.js.map} +1 -1
  5. package/bizar-dash/dist/assets/MobileSettings-Cq1oFIdO.js +1 -0
  6. package/bizar-dash/dist/assets/{MobileSettings-CVW9VhEo.js.map → MobileSettings-Cq1oFIdO.js.map} +1 -1
  7. package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js → Toast-C6f1zFJ2.js} +1 -1
  8. package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js.map → Toast-C6f1zFJ2.js.map} +1 -1
  9. package/bizar-dash/dist/assets/{icons-OMEGx5KA.js → icons-BsWXWkEF.js} +121 -126
  10. package/bizar-dash/dist/assets/icons-BsWXWkEF.js.map +1 -0
  11. package/bizar-dash/dist/assets/main-BklYy02e.css +1 -0
  12. package/bizar-dash/dist/assets/main-CBTgYSeG.js +18 -0
  13. package/bizar-dash/dist/assets/main-CBTgYSeG.js.map +1 -0
  14. package/bizar-dash/dist/assets/mobile-CsaN4CxH.js +1 -0
  15. package/bizar-dash/dist/assets/{mobile-XN2P7YfM.js.map → mobile-CsaN4CxH.js.map} +1 -1
  16. package/bizar-dash/dist/assets/{mobile-layout-D6V-7WnA.css → mobile-layout-CJnZNLy3.css} +1 -1
  17. package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js +2 -0
  18. package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js.map +1 -0
  19. package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js → useSlashCommands-DGJcC9iQ.js} +2 -2
  20. package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js.map → useSlashCommands-DGJcC9iQ.js.map} +1 -1
  21. package/bizar-dash/dist/index.html +8 -8
  22. package/bizar-dash/dist/mobile.html +4 -4
  23. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  24. package/bizar-dash/src/server/backup-store.mjs +6 -1
  25. package/bizar-dash/src/server/bg-spawner.mjs +574 -0
  26. package/bizar-dash/src/server/memory-lightrag.mjs +242 -0
  27. package/bizar-dash/src/server/memory-store.mjs +43 -3
  28. package/bizar-dash/src/server/routes/background.mjs +143 -0
  29. package/bizar-dash/src/server/routes/lightrag.mjs +10 -0
  30. package/bizar-dash/src/server/routes/memory.mjs +8 -0
  31. package/bizar-dash/src/server/server.mjs +22 -0
  32. package/bizar-dash/src/web/App.tsx +3 -0
  33. package/bizar-dash/src/web/MobileApp.tsx +1 -10
  34. package/bizar-dash/src/web/components/InstallConfirmDialog.tsx +1 -1
  35. package/bizar-dash/src/web/components/SettingsNav.tsx +1 -10
  36. package/bizar-dash/src/web/lib/types.ts +39 -1
  37. package/bizar-dash/src/web/styles/main.css +43 -0
  38. package/bizar-dash/src/web/styles/mobile-layout.css +7 -5
  39. package/bizar-dash/src/web/styles/settings.css +46 -0
  40. package/bizar-dash/src/web/views/BackgroundAgents.tsx +399 -30
  41. package/bizar-dash/src/web/views/Settings.tsx +12 -12
  42. package/bizar-dash/src/web/views/SpawnAgentModal.tsx +221 -0
  43. package/bizar-dash/tests/BackgroundAgents.test.tsx +160 -0
  44. package/bizar-dash/tests/background-pause-resume.test.mjs +46 -0
  45. package/bizar-dash/tests/background-spawn-from-ui.test.mjs +36 -0
  46. package/bizar-dash/tests/background-steer.test.mjs +42 -0
  47. package/bizar-dash/tests/lightrag-startup-hook.test.mjs +206 -0
  48. package/bizar-dash/tests/memory-auto-reindex.test.mjs +196 -0
  49. package/bizar-dash/tests/memory-cli.test.mjs +1 -1
  50. package/bizar-dash/tests/memory-default-vault.test.mjs +90 -3
  51. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +131 -0
  52. package/bizar-dash/tests/mobile-layout.test.tsx +36 -0
  53. package/bizar-dash/tests/settings-layout.test.tsx +3 -3
  54. package/bizar-dash/tests/settings-nav.test.tsx +7 -5
  55. package/cli/bin.mjs +16 -0
  56. package/cli/commands/deploy/docker.mjs +2 -1
  57. package/cli/commands/lightrag.mjs +171 -0
  58. package/cli/commands/service.mjs +7 -0
  59. package/cli/commands/util.mjs +8 -1
  60. package/cli/init.mjs +1 -1
  61. package/cli/post-install-smoke.mjs +230 -0
  62. package/cli/provision.mjs +177 -0
  63. package/cli/provision.test.mjs +180 -0
  64. package/cli/service-controller.mjs +378 -28
  65. package/cli/service-controller.test.mjs +100 -0
  66. package/cli/service-env.mjs +139 -0
  67. package/cli/service.mjs +23 -0
  68. package/config/agents/_shared/AGENT_BASELINE.md +6 -6
  69. package/install.sh +87 -6
  70. package/package.json +1 -1
  71. package/plugins/bizar/index.ts +147 -0
  72. package/plugins/bizar/src/background-state.ts +84 -5
  73. package/plugins/bizar/src/background.ts +388 -10
  74. package/plugins/bizar/src/hooks/memory-inject.ts +247 -0
  75. package/plugins/bizar/src/hooks/memory-write-on-end.ts +188 -0
  76. package/plugins/bizar/src/opencode-runner.ts +87 -1
  77. package/plugins/bizar/src/tools/bg-pause.ts +77 -0
  78. package/plugins/bizar/src/tools/bg-report-progress.ts +101 -0
  79. package/plugins/bizar/src/tools/bg-resume.ts +72 -0
  80. package/plugins/bizar/src/tools/bg-send-message.ts +105 -0
  81. package/plugins/bizar/src/tools/bg-spawn.ts +13 -6
  82. package/plugins/bizar/src/tools/bg-status.ts +10 -0
  83. package/plugins/bizar/src/tools/memory-list.ts +135 -0
  84. package/plugins/bizar/src/tools/memory-read.ts +142 -0
  85. package/plugins/bizar/src/tools/memory-search.ts +228 -0
  86. package/plugins/bizar/src/tools/memory-write.ts +183 -0
  87. package/plugins/bizar/tests/memory-write-on-end.test.ts +92 -0
  88. package/plugins/bizar/tests/tools/bg-pause.test.ts +64 -0
  89. package/plugins/bizar/tests/tools/bg-report-progress.test.ts +82 -0
  90. package/plugins/bizar/tests/tools/bg-resume.test.ts +43 -0
  91. package/plugins/bizar/tests/tools/bg-send-message.test.ts +46 -0
  92. package/bizar-dash/dist/assets/MobileSettings-CVW9VhEo.js +0 -1
  93. package/bizar-dash/dist/assets/icons-OMEGx5KA.js.map +0 -1
  94. package/bizar-dash/dist/assets/main-CMAGZouj.js +0 -16
  95. package/bizar-dash/dist/assets/main-CMAGZouj.js.map +0 -1
  96. package/bizar-dash/dist/assets/main-O2tZVdHm.css +0 -1
  97. package/bizar-dash/dist/assets/mobile-XN2P7YfM.js +0 -1
  98. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js +0 -2
  99. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js.map +0 -1
@@ -40,6 +40,7 @@ import { homedir, platform } from 'node:os';
40
40
  import { join, dirname, resolve, isAbsolute } from 'node:path';
41
41
  import { fileURLToPath } from 'node:url';
42
42
  import { bizarConfigDir } from './utils.mjs';
43
+ import { buildServiceEnvFile } from './service-env.mjs';
43
44
 
44
45
  const __filename = fileURLToPath(import.meta.url);
45
46
  const __dirname = dirname(__filename);
@@ -101,6 +102,9 @@ function linuxUnitPath() {
101
102
  function linuxEnvPath() {
102
103
  return join(bizarConfigDir(), 'service.env');
103
104
  }
105
+ function linuxHeadroomUnitPath() {
106
+ return join(userUnitDir(), 'bizar-headroom.service');
107
+ }
104
108
  function darwinPlistPath() {
105
109
  return join(launchAgentsDir(), 'com.bizar.dashboard.plist');
106
110
  }
@@ -140,7 +144,13 @@ function linuxUnitContent({ nodePath, projectRoot }) {
140
144
  const cliEntry = join(projectRoot, 'cli', 'bin.mjs');
141
145
  return [
142
146
  '[Unit]',
143
- 'Description=Bizar background service daemon',
147
+ 'Description=Bizar background service daemon (schedules + task-delegator)',
148
+ // v5.x — issue #7 — Make the service unit more robust.
149
+ // Bump StartLimitInterval so transient failures don't trip the
150
+ // systemd rate-limit (default 10s / 5 starts is too aggressive
151
+ // for a service that restarts after `bizar update`).
152
+ 'StartLimitIntervalSec=300',
153
+ 'StartLimitBurst=10',
144
154
  'After=network-online.target',
145
155
  'Wants=network-online.target',
146
156
  '',
@@ -148,9 +158,15 @@ function linuxUnitContent({ nodePath, projectRoot }) {
148
158
  'Type=simple',
149
159
  `EnvironmentFile=${envPath}`,
150
160
  `ExecStart=${nodePath} ${cliEntry} service _daemon`,
151
- 'Restart=always',
161
+ // Restart=on-failure means we only auto-restart on actual errors
162
+ // (non-zero exit). The default `always` would also restart on
163
+ // SIGTERM (which the user just used to stop us), making manual
164
+ // `systemctl --user stop` a fight against the supervisor.
165
+ 'Restart=on-failure',
152
166
  'RestartSec=5',
153
167
  'TimeoutStopSec=20',
168
+ // Hard ceiling so a runaway daemon doesn't pin a CPU forever.
169
+ 'CPUQuota=10%',
154
170
  '',
155
171
  '[Install]',
156
172
  'WantedBy=default.target',
@@ -159,21 +175,163 @@ function linuxUnitContent({ nodePath, projectRoot }) {
159
175
  }
160
176
 
161
177
  function linuxEnvFileContent({ projectRoot }) {
162
- const lines = [
163
- `# Generated by bizar service install — do not edit by hand.`,
164
- `# Source this file from your shell to get the same env that the daemon sees.`,
165
- `BIZAR_HOME=${bizarConfigDir()}`,
166
- `PATH=${process.env.PATH || '/usr/local/bin:/usr/bin:/bin'}`,
167
- `BIZAR_REPO=${projectRoot}`,
168
- ];
169
- // Secrets are NOT written here. A user can append OPENCODE_SERVER_PASSWORD
170
- // manually, but the controller never copies secrets into the unit file.
171
- if (process.env.OPENCODE_SERVER_PASSWORD && !process.env.BIZAR_REDACT_PASSWORD) {
172
- // Honored only if the user opts in via BIZAR_REDACT_PASSWORD=1 — default
173
- // is to never persist secrets; the comment above documents this.
174
- lines.push(`OPENCODE_SERVER_PASSWORD=${process.env.OPENCODE_SERVER_PASSWORD}`);
178
+ return buildServiceEnvFile({ bizarHome: bizarConfigDir(), repoPath: projectRoot });
179
+ }
180
+
181
+ // ── Headroom companion service (Linux) ─────────────────────────────────────────
182
+
183
+ function linuxHeadroomUnitContent() {
184
+ const envPath = join(bizarConfigDir(), 'service.env');
185
+ return [
186
+ '[Unit]',
187
+ 'Description=Bizar Headroom context-compression proxy',
188
+ 'After=bizar.service',
189
+ 'Wants=bizar.service',
190
+ '',
191
+ '[Service]',
192
+ 'Type=simple',
193
+ `EnvironmentFile=${envPath}`,
194
+ 'ExecStart=/bin/sh -c "command -v headroom >/dev/null 2>&1 && headroom proxy opencode || exit 0"',
195
+ 'Restart=on-failure',
196
+ 'RestartSec=10',
197
+ 'TimeoutStopSec=15',
198
+ '',
199
+ '[Install]',
200
+ 'WantedBy=default.target',
201
+ '',
202
+ ].join('\n');
203
+ }
204
+
205
+ function darwinHeadroomPlistPath() {
206
+ return join(launchAgentsDir(), 'com.bizar.headroom.plist');
207
+ }
208
+
209
+ function darwinHeadroomPlistContent({ nodePath, projectRoot }) {
210
+ const envContent = buildServiceEnvFile({ bizarHome: bizarConfigDir(), repoPath: projectRoot });
211
+ const envVars = parseEnvFile(envContent);
212
+ const envXml = envVars
213
+ .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
214
+ .join('\n');
215
+ return [
216
+ '<?xml version="1.0" encoding="UTF-8"?>',
217
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
218
+ '<plist version="1.0">',
219
+ '<dict>',
220
+ ' <key>Label</key>',
221
+ ' <string>com.bizar.headroom</string>',
222
+ ' <key>ProgramArguments</key>',
223
+ ' <array>',
224
+ ` <string>${xmlEscape(nodePath)}</string>`,
225
+ ' <string>-c</string>',
226
+ ' <string>command -v headroom >/dev/null 2>&1 && headroom proxy opencode || exit 0</string>',
227
+ ' </array>',
228
+ ' <key>EnvironmentVariables</key>',
229
+ ' <dict>',
230
+ envXml,
231
+ ' </dict>',
232
+ ' <key>KeepAlive</key>',
233
+ ' <true/>',
234
+ ' <key>RunAtLoad</key>',
235
+ ' <true/>',
236
+ ' <key>StandardOutPath</key>',
237
+ ` <string>${xmlEscape(join(bizarConfigDir(), 'headroom.log'))}</string>`,
238
+ ' <key>StandardErrorPath</key>',
239
+ ` <string>${xmlEscape(join(bizarConfigDir(), 'headroom.log'))}</string>`,
240
+ ' <key>WorkingDirectory</key>',
241
+ ` <string>${xmlEscape(projectRoot)}</string>`,
242
+ '</dict>',
243
+ '</plist>',
244
+ '',
245
+ ].join('\n');
246
+ }
247
+
248
+ function installHeadroomServiceLinux({ force = false }) {
249
+ // Honour BIZAR_HEADROOM_AUTOSTART from process.env (already merged by buildServiceEnvFile)
250
+ const headroomAutoStart = process.env.BIZAR_HEADROOM_AUTOSTART !== '0';
251
+ if (!headroomAutoStart) {
252
+ return ok({ note: 'headroom autostart disabled via BIZAR_HEADROOM_AUTOSTART=0' });
253
+ }
254
+ if (!existsSync(userUnitDir())) {
255
+ try { mkdirSync(userUnitDir(), { recursive: true }); } catch (err) {
256
+ return fail(`cannot create ${userUnitDir()}: ${err.message}`);
257
+ }
258
+ }
259
+ const unitPath = linuxHeadroomUnitPath();
260
+ const want = linuxHeadroomUnitContent();
261
+ if (!force && existsSync(unitPath)) {
262
+ const have = readFileSync(unitPath, 'utf8').trim() + '\n';
263
+ if (have === want.trim() + '\n') {
264
+ return ok({ alreadyInstalled: true, unitPath, note: 'headroom unit already matches' });
265
+ }
266
+ }
267
+ try {
268
+ writeFileSync(unitPath, want, { encoding: 'utf8', mode: 0o644 });
269
+ } catch (err) {
270
+ return fail(`write ${unitPath}: ${err.message}`);
271
+ }
272
+ runCmd('systemctl', ['--user', 'daemon-reload']);
273
+ const r = runCmd('systemctl', ['--user', 'enable', '--now', 'bizar-headroom.service']);
274
+ if (r.error || (r.status !== 0 && r.status !== null)) {
275
+ return fail(`headroom service enable failed: ${r.stderr || r.error?.message || `exit ${r.status}`}`, { unitPath });
175
276
  }
176
- return lines.join('\n') + '\n';
277
+ return ok({ unitPath, note: 'headroom companion service installed and started' });
278
+ }
279
+
280
+ function uninstallHeadroomServiceLinux() {
281
+ const unitPath = linuxHeadroomUnitPath();
282
+ if (!existsSync(unitPath)) {
283
+ return ok({ unitPath: null, note: 'no headroom systemd unit installed' });
284
+ }
285
+ runCmd('systemctl', ['--user', 'disable', '--now', 'bizar-headroom.service']);
286
+ try { unlinkSync(unitPath); } catch { /* ignore */ }
287
+ runCmd('systemctl', ['--user', 'daemon-reload']);
288
+ return ok({ unitPath: null, note: 'headroom systemd unit removed' });
289
+ }
290
+
291
+ function installHeadroomServiceDarwin({ nodePath, projectRoot, force = false }) {
292
+ const headroomAutoStart = process.env.BIZAR_HEADROOM_AUTOSTART !== '0';
293
+ if (!headroomAutoStart) {
294
+ return ok({ note: 'headroom autostart disabled via BIZAR_HEADROOM_AUTOSTART=0' });
295
+ }
296
+ const dir = launchAgentsDir();
297
+ if (!existsSync(dir)) {
298
+ try { mkdirSync(dir, { recursive: true }); } catch (err) {
299
+ return fail(`cannot create ${dir}: ${err.message}`);
300
+ }
301
+ }
302
+ const plistPath = darwinHeadroomPlistPath();
303
+ const want = darwinHeadroomPlistContent({ nodePath, projectRoot });
304
+ if (!force && existsSync(plistPath)) {
305
+ const have = readFileSync(plistPath, 'utf8').trim() + '\n';
306
+ if (have === want.trim() + '\n') {
307
+ const lst = runCmd('launchctl', ['list']);
308
+ if (lst.status === 0 && /com\.bizar\.headroom/.test(lst.stdout)) {
309
+ return ok({ alreadyInstalled: true, unitPath: plistPath, note: 'headroom plist matches and is loaded' });
310
+ }
311
+ }
312
+ }
313
+ try {
314
+ writeFileSync(plistPath, want, { encoding: 'utf8', mode: 0o644 });
315
+ try { chmodSync(plistPath, 0o644); } catch { /* best-effort */ }
316
+ } catch (err) {
317
+ return fail(`write ${plistPath}: ${err.message}`);
318
+ }
319
+ runCmd('launchctl', ['unload', plistPath]);
320
+ const load = runCmd('launchctl', ['load', '-w', plistPath]);
321
+ if (load.error || (load.status !== 0 && load.status !== null)) {
322
+ return fail(`launchctl load failed for headroom: ${load.stderr || load.error?.message || `exit ${load.status}`}`, { unitPath: plistPath });
323
+ }
324
+ return ok({ unitPath: plistPath, note: 'headroom companion plist installed and loaded' });
325
+ }
326
+
327
+ function uninstallHeadroomServiceDarwin() {
328
+ const plistPath = darwinHeadroomPlistPath();
329
+ if (!existsSync(plistPath)) {
330
+ return ok({ unitPath: null, note: 'no headroom launchd plist installed' });
331
+ }
332
+ runCmd('launchctl', ['unload', plistPath]);
333
+ try { unlinkSync(plistPath); } catch { /* ignore */ }
334
+ return ok({ unitPath: null, note: 'headroom launchd plist removed' });
177
335
  }
178
336
 
179
337
  function installServiceLinux({ nodePath, projectRoot, force = false }) {
@@ -235,6 +393,13 @@ function installServiceLinux({ nodePath, projectRoot, force = false }) {
235
393
  return fail(`systemctl --user enable --now failed: ${r.stderr || r.error?.message || `exit ${r.status}`}`, { unitPath });
236
394
  }
237
395
 
396
+ // Also install headroom companion service (non-fatal if it fails)
397
+ const hr = installHeadroomServiceLinux({ force });
398
+ if (!hr.ok) {
399
+ // Non-fatal: headroom is optional
400
+ console.log(` ${hr.error || 'headroom companion install failed'}`);
401
+ }
402
+
238
403
  return ok({
239
404
  unitPath,
240
405
  note: 'systemd user unit installed and started (or already running)',
@@ -261,6 +426,14 @@ function uninstallServiceLinux() {
261
426
  });
262
427
  }
263
428
 
429
+ function stopServiceLinux() {
430
+ const r = runCmd('systemctl', ['--user', 'stop', 'bizar.service']);
431
+ if (r.error || (r.status !== 0 && r.status !== null)) {
432
+ return { ok: false, error: `systemctl stop failed: ${r.stderr || r.error?.message || `exit ${r.status}`}` };
433
+ }
434
+ return { ok: true, note: 'bizar.service stopped' };
435
+ }
436
+
264
437
  function linuxServiceStatus() {
265
438
  const unitPath = linuxUnitPath();
266
439
  if (!existsSync(unitPath)) {
@@ -277,11 +450,9 @@ function linuxServiceStatus() {
277
450
 
278
451
  function darwinPlistContent({ nodePath, projectRoot }) {
279
452
  const cliEntry = join(projectRoot, 'cli', 'bin.mjs');
280
- const envVars = [
281
- ['BIZAR_HOME', bizarConfigDir()],
282
- ['PATH', process.env.PATH || '/usr/local/bin:/usr/bin:/bin'],
283
- ['BIZAR_REPO', projectRoot],
284
- ];
453
+ // Use buildServiceEnvFile to get all env vars (honours existing process.env)
454
+ const envContent = buildServiceEnvFile({ bizarHome: bizarConfigDir(), repoPath: projectRoot });
455
+ const envVars = parseEnvFile(envContent);
285
456
  const envXml = envVars
286
457
  .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
287
458
  .join('\n');
@@ -326,6 +497,25 @@ function xmlEscape(s) {
326
497
  .replace(/>/g, '&gt;');
327
498
  }
328
499
 
500
+ /**
501
+ * Parse a shell-style env file (KEY=VALUE lines, # comments) into
502
+ * an array of [key, value] tuples. Used by darwinPlistContent to
503
+ * embed all env vars in the launchd plist.
504
+ */
505
+ function parseEnvFile(content) {
506
+ const result = [];
507
+ for (const line of content.split('\n')) {
508
+ const trimmed = line.trim();
509
+ if (!trimmed || trimmed.startsWith('#')) continue;
510
+ const eqIdx = trimmed.indexOf('=');
511
+ if (eqIdx < 0) continue;
512
+ const key = trimmed.slice(0, eqIdx);
513
+ const value = trimmed.slice(eqIdx + 1);
514
+ result.push([key, value]);
515
+ }
516
+ return result;
517
+ }
518
+
329
519
  function installServiceDarwin({ nodePath, projectRoot, force = false }) {
330
520
  const dir = launchAgentsDir();
331
521
  if (!existsSync(dir)) {
@@ -371,6 +561,13 @@ function installServiceDarwin({ nodePath, projectRoot, force = false }) {
371
561
  if (load.error || (load.status !== 0 && load.status !== null)) {
372
562
  return fail(`launchctl load failed: ${load.stderr || load.error?.message || `exit ${load.status}`}`, { unitPath: plistPath });
373
563
  }
564
+
565
+ // Also install headroom companion service (non-fatal if it fails)
566
+ const hr = installHeadroomServiceDarwin({ nodePath, projectRoot, force });
567
+ if (!hr.ok) {
568
+ console.log(` ${hr.error || 'headroom companion install failed'}`);
569
+ }
570
+
374
571
  return ok({ unitPath: plistPath, note: 'plist installed and loaded' });
375
572
  }
376
573
 
@@ -389,6 +586,15 @@ function uninstallServiceDarwin() {
389
586
  return ok({ unitPath: null, note: 'plist removed' });
390
587
  }
391
588
 
589
+ function stopServiceDarwin() {
590
+ const plistPath = darwinPlistPath();
591
+ const r = runCmd('launchctl', ['unload', plistPath]);
592
+ if (r.error || (r.status !== 0 && r.status !== null)) {
593
+ return { ok: false, error: `launchctl unload failed: ${r.stderr || r.error?.message || `exit ${r.status}`}` };
594
+ }
595
+ return { ok: true, note: 'com.bizar.dashboard stopped' };
596
+ }
597
+
392
598
  function darwinServiceStatus() {
393
599
  const plistPath = darwinPlistPath();
394
600
  const lst = runCmd('launchctl', ['list']);
@@ -404,12 +610,22 @@ function darwinServiceStatus() {
404
610
 
405
611
  function windowsCmdContent({ nodePath, projectRoot }) {
406
612
  const cliEntry = join(projectRoot, 'cli', 'bin.mjs');
613
+ const envContent = buildServiceEnvFile({ bizarHome: bizarConfigDir(), repoPath: projectRoot });
614
+ const envLines = [];
615
+ for (const line of envContent.split('\n')) {
616
+ const trimmed = line.trim();
617
+ if (!trimmed || trimmed.startsWith('#')) continue;
618
+ const eqIdx = trimmed.indexOf('=');
619
+ if (eqIdx < 0) continue;
620
+ const key = trimmed.slice(0, eqIdx);
621
+ const value = trimmed.slice(eqIdx + 1);
622
+ envLines.push(`set "${key}=${value.replace(/"/g, '\\"')}"`);
623
+ }
407
624
  const lines = [
408
625
  '@echo off',
409
626
  'REM Generated by bizar service install — do not edit by hand.',
410
- `set "BIZAR_HOME=${bizarConfigDir()}"`,
411
- `set "PATH=${process.env.PATH || '%PATH%'}"`,
412
- `:loop`,
627
+ ...envLines,
628
+ ':loop',
413
629
  `node "${cliEntry}" service _daemon`,
414
630
  'REM Restart with 5s backoff on exit. /b keeps this window alive.',
415
631
  'timeout /t 5 /nobreak >nul',
@@ -481,6 +697,15 @@ function uninstallServiceWindows() {
481
697
  });
482
698
  }
483
699
 
700
+ function stopServiceWindows() {
701
+ const taskName = 'BizarDashboardService';
702
+ const r = runCmd('cmd.exe', ['/c', 'schtasks', '/End', '/TN', taskName]);
703
+ if (r.error || (r.status !== 0 && r.status !== null)) {
704
+ return { ok: false, error: `schtasks /End failed: ${r.stderr || r.error?.message || `exit ${r.status}`}` };
705
+ }
706
+ return { ok: true, note: 'BizarDashboardService stopped' };
707
+ }
708
+
484
709
  function isScheduledTaskInstalled(taskName) {
485
710
  // schtasks /Query is silent on stdout when the task is missing.
486
711
  const r = runCmd('cmd.exe', ['/c', 'schtasks', '/Query', '/TN', taskName], { stdio: ['ignore', 'pipe', 'pipe'] });
@@ -525,10 +750,128 @@ export function installService(opts = {}) {
525
750
  }
526
751
 
527
752
  export function uninstallService() {
528
- if (PLATFORM === 'linux') return uninstallServiceLinux();
529
- if (PLATFORM === 'darwin') return uninstallServiceDarwin();
530
- if (PLATFORM === 'win32') return uninstallServiceWindows();
531
- return fail(`unsupported platform: ${PLATFORM}`);
753
+ let result;
754
+ if (PLATFORM === 'linux') result = uninstallServiceLinux();
755
+ else if (PLATFORM === 'darwin') result = uninstallServiceDarwin();
756
+ else if (PLATFORM === 'win32') result = uninstallServiceWindows();
757
+ else return fail(`unsupported platform: ${PLATFORM}`);
758
+ // Also uninstall headroom companion
759
+ if (PLATFORM === 'linux') uninstallHeadroomServiceLinux();
760
+ if (PLATFORM === 'darwin') uninstallHeadroomServiceDarwin();
761
+ return result;
762
+ }
763
+
764
+ /**
765
+ * Install the headroom companion service only (does NOT install the
766
+ * main dashboard service). Idempotent. Safe to re-run.
767
+ */
768
+ export function installHeadroomService(opts = {}) {
769
+ const nodePath = resolveNodePath(opts.nodePath);
770
+ const projectRoot = resolveRepoRoot(opts.projectRoot);
771
+ if (PLATFORM === 'linux') return installHeadroomServiceLinux({ force: !!opts.force });
772
+ if (PLATFORM === 'darwin') return installHeadroomServiceDarwin({ nodePath, projectRoot, force: !!opts.force });
773
+ return ok({ note: `headroom companion not supported on ${PLATFORM}` });
774
+ }
775
+
776
+ export function uninstallHeadroomService() {
777
+ if (PLATFORM === 'linux') return uninstallHeadroomServiceLinux();
778
+ if (PLATFORM === 'darwin') return uninstallHeadroomServiceDarwin();
779
+ return ok({ note: `headroom companion not supported on ${PLATFORM}` });
780
+ }
781
+
782
+ /**
783
+ * v5.x — Restart the service (issue #7).
784
+ *
785
+ * The update flow replaces the on-disk files that the running service is
786
+ * holding open. Without a stop/start cycle, the service either crashes
787
+ * when the underlying inode is replaced (Linux) or runs forever against a
788
+ * stale binary (macOS / Windows).
789
+ *
790
+ * This function:
791
+ * 1. Stops the running service (systemctl stop / launchctl unload /
792
+ * schtasks /End). Best-effort — if the service isn't running, we
793
+ * treat that as success and continue.
794
+ * 2. Waits up to `waitMs` (default 10s) for the service to exit.
795
+ * 3. Re-installs the unit file. Idempotent — if the content hasn't
796
+ * changed, installService returns alreadyInstalled=true.
797
+ * 4. Starts the service again (systemctl --user enable --now / launchctl
798
+ * load -w / schtasks /Run).
799
+ *
800
+ * Returns a structured { ok, stopped, installed, started, error? }.
801
+ *
802
+ * @param {{ nodePath?: string, projectRoot?: string, force?: boolean, waitMs?: number, timeoutMs?: number, dryRun?: boolean }} [opts]
803
+ */
804
+ export function restartService(opts = {}) {
805
+ const { waitMs = 10_000, dryRun = false } = opts;
806
+ const nodePath = resolveNodePath(opts.nodePath);
807
+ const projectRoot = resolveRepoRoot(opts.projectRoot);
808
+ const force = !!opts.force;
809
+
810
+ if (dryRun) {
811
+ return ok({
812
+ stopped: true,
813
+ installed: { alreadyInstalled: true },
814
+ started: true,
815
+ note: 'dry-run: would stop, reinstall, and start the service',
816
+ });
817
+ }
818
+
819
+ // 1. Stop (best-effort)
820
+ let stopped;
821
+ try {
822
+ if (PLATFORM === 'linux') stopped = stopServiceLinux();
823
+ else if (PLATFORM === 'darwin') stopped = stopServiceDarwin();
824
+ else if (PLATFORM === 'win32') stopped = stopServiceWindows();
825
+ else return fail(`unsupported platform: ${PLATFORM}`);
826
+ } catch (err) {
827
+ return fail(`stop failed: ${err.message}`, { stopped: { ok: false, error: err.message } });
828
+ }
829
+
830
+ // 2. Wait for the service to actually exit. The stop shell-outs are
831
+ // synchronous in the per-platform functions (systemctl blocks until
832
+ // stop completes; launchctl unload returns immediately; schtasks
833
+ // /End returns immediately), so we additionally check the running
834
+ // state and wait until the OS reports not-running OR our deadline
835
+ // elapses.
836
+ const deadline = Date.now() + waitMs;
837
+ if (PLATFORM === 'linux' || PLATFORM === 'darwin' || PLATFORM === 'win32') {
838
+ while (Date.now() < deadline) {
839
+ const st = serviceStatus();
840
+ if (!st.running) break;
841
+ // Synchronous short sleep — service should exit in <2s on a
842
+ // healthy system. We avoid pulling in a timer just for this
843
+ // bounded wait.
844
+ const until = Date.now() + 200;
845
+ while (Date.now() < until) { /* spin briefly */ }
846
+ }
847
+ }
848
+
849
+ // 3. Reinstall
850
+ let installed;
851
+ try {
852
+ installed = installService({ nodePath, projectRoot, force });
853
+ } catch (err) {
854
+ return fail(`reinstall failed: ${err.message}`, { stopped, installed: { ok: false, error: err.message } });
855
+ }
856
+ if (!installed.ok) {
857
+ return fail(`reinstall failed: ${installed.error}`, { stopped, installed });
858
+ }
859
+
860
+ // 4. Start. The installService* helpers above already do an
861
+ // `enable --now` / `load -w` / `schtasks /Create` (the Windows
862
+ // wrapper script contains a self-restart loop, so creating the
863
+ // task starts the daemon). So when installService succeeds, the
864
+ // service is already running on every platform. Surface that as
865
+ // `started: true` here. The Status field of the return value is
866
+ // still authoritative — operators can call `bizar service status`
867
+ // to confirm.
868
+ return ok({
869
+ stopped,
870
+ installed,
871
+ started: true,
872
+ unitPath: installed.unitPath || serviceUnitPath({ projectRoot }),
873
+ note: 'service stopped, unit reinstalled, service started',
874
+ });
532
875
  }
533
876
 
534
877
  export function isInstalled() {
@@ -559,6 +902,12 @@ if (isMain) {
559
902
  console.log(fmt(installService({ force, dryRun: dry })));
560
903
  } else if (sub === 'uninstall') {
561
904
  console.log(fmt(uninstallService()));
905
+ } else if (sub === 'restart') {
906
+ // v5.x — issue #7. Stop → reinstall unit → start. Idempotent on the
907
+ // install step (when the unit content hasn't changed, the second
908
+ // installService call returns alreadyInstalled=true and the service
909
+ // is still restarted).
910
+ console.log(fmt(restartService({ force, dryRun: dry })));
562
911
  } else if (sub === 'status') {
563
912
  console.log(fmt(serviceStatus()));
564
913
  } else if (sub === 'is-installed') {
@@ -570,6 +919,7 @@ service-controller — platform-aware installer for the Bizar background service
570
919
  Usage:
571
920
  node cli/service-controller.mjs install [--force] [--dry-run]
572
921
  node cli/service-controller.mjs uninstall
922
+ node cli/service-controller.mjs restart [--force] [--dry-run]
573
923
  node cli/service-controller.mjs status
574
924
  node cli/service-controller.mjs is-installed
575
925
  `);
@@ -2,6 +2,7 @@
2
2
  * cli/service-controller.test.mjs
3
3
  *
4
4
  * v4.4.0 — Smoke tests for the platform-aware service installer.
5
+ * v5.x — Adds tests for the `restartService()` lifecycle (issue #7).
5
6
  *
6
7
  * Strategy: focus on the parts that are platform-portable and don't
7
8
  * require touching the user's real `$XDG_CONFIG_HOME/systemd/`:
@@ -10,6 +11,8 @@
10
11
  * - `installService({ dryRun: true })` is side-effect free
11
12
  * - `installService()` is idempotent (run twice → second reports
12
13
  * `alreadyInstalled: true`)
14
+ * - `restartService({ dryRun: true })` is side-effect free
15
+ * - `restartService({ dryRun: true })` returns the documented shape
13
16
  *
14
17
  * Tests that actually install / uninstall on the host machine are
15
18
  * skipped by default — opt in by setting `BIZAR_TEST_INSTALL=1`.
@@ -23,6 +26,7 @@ import { join } from 'node:path';
23
26
  import {
24
27
  installService,
25
28
  uninstallService,
29
+ restartService,
26
30
  serviceStatus,
27
31
  serviceUnitPath,
28
32
  } from './service-controller.mjs';
@@ -90,3 +94,99 @@ test('file permissions on the env file are 0600 (Linux/macOS only)', { skip: HOS
90
94
  }
91
95
  uninstallService();
92
96
  });
97
+
98
+ // ── v5.x — restartService lifecycle (issue #7) ──────────────────────────────
99
+
100
+ test('restartService({ dryRun: true }) makes no filesystem changes', () => {
101
+ const before = serviceStatus();
102
+ const r = restartService({ dryRun: true });
103
+ assert.equal(r.ok, true, `dry-run restart returned ok=false: ${r.error}`);
104
+ const after = serviceStatus();
105
+ // dry-run must not flip the installed bit.
106
+ assert.equal(before.installed, after.installed);
107
+ assert.equal(before.running, after.running);
108
+ });
109
+
110
+ test('restartService({ dryRun: true }) returns the documented shape', () => {
111
+ const r = restartService({ dryRun: true });
112
+ assert.equal(r.ok, true);
113
+ assert.equal(r.started, true, 'dry-run reports started=true');
114
+ // stopped/installed are present (even if their inner shape is
115
+ // minimal in dry-run mode).
116
+ assert.ok('stopped' in r, 'stopped field present');
117
+ assert.ok('installed' in r, 'installed field present');
118
+ });
119
+
120
+ test('restartService() on a fresh install (host-only)', { skip: !ALLOW_INSTALL }, () => {
121
+ // Tear down, install, then restart and confirm the unit path is
122
+ // reported. We don't assert that the service is running because
123
+ // the OS init system (systemd / launchd / schtasks) may not be
124
+ // active in the test environment.
125
+ uninstallService();
126
+ const r1 = installService({});
127
+ assert.equal(r1.ok, true, `install failed: ${r1.error}`);
128
+
129
+ const r2 = restartService({});
130
+ assert.equal(r2.ok, true, `restart failed: ${r2.error}`);
131
+ assert.ok(typeof r2.unitPath === 'string', 'unitPath reported');
132
+ assert.equal(r2.started, true, 'restart reports started=true');
133
+
134
+ // The service should still be reported as installed.
135
+ const status = serviceStatus();
136
+ assert.equal(status.installed, true, 'service still installed after restart');
137
+
138
+ uninstallService();
139
+ });
140
+
141
+ // ── v5.x — buildServiceEnvFile + parseEnvFile ────────────────────────────────
142
+
143
+ test('buildServiceEnvFile returns string with all required vars', async () => {
144
+ const { buildServiceEnvFile } = await import('./service-env.mjs');
145
+ const content = buildServiceEnvFile({ repoPath: '/test/repo' });
146
+ assert.ok(typeof content === 'string');
147
+ assert.ok(content.includes('BIZAR_HOME='), 'BIZAR_HOME present');
148
+ assert.ok(content.includes('BIZAR_REPO=/test/repo'), 'BIZAR_REPO present');
149
+ assert.ok(content.includes('PATH='), 'PATH present');
150
+ assert.ok(content.includes('OPENCODE_SERVER_PASSWORD='), 'OPENCODE_SERVER_PASSWORD present');
151
+ assert.ok(content.includes('BIZAR_DASHBOARD_PORT='), 'BIZAR_DASHBOARD_PORT present');
152
+ assert.ok(content.includes('BIZAR_DASHBOARD_HOST='), 'BIZAR_DASHBOARD_HOST present');
153
+ assert.ok(content.includes('BIZAR_LOG_LEVEL='), 'BIZAR_LOG_LEVEL present');
154
+ assert.ok(content.includes('BIZAR_HEADROOM_AUTOSTART='), 'BIZAR_HEADROOM_AUTOSTART present');
155
+ assert.ok(content.includes('BIZAR_LIGHTRAG_AUTOSTART='), 'BIZAR_LIGHTRAG_AUTOSTART present');
156
+ assert.ok(content.includes('BIZAR_MEMORY_VAULT='), 'BIZAR_MEMORY_VAULT present');
157
+ });
158
+
159
+ test('buildServiceEnvFile honours existing process.env values', async () => {
160
+ const { buildServiceEnvFile } = await import('./service-env.mjs');
161
+ const origPort = process.env.BIZAR_DASHBOARD_PORT;
162
+ process.env.BIZAR_DASHBOARD_PORT = '9999';
163
+ try {
164
+ const content = buildServiceEnvFile({ repoPath: '/test/repo' });
165
+ assert.ok(content.includes('BIZAR_DASHBOARD_PORT=9999'), 'honoured BIZAR_DASHBOARD_PORT from env');
166
+ } finally {
167
+ if (origPort !== undefined) process.env.BIZAR_DASHBOARD_PORT = origPort;
168
+ else delete process.env.BIZAR_DASHBOARD_PORT;
169
+ }
170
+ });
171
+
172
+ test('buildServiceEnvFile uses default BIZAR_MEMORY_VAULT when not set', async () => {
173
+ const { buildServiceEnvFile } = await import('./service-env.mjs');
174
+ const orig = process.env.BIZAR_MEMORY_VAULT;
175
+ delete process.env.BIZAR_MEMORY_VAULT;
176
+ try {
177
+ const content = buildServiceEnvFile({ repoPath: '/test/repo' });
178
+ assert.ok(content.includes('BIZAR_MEMORY_VAULT='), 'BIZAR_MEMORY_VAULT present');
179
+ assert.ok(content.includes('.bizar_memory'), 'default vault path present');
180
+ } finally {
181
+ if (orig !== undefined) process.env.BIZAR_MEMORY_VAULT = orig;
182
+ }
183
+ });
184
+
185
+ test('darwinPlistContent generates XML with BIZAR env vars (dry-run only — no file written)', async () => {
186
+ // dry-run test: verify darwinPlistContent is accessible and produces XML.
187
+ // We test via installService({ dryRun: true }) which uses darwinPlistContent.
188
+ const r = installService({ dryRun: true });
189
+ assert.equal(r.ok, true);
190
+ assert.ok(typeof r.note === 'string');
191
+ });
192
+