beecork 1.4.8 → 1.4.10
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 +17 -0
- package/dist/index.js +12 -0
- package/dist/session/manager.js +28 -13
- package/dist/tasks/scheduler.js +3 -2
- package/dist/types.d.ts +5 -4
- package/dist/util/auto-heal.d.ts +43 -0
- package/dist/util/auto-heal.js +97 -0
- package/dist/util/install-info.d.ts +37 -0
- package/dist/util/install-info.js +78 -0
- package/dist/util/paths.d.ts +1 -0
- package/dist/util/paths.js +3 -0
- package/package.json +5 -3
- package/scripts/postinstall.mjs +50 -0
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
|
package/dist/session/manager.js
CHANGED
|
@@ -146,7 +146,7 @@ export class TabManager {
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
|
-
async executeMessage(tab, prompt, resume, onTextChunk, onToolUse, compactionDepth) {
|
|
149
|
+
async executeMessage(tab, prompt, resume, onTextChunk, onToolUse, compactionDepth, forceFresh = false, retryDepth = 0) {
|
|
150
150
|
const db = getDb();
|
|
151
151
|
logActivity('task_started', 'Processing message', { tabName: tab.name, details: prompt.slice(0, 500) });
|
|
152
152
|
// Budget check before spawning
|
|
@@ -177,9 +177,10 @@ export class TabManager {
|
|
|
177
177
|
const breaker = new CircuitBreaker(tab.name);
|
|
178
178
|
this.circuitBreakers.set(tab.name, breaker);
|
|
179
179
|
const contextMonitor = new ContextMonitor(tab.name);
|
|
180
|
-
// Resume if: explicitly requested or DB has prior successful responses for this tab
|
|
181
|
-
|
|
182
|
-
const
|
|
180
|
+
// Resume if: explicitly requested or DB has prior successful responses for this tab.
|
|
181
|
+
// forceFresh overrides both — used after a stale-session retry to guarantee --session-id, not --resume.
|
|
182
|
+
const hasDbHistory = db.prepare('SELECT COUNT(*) as count FROM messages WHERE tab_id = ? AND role = ? AND content != ?').get(tab.id, 'assistant', '');
|
|
183
|
+
const shouldResume = !forceFresh && (resume || hasDbHistory.count > 0);
|
|
183
184
|
return new Promise((resolve, reject) => {
|
|
184
185
|
let resultText = '';
|
|
185
186
|
let resultEvent = null;
|
|
@@ -245,21 +246,35 @@ export class TabManager {
|
|
|
245
246
|
sessionId: subprocess.sessionId,
|
|
246
247
|
error: resultEvent?.is_error ?? (code !== 0),
|
|
247
248
|
};
|
|
248
|
-
// Handle resume failure (
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
249
|
+
// Handle resume failure (Claude Code session cache rotated / never existed / expired).
|
|
250
|
+
// Detection covers both legacy text-based errors and the modern error_during_execution
|
|
251
|
+
// event shape ({"subtype":"error_during_execution","errors":["No conversation found..."]}).
|
|
252
|
+
// retryDepth guards against any pathological loop.
|
|
253
|
+
const staleSession = result.error && shouldResume && retryDepth === 0 && (result.text.match(/session (not found|expired|invalid)/i) !== null ||
|
|
254
|
+
(resultEvent?.subtype === 'error_during_execution' &&
|
|
255
|
+
resultEvent.errors?.some(e => /no conversation found|session.*not found|session.*expired|session.*invalid/i.test(e))));
|
|
256
|
+
if (staleSession) {
|
|
257
|
+
const detail = resultEvent?.errors?.[0] ?? result.text.split('\n')[0];
|
|
258
|
+
logger.warn(`[${tab.name}] Resume session ${tab.sessionId} unavailable in Claude Code cache (${detail}). Retrying with fresh session.`);
|
|
259
|
+
const recentMsgs = db.prepare("SELECT role, content FROM messages WHERE tab_id = ? AND content != '' ORDER BY created_at DESC LIMIT 5").all(tab.id);
|
|
252
260
|
const context = recentMsgs.reverse().map(m => `${m.role}: ${m.content.slice(0, 200)}`).join('\n');
|
|
253
|
-
const contextPrompt =
|
|
254
|
-
|
|
261
|
+
const contextPrompt = context
|
|
262
|
+
? `[Previous conversation context:\n${context}\n]\n\n${enrichedPrompt}`
|
|
263
|
+
: enrichedPrompt;
|
|
264
|
+
// Reset session ID for fresh start. Use --session-id (forceFresh) to bypass the
|
|
265
|
+
// hasDbHistory shouldResume override that would otherwise --resume the new UUID
|
|
266
|
+
// against an equally-empty Claude Code cache.
|
|
255
267
|
const newSessionId = uuidv4();
|
|
256
268
|
db.prepare('UPDATE tabs SET session_id = ?, status = ? WHERE id = ?').run(newSessionId, 'idle', tab.id);
|
|
257
|
-
this.executeMessage({ ...tab, sessionId: newSessionId }, contextPrompt, false, onTextChunk)
|
|
269
|
+
this.executeMessage({ ...tab, sessionId: newSessionId }, contextPrompt, false, onTextChunk, onToolUse, compactionDepth, true, retryDepth + 1)
|
|
258
270
|
.then(resolve).catch(reject);
|
|
259
271
|
return;
|
|
260
272
|
}
|
|
261
|
-
// Store assistant response
|
|
262
|
-
|
|
273
|
+
// Store assistant response. Skip empty content (typically failed/error runs) so
|
|
274
|
+
// it doesn't trigger the hasDbHistory shouldResume override on future calls.
|
|
275
|
+
if (result.text.trim() !== '') {
|
|
276
|
+
db.prepare('INSERT INTO messages (tab_id, role, content, cost_usd, tokens_in, tokens_out) VALUES (?, ?, ?, ?, ?, ?)').run(tab.id, 'assistant', result.text, result.costUsd, resultEvent?.usage?.input_tokens ?? null, resultEvent?.usage?.output_tokens ?? null);
|
|
277
|
+
}
|
|
263
278
|
// Update tab
|
|
264
279
|
db.prepare('UPDATE tabs SET status = ?, last_activity_at = ?, pid = NULL WHERE name = ?')
|
|
265
280
|
.run('idle', new Date().toISOString(), tab.name);
|
package/dist/tasks/scheduler.js
CHANGED
|
@@ -166,8 +166,9 @@ export class TaskScheduler {
|
|
|
166
166
|
const result = await this.tabManager.sendMessage(job.tabName, job.message);
|
|
167
167
|
this.store.update(job.id, { lastRunAt: new Date().toISOString() });
|
|
168
168
|
const firstLine = result.text.split('\n')[0]?.slice(0, 200) || '(no output)';
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
const status = result.error ? 'ERROR' : 'SUCCESS';
|
|
170
|
+
// Log result (status reflects subprocess exit / is_error, not just completion)
|
|
171
|
+
await fs.promises.appendFile(logFile, `[${new Date().toISOString()}] ${status}: ${firstLine}\n`);
|
|
171
172
|
// Notify (separate try/catch -- notification failure shouldn't be reported as job failure)
|
|
172
173
|
try {
|
|
173
174
|
if (this.onNotify) {
|
package/dist/types.d.ts
CHANGED
|
@@ -132,13 +132,14 @@ export interface StreamAssistant {
|
|
|
132
132
|
}
|
|
133
133
|
export interface StreamResult {
|
|
134
134
|
type: 'result';
|
|
135
|
-
subtype: 'success' | 'error';
|
|
135
|
+
subtype: 'success' | 'error' | 'error_during_execution';
|
|
136
136
|
is_error: boolean;
|
|
137
137
|
duration_ms: number;
|
|
138
|
-
result
|
|
138
|
+
result?: string;
|
|
139
|
+
errors?: string[];
|
|
139
140
|
session_id: string;
|
|
140
|
-
total_cost_usd
|
|
141
|
-
usage
|
|
141
|
+
total_cost_usd?: number;
|
|
142
|
+
usage?: StreamUsage;
|
|
142
143
|
}
|
|
143
144
|
export interface StreamUsage {
|
|
144
145
|
input_tokens: number;
|
|
@@ -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
|
+
}
|
package/dist/util/paths.d.ts
CHANGED
|
@@ -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;
|
package/dist/util/paths.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.4.10",
|
|
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
|
+
}
|