beecork 1.4.9 → 1.4.11

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/dist/daemon.js CHANGED
@@ -12,6 +12,7 @@ import { cleanupMedia } from './media/store.js';
12
12
  import { createNotificationProvider } from './notifications/index.js';
13
13
  import { VERSION } from './version.js';
14
14
  import { logActivity } from './timeline/index.js';
15
+ import { findInstallRoot, getDaemonScript, writeRuntimeInfo, removeRuntimeInfo } from './util/install-info.js';
15
16
  let tabManager;
16
17
  let channelRegistry;
17
18
  let taskScheduler;
@@ -71,6 +72,21 @@ async function main() {
71
72
  fs.writeFileSync(pidPath, String(process.pid));
72
73
  }
73
74
  logger.info(`PID file written: ${process.pid}`);
75
+ // 3a. Write runtime.json so the CLI / postinstall can detect install-path divergence
76
+ try {
77
+ const installRoot = findInstallRoot(import.meta.url);
78
+ writeRuntimeInfo({
79
+ pid: process.pid,
80
+ version: VERSION,
81
+ installRoot,
82
+ daemonScript: getDaemonScript(installRoot),
83
+ startedAt: new Date().toISOString(),
84
+ nodeVersion: process.version,
85
+ });
86
+ }
87
+ catch (err) {
88
+ logger.warn('Could not write runtime.json (auto-heal may be skipped):', err);
89
+ }
74
90
  // 4. Create TabManager
75
91
  tabManager = new TabManager(config);
76
92
  // 5. Ensure default tab
@@ -188,6 +204,7 @@ async function main() {
188
204
  const pidPath = getPidPath();
189
205
  if (fs.existsSync(pidPath))
190
206
  fs.unlinkSync(pidPath);
207
+ removeRuntimeInfo();
191
208
  logActivity('system_event', 'Beecork daemon stopped');
192
209
  logger.info('Beecork daemon stopped.');
193
210
  logger.close();
package/dist/index.js CHANGED
@@ -3,6 +3,18 @@ import { Command } from 'commander';
3
3
  import { platform } from 'node:os';
4
4
  import { VERSION } from './version.js';
5
5
  import { setupWizard } from './cli/setup.js';
6
+ import { autoHealInstall } from './util/auto-heal.js';
7
+ // Auto-heal install-path divergence: if the daemon is running from a different
8
+ // beecork install than this CLI binary (e.g. user did `npm install -g beecork@latest`
9
+ // to a different prefix than the launchd plist points at), rewrite the unit file
10
+ // and signal the daemon to restart. Idempotent no-op otherwise. Never blocks the CLI.
11
+ {
12
+ const heal = autoHealInstall(import.meta.url);
13
+ if (heal.action !== 'noop' && heal.action !== 'skip') {
14
+ const target = heal.newDaemonScript ?? '(running daemon)';
15
+ process.stderr.write(`[beecork] auto-heal: ${heal.action} → ${target}\n`);
16
+ }
17
+ }
6
18
  import { startDaemon, stopDaemon, showStatus, listTabs, tailLogs, listCrons, deleteCron, listMemories, deleteMemory, sendMessage, updateBeecork, } from './cli/commands.js';
7
19
  const program = new Command();
8
20
  program
@@ -1,3 +1,4 @@
1
+ import type Database from 'better-sqlite3';
1
2
  export interface CostSummary {
2
3
  today: number;
3
4
  last7Days: number;
@@ -19,6 +20,11 @@ export interface ActivitySummary {
19
20
  }
20
21
  export declare function getCostSummary(): CostSummary;
21
22
  export declare function getActivitySummary(hours?: number): ActivitySummary;
23
+ /**
24
+ * Returns a notification only on state transitions (ok↔breach), persisting
25
+ * the state in the `preferences` table so daemon restarts don't re-fire.
26
+ */
27
+ export declare function checkAnomaliesWithDb(db: Database.Database): string | null;
22
28
  export declare function checkAnomalies(): string | null;
23
29
  export declare function formatCostSummary(summary: CostSummary): string;
24
30
  export declare function formatActivitySummary(summary: ActivitySummary): string;
@@ -30,8 +30,12 @@ export function getActivitySummary(hours = 24) {
30
30
  activeTabsCount,
31
31
  };
32
32
  }
33
- export function checkAnomalies() {
34
- const db = getDb();
33
+ const ANOMALY_STATE_KEY = 'anomaly_spend_state';
34
+ /**
35
+ * Returns a notification only on state transitions (ok↔breach), persisting
36
+ * the state in the `preferences` table so daemon restarts don't re-fire.
37
+ */
38
+ export function checkAnomaliesWithDb(db) {
35
39
  // Today's spend
36
40
  const todaySpend = db.prepare("SELECT COALESCE(SUM(cost_usd), 0) as total FROM messages WHERE created_at > date('now')").get().total;
37
41
  // 7-day rolling average (excluding today)
@@ -43,10 +47,21 @@ export function checkAnomalies() {
43
47
  GROUP BY date(created_at)
44
48
  )
45
49
  `).get().avg;
46
- if (avgSpend > 0 && todaySpend > avgSpend * 2) {
50
+ const isBreach = avgSpend > 0 && todaySpend > avgSpend * 2;
51
+ const newState = isBreach ? 'breach' : 'ok';
52
+ const prevRow = db.prepare('SELECT value FROM preferences WHERE key = ?').get(ANOMALY_STATE_KEY);
53
+ const prevState = prevRow?.value ?? 'ok';
54
+ if (newState === prevState)
55
+ return null;
56
+ db.prepare(`INSERT INTO preferences (key, value, updated_at) VALUES (?, ?, datetime('now'))
57
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`).run(ANOMALY_STATE_KEY, newState);
58
+ if (isBreach) {
47
59
  return `⚠️ Anomaly: Today's spend ($${todaySpend.toFixed(4)}) exceeds 2x your 7-day average ($${avgSpend.toFixed(4)}/day)`;
48
60
  }
49
- return null;
61
+ return `✅ Recovered: Today's spend ($${todaySpend.toFixed(4)}) is back within 2x your 7-day average ($${avgSpend.toFixed(4)}/day)`;
62
+ }
63
+ export function checkAnomalies() {
64
+ return checkAnomaliesWithDb(getDb());
50
65
  }
51
66
  export function formatCostSummary(summary) {
52
67
  const lines = [
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Auto-heal install-path divergence between the CLI (this process) and the
3
+ * running daemon. Triggered both from the npm postinstall hook and from the
4
+ * CLI entry on every invocation — both paths converge here so the policy is
5
+ * single-sourced and idempotent.
6
+ *
7
+ * What "divergence" means:
8
+ * The daemon's launchd plist (or systemd unit) hard-codes an absolute path
9
+ * to dist/daemon.js. If `npm install -g beecork@<new>` lands in a different
10
+ * npm prefix than the one the unit file points at, the daemon keeps running
11
+ * the old code while the CLI shows the new version. Bug reports get
12
+ * misdiagnosed because the user thinks both are on the new version.
13
+ *
14
+ * What this function does:
15
+ * 1. Rewrite the unit file's daemon path if it doesn't point at us.
16
+ * 2. SIGTERM the running daemon (KeepAlive / Restart=always brings it back
17
+ * on the new code) only when its runtime.json shows it was launched from
18
+ * a different install root than ours.
19
+ *
20
+ * Returns a Result describing what happened, for the caller to surface (or not)
21
+ * to the user. The auto-heal NEVER throws — failures degrade to {action:'skip'}.
22
+ */
23
+ export interface HealResult {
24
+ action: 'noop' | 'rewrote-unit' | 'signaled-daemon' | 'rewrote-and-signaled' | 'skip';
25
+ reason?: string;
26
+ unitPath?: string;
27
+ oldDaemonScript?: string;
28
+ newDaemonScript?: string;
29
+ }
30
+ export declare function autoHealInstall(fromFileUrl: string): HealResult;
31
+ /**
32
+ * Extract the daemon-script absolute path from a launchd plist or systemd unit.
33
+ * Returns null if the file shape isn't recognized — caller treats that as "skip".
34
+ *
35
+ * launchd: looks inside <key>ProgramArguments</key><array> for the .js entry.
36
+ * systemd: looks for ExecStart=<node> <daemon.js> on a single line.
37
+ */
38
+ export declare function extractDaemonScript(content: string): string | null;
39
+ /** Rewrite-only variant used by tests and the postinstall hook in dry-run mode. */
40
+ export declare function rewriteUnitDaemonScript(unitPath: string, newDaemonScript: string): {
41
+ rewrote: boolean;
42
+ oldDaemonScript: string | null;
43
+ };
@@ -0,0 +1,97 @@
1
+ import fs from 'node:fs';
2
+ import { findInstallRoot, getDaemonScript, readRuntimeInfo, isPidAlive, getServiceUnitPath, } from './install-info.js';
3
+ export function autoHealInstall(fromFileUrl) {
4
+ try {
5
+ const currentRoot = findInstallRoot(fromFileUrl);
6
+ const currentDaemonScript = getDaemonScript(currentRoot);
7
+ const unitPath = getServiceUnitPath();
8
+ if (!unitPath || !fs.existsSync(unitPath)) {
9
+ // No installed service — beecork has been npm-installed but `beecork setup` (or
10
+ // equivalent) hasn't been run yet. Nothing to heal.
11
+ return { action: 'skip', reason: 'no service unit file present' };
12
+ }
13
+ const unitContent = fs.readFileSync(unitPath, 'utf-8');
14
+ const oldDaemonScript = extractDaemonScript(unitContent);
15
+ let rewroteUnit = false;
16
+ if (oldDaemonScript && oldDaemonScript !== currentDaemonScript) {
17
+ // Validate the new path actually exists before rewriting — never break the daemon
18
+ // by pointing it at a missing file.
19
+ if (!fs.existsSync(currentDaemonScript)) {
20
+ return { action: 'skip', reason: `current install missing daemon: ${currentDaemonScript}` };
21
+ }
22
+ const next = unitContent.split(oldDaemonScript).join(currentDaemonScript);
23
+ const tmp = unitPath + '.tmp';
24
+ fs.writeFileSync(tmp, next);
25
+ fs.renameSync(tmp, unitPath);
26
+ rewroteUnit = true;
27
+ }
28
+ // Restart the daemon if it's currently running from a stale install. This is
29
+ // separate from the unit-file rewrite so we also catch the case where the unit
30
+ // file is already current but the running process predates the latest install.
31
+ const runtime = readRuntimeInfo();
32
+ let signaledDaemon = false;
33
+ if (runtime && runtime.installRoot !== currentRoot && isPidAlive(runtime.pid)) {
34
+ try {
35
+ process.kill(runtime.pid, 'SIGTERM');
36
+ signaledDaemon = true;
37
+ }
38
+ catch {
39
+ // Permission denied (different user, or already gone). Surface as skip;
40
+ // we still rewrote the unit file so the next start picks up the new path.
41
+ }
42
+ }
43
+ if (rewroteUnit && signaledDaemon) {
44
+ return { action: 'rewrote-and-signaled', unitPath, oldDaemonScript: oldDaemonScript, newDaemonScript: currentDaemonScript };
45
+ }
46
+ if (rewroteUnit)
47
+ return { action: 'rewrote-unit', unitPath, oldDaemonScript: oldDaemonScript, newDaemonScript: currentDaemonScript };
48
+ if (signaledDaemon)
49
+ return { action: 'signaled-daemon', unitPath };
50
+ return { action: 'noop' };
51
+ }
52
+ catch (err) {
53
+ return { action: 'skip', reason: err instanceof Error ? err.message : String(err) };
54
+ }
55
+ }
56
+ /**
57
+ * Extract the daemon-script absolute path from a launchd plist or systemd unit.
58
+ * Returns null if the file shape isn't recognized — caller treats that as "skip".
59
+ *
60
+ * launchd: looks inside <key>ProgramArguments</key><array> for the .js entry.
61
+ * systemd: looks for ExecStart=<node> <daemon.js> on a single line.
62
+ */
63
+ export function extractDaemonScript(content) {
64
+ // launchd plist: pull the second <string> inside ProgramArguments
65
+ const launchdMatch = content.match(/<key>\s*ProgramArguments\s*<\/key>\s*<array>([\s\S]*?)<\/array>/);
66
+ if (launchdMatch) {
67
+ const args = Array.from(launchdMatch[1].matchAll(/<string>([^<]+)<\/string>/g)).map(m => m[1]);
68
+ const jsArg = args.find(a => a.endsWith('daemon.js'));
69
+ if (jsArg)
70
+ return jsArg;
71
+ }
72
+ // systemd unit: ExecStart=<node> <daemon.js> [args...]
73
+ const systemdMatch = content.match(/^ExecStart\s*=\s*(.+)$/m);
74
+ if (systemdMatch) {
75
+ const parts = systemdMatch[1].split(/\s+/);
76
+ const jsArg = parts.find(p => p.endsWith('daemon.js'));
77
+ if (jsArg)
78
+ return jsArg;
79
+ }
80
+ return null;
81
+ }
82
+ /** Rewrite-only variant used by tests and the postinstall hook in dry-run mode. */
83
+ export function rewriteUnitDaemonScript(unitPath, newDaemonScript) {
84
+ if (!fs.existsSync(unitPath))
85
+ return { rewrote: false, oldDaemonScript: null };
86
+ const content = fs.readFileSync(unitPath, 'utf-8');
87
+ const oldDaemonScript = extractDaemonScript(content);
88
+ if (!oldDaemonScript)
89
+ return { rewrote: false, oldDaemonScript: null };
90
+ if (oldDaemonScript === newDaemonScript)
91
+ return { rewrote: false, oldDaemonScript };
92
+ const next = content.split(oldDaemonScript).join(newDaemonScript);
93
+ const tmp = unitPath + '.tmp';
94
+ fs.writeFileSync(tmp, next);
95
+ fs.renameSync(tmp, unitPath);
96
+ return { rewrote: true, oldDaemonScript };
97
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Metadata the daemon writes to ~/.beecork/runtime.json at startup so the CLI
3
+ * (and postinstall hook) can detect when daemon and CLI are running from
4
+ * different physical installs of beecork — the most common silent-staleness
5
+ * mode after `npm install -g beecork@<new>` lands in a prefix the daemon's
6
+ * launchd plist doesn't point at.
7
+ */
8
+ export interface RuntimeInfo {
9
+ pid: number;
10
+ version: string;
11
+ /** The package root: <prefix>/lib/node_modules/beecork. Single canonical key. */
12
+ installRoot: string;
13
+ /** Absolute path to dist/daemon.js — derived from installRoot. */
14
+ daemonScript: string;
15
+ /** ISO timestamp. */
16
+ startedAt: string;
17
+ /** process.version (e.g. "v20.11.0") */
18
+ nodeVersion: string;
19
+ }
20
+ /**
21
+ * Walk up from a given file (typically import.meta.url) to find the directory
22
+ * containing package.json — i.e. the beecork install root. Works for both:
23
+ * - source dev runs (.../src/util/install-info.ts → .../ )
24
+ * - built/installed runs (.../dist/util/install-info.js → .../ )
25
+ */
26
+ export declare function findInstallRoot(fromFileUrl: string): string;
27
+ export declare function getDaemonScript(installRoot: string): string;
28
+ export declare function writeRuntimeInfo(info: RuntimeInfo): void;
29
+ export declare function readRuntimeInfo(): RuntimeInfo | null;
30
+ export declare function removeRuntimeInfo(): void;
31
+ export declare function isPidAlive(pid: number): boolean;
32
+ /**
33
+ * Cross-platform path to the service-manager unit file that launches the daemon.
34
+ * Returns null if beecork doesn't manage one on this OS (currently Windows uses
35
+ * Task Scheduler and is out of scope for the auto-heal flow).
36
+ */
37
+ export declare function getServiceUnitPath(): string | null;
@@ -0,0 +1,78 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { getRuntimeInfoPath } from './paths.js';
6
+ /**
7
+ * Walk up from a given file (typically import.meta.url) to find the directory
8
+ * containing package.json — i.e. the beecork install root. Works for both:
9
+ * - source dev runs (.../src/util/install-info.ts → .../ )
10
+ * - built/installed runs (.../dist/util/install-info.js → .../ )
11
+ */
12
+ export function findInstallRoot(fromFileUrl) {
13
+ const fromPath = fileURLToPath(fromFileUrl);
14
+ let dir = path.dirname(fromPath);
15
+ for (let i = 0; i < 6; i++) {
16
+ if (fs.existsSync(path.join(dir, 'package.json')))
17
+ return dir;
18
+ const parent = path.dirname(dir);
19
+ if (parent === dir)
20
+ break;
21
+ dir = parent;
22
+ }
23
+ throw new Error(`Could not locate beecork install root walking up from ${fromPath}`);
24
+ }
25
+ export function getDaemonScript(installRoot) {
26
+ return path.join(installRoot, 'dist', 'daemon.js');
27
+ }
28
+ export function writeRuntimeInfo(info) {
29
+ const filePath = getRuntimeInfoPath();
30
+ // Atomic-ish write: temp + rename so a partial write never confuses a reader.
31
+ const tmp = filePath + '.tmp';
32
+ fs.writeFileSync(tmp, JSON.stringify(info, null, 2));
33
+ fs.renameSync(tmp, filePath);
34
+ }
35
+ export function readRuntimeInfo() {
36
+ const filePath = getRuntimeInfoPath();
37
+ if (!fs.existsSync(filePath))
38
+ return null;
39
+ try {
40
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
41
+ if (typeof data?.pid === 'number' && typeof data?.installRoot === 'string') {
42
+ return data;
43
+ }
44
+ return null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ export function removeRuntimeInfo() {
51
+ const filePath = getRuntimeInfoPath();
52
+ try {
53
+ fs.unlinkSync(filePath);
54
+ }
55
+ catch { /* not present, fine */ }
56
+ }
57
+ export function isPidAlive(pid) {
58
+ try {
59
+ process.kill(pid, 0);
60
+ return true;
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ /**
67
+ * Cross-platform path to the service-manager unit file that launches the daemon.
68
+ * Returns null if beecork doesn't manage one on this OS (currently Windows uses
69
+ * Task Scheduler and is out of scope for the auto-heal flow).
70
+ */
71
+ export function getServiceUnitPath() {
72
+ const home = os.homedir();
73
+ if (process.platform === 'darwin')
74
+ return path.join(home, 'Library', 'LaunchAgents', 'com.beecork.daemon.plist');
75
+ if (process.platform === 'linux')
76
+ return path.join(home, '.config', 'systemd', 'user', 'beecork.service');
77
+ return null;
78
+ }
@@ -5,6 +5,7 @@ export declare function getCrontabPath(): string;
5
5
  export declare function getMcpConfigPath(): string;
6
6
  export declare function getLogsDir(): string;
7
7
  export declare function getPidPath(): string;
8
+ export declare function getRuntimeInfoPath(): string;
8
9
  export declare function getCronReloadSignalPath(): string;
9
10
  export declare function ensureBeecorkDirs(): void;
10
11
  export declare function expandHome(p: string): string;
@@ -23,6 +23,9 @@ export function getLogsDir() {
23
23
  export function getPidPath() {
24
24
  return path.join(getBeecorkHome(), 'beecork.pid');
25
25
  }
26
+ export function getRuntimeInfoPath() {
27
+ return path.join(getBeecorkHome(), 'runtime.json');
28
+ }
26
29
  export function getCronReloadSignalPath() {
27
30
  return path.join(getBeecorkHome(), '.cron-reload');
28
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beecork",
3
- "version": "1.4.9",
3
+ "version": "1.4.11",
4
4
  "description": "Claude Code always-on infrastructure — a phone number, a memory, and an alarm clock",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,8 @@
16
16
  "test": "vitest",
17
17
  "lint": "eslint src/",
18
18
  "build:css": "tailwindcss --content src/dashboard/html.ts --minify",
19
- "prepublishOnly": "npm test && npm run build"
19
+ "prepublishOnly": "npm test && npm run build",
20
+ "postinstall": "node scripts/postinstall.mjs"
20
21
  },
21
22
  "dependencies": {
22
23
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -42,7 +43,8 @@
42
43
  "vitest": "^4.1.4"
43
44
  },
44
45
  "files": [
45
- "dist/"
46
+ "dist/",
47
+ "scripts/postinstall.mjs"
46
48
  ],
47
49
  "keywords": [
48
50
  "claude",
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ // Beecork postinstall hook.
3
+ //
4
+ // Runs after `npm install -g beecork[@<version>]`. Goal: if a beecork daemon
5
+ // is already managed by the OS (launchd plist on mac, systemd unit on linux)
6
+ // from a DIFFERENT install path than this freshly-installed one, rewrite the
7
+ // unit file to point at this install and signal the daemon to restart so the
8
+ // running process picks up the new code without the user having to think about
9
+ // install prefixes.
10
+ //
11
+ // No-ops cleanly in every other case (first install, local install,
12
+ // dependency install, no daemon, dependency install in CI, etc.).
13
+
14
+ import { fileURLToPath } from 'node:url';
15
+ import path from 'node:path';
16
+ import fs from 'node:fs';
17
+
18
+ // Run only for global installs. npm sets npm_config_global="true" for `npm install -g`.
19
+ // `npm ci` / `npm install` (no -g) inside the repo leaves it unset/empty — skip those.
20
+ if (process.env.npm_config_global !== 'true') {
21
+ process.exit(0);
22
+ }
23
+
24
+ // Resolve the auto-heal module from dist/. If the user installed from a
25
+ // tarball without dist/ (shouldn't happen — `files` whitelists dist/, and
26
+ // `prepublishOnly` builds it — but be defensive), skip silently.
27
+ const here = path.dirname(fileURLToPath(import.meta.url));
28
+ const installRoot = path.dirname(here);
29
+ const autoHealModule = path.join(installRoot, 'dist', 'util', 'auto-heal.js');
30
+
31
+ if (!fs.existsSync(autoHealModule)) {
32
+ // Built artifacts missing — skip silently. This is the npm-publish-with-no-build
33
+ // edge case which is already caught by `prepublishOnly`. Don't fail the install.
34
+ process.exit(0);
35
+ }
36
+
37
+ try {
38
+ const { autoHealInstall } = await import(autoHealModule);
39
+ const result = autoHealInstall(import.meta.url);
40
+ if (result.action === 'rewrote-unit' || result.action === 'rewrote-and-signaled') {
41
+ process.stderr.write(`[beecork] launchd/systemd unit re-pointed at ${result.newDaemonScript}\n`);
42
+ }
43
+ if (result.action === 'signaled-daemon' || result.action === 'rewrote-and-signaled') {
44
+ process.stderr.write(`[beecork] running daemon signaled to restart on new code\n`);
45
+ }
46
+ // 'noop' / 'skip' → silent
47
+ } catch (err) {
48
+ // Never fail an install on auto-heal trouble — just leave a breadcrumb.
49
+ process.stderr.write(`[beecork] postinstall auto-heal skipped: ${err?.message ?? err}\n`);
50
+ }