beecork 1.7.0 → 1.7.1

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.
@@ -0,0 +1,22 @@
1
+ import type { TabManager, NotifyCallback } from '../session/manager.js';
2
+ export declare class CronScheduler {
3
+ private tabManager;
4
+ private onNotify;
5
+ private scheduledJobs;
6
+ private store;
7
+ constructor(tabManager: TabManager, onNotify: NotifyCallback | null);
8
+ /** Load all cron jobs from store and schedule them */
9
+ loadAndSchedule(): void;
10
+ /** Check for the reload signal file and reload if present */
11
+ checkForReload(): void;
12
+ /** Stop all scheduled jobs */
13
+ stopAll(): void;
14
+ private scheduleJob;
15
+ private fireJob;
16
+ private handleSystemEvent;
17
+ }
18
+ /** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to milliseconds */
19
+ export declare function intervalToMs(interval: string): number | null;
20
+ /** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to cron expression */
21
+ export declare function intervalToCron(interval: string): string | null;
22
+ //# sourceMappingURL=scheduler.d.ts.map
@@ -0,0 +1,220 @@
1
+ import cron from 'node-cron';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { CronStore } from './store.js';
5
+ import { getCronReloadSignalPath, getLogsDir } from '../util/paths.js';
6
+ import { logger } from '../util/logger.js';
7
+ export class CronScheduler {
8
+ tabManager;
9
+ onNotify;
10
+ scheduledJobs = new Map();
11
+ store = new CronStore();
12
+ constructor(tabManager, onNotify) {
13
+ this.tabManager = tabManager;
14
+ this.onNotify = onNotify;
15
+ }
16
+ /** Load all cron jobs from store and schedule them */
17
+ loadAndSchedule() {
18
+ // Cancel existing
19
+ for (const [, task] of this.scheduledJobs) {
20
+ task.stop();
21
+ }
22
+ this.scheduledJobs.clear();
23
+ const jobs = this.store.list();
24
+ let scheduled = 0;
25
+ let missedFires = 0;
26
+ for (const job of jobs) {
27
+ if (!job.enabled)
28
+ continue;
29
+ // Detect missed fires: one-time "at" jobs whose time has passed but never ran
30
+ if (job.scheduleType === 'at' && !job.lastRunAt) {
31
+ const targetTime = new Date(job.schedule).getTime();
32
+ if (targetTime <= Date.now()) {
33
+ logger.warn(`Cron: missed fire detected for "${job.name}" (was scheduled for ${job.schedule}), firing now`);
34
+ missedFires++;
35
+ this.fireJob(job);
36
+ this.store.update(job.id, { enabled: false }); // Disable after one-time execution
37
+ continue;
38
+ }
39
+ }
40
+ this.scheduleJob(job);
41
+ scheduled++;
42
+ }
43
+ logger.info(`Cron: loaded ${scheduled} active jobs (${jobs.length} total)${missedFires > 0 ? `, fired ${missedFires} missed` : ''}`);
44
+ }
45
+ /** Check for the reload signal file and reload if present */
46
+ checkForReload() {
47
+ const signalPath = getCronReloadSignalPath();
48
+ if (fs.existsSync(signalPath)) {
49
+ try {
50
+ fs.unlinkSync(signalPath);
51
+ }
52
+ catch { /* race condition, ok */ }
53
+ logger.info('Cron: reload signal detected, reloading schedules');
54
+ this.loadAndSchedule();
55
+ }
56
+ }
57
+ /** Stop all scheduled jobs */
58
+ stopAll() {
59
+ for (const [, task] of this.scheduledJobs) {
60
+ task.stop();
61
+ }
62
+ this.scheduledJobs.clear();
63
+ }
64
+ scheduleJob(job) {
65
+ switch (job.scheduleType) {
66
+ case 'cron': {
67
+ if (!cron.validate(job.schedule)) {
68
+ logger.error(`Cron: invalid expression for "${job.name}": ${job.schedule}`);
69
+ return;
70
+ }
71
+ const task = cron.schedule(job.schedule, () => this.fireJob(job));
72
+ this.scheduledJobs.set(job.id, task);
73
+ break;
74
+ }
75
+ case 'every': {
76
+ const cronExpr = intervalToCron(job.schedule);
77
+ if (cronExpr) {
78
+ if (!cron.validate(cronExpr)) {
79
+ logger.error(`Cron: invalid cron expression for "${job.name}": ${cronExpr}`);
80
+ return;
81
+ }
82
+ const task = cron.schedule(cronExpr, () => this.fireJob(job));
83
+ this.scheduledJobs.set(job.id, task);
84
+ }
85
+ else {
86
+ // Use setInterval for non-cron-expressible intervals
87
+ const totalMs = intervalToMs(job.schedule);
88
+ if (totalMs) {
89
+ const timer = setInterval(() => this.fireJob(job), totalMs);
90
+ this.scheduledJobs.set(job.id, { stop: () => clearInterval(timer) });
91
+ }
92
+ else {
93
+ logger.error(`Cron: invalid interval for "${job.name}": ${job.schedule}`);
94
+ }
95
+ }
96
+ break;
97
+ }
98
+ case 'at': {
99
+ const targetTime = new Date(job.schedule).getTime();
100
+ const delay = targetTime - Date.now();
101
+ if (delay <= 0) {
102
+ logger.warn(`Cron: one-time job "${job.name}" is in the past, skipping`);
103
+ return;
104
+ }
105
+ const timer = setTimeout(() => {
106
+ this.fireJob(job);
107
+ this.store.update(job.id, { enabled: false });
108
+ }, delay);
109
+ this.scheduledJobs.set(job.id, { stop: () => clearTimeout(timer) });
110
+ break;
111
+ }
112
+ }
113
+ }
114
+ async fireJob(job) {
115
+ logger.info(`Cron firing: "${job.name}" (${job.payloadType || 'agentTurn'}) → tab:${job.tabName}`);
116
+ const logFile = path.join(getLogsDir(), `cron-${job.name}.log`);
117
+ // Handle systemEvent — internal Beecork actions, not Claude Code
118
+ if (job.payloadType === 'systemEvent') {
119
+ try {
120
+ await this.handleSystemEvent(job);
121
+ this.store.update(job.id, { lastRunAt: new Date().toISOString() });
122
+ fs.appendFileSync(logFile, `[${new Date().toISOString()}] SYSTEM: ${job.message}\n`);
123
+ }
124
+ catch (err) {
125
+ const errMsg = err instanceof Error ? err.message : String(err);
126
+ logger.error(`System event "${job.name}" failed:`, err);
127
+ fs.appendFileSync(logFile, `[${new Date().toISOString()}] ERROR: ${errMsg}\n`);
128
+ }
129
+ return;
130
+ }
131
+ try {
132
+ this.tabManager.ensureTab(job.tabName);
133
+ const result = await this.tabManager.sendMessage(job.tabName, job.message);
134
+ this.store.update(job.id, { lastRunAt: new Date().toISOString() });
135
+ const firstLine = result.text.split('\n')[0]?.slice(0, 200) || '(no output)';
136
+ // Log result
137
+ fs.appendFileSync(logFile, `[${new Date().toISOString()}] SUCCESS: ${firstLine}\n`);
138
+ // Notify (separate try/catch — notification failure shouldn't be reported as job failure)
139
+ try {
140
+ if (this.onNotify) {
141
+ if (result.error) {
142
+ await this.onNotify(`❌ [${job.name}] Failed — ${firstLine}`);
143
+ }
144
+ else {
145
+ await this.onNotify(`✅ [${job.name}] Done — ${firstLine}`);
146
+ }
147
+ }
148
+ }
149
+ catch (notifyErr) {
150
+ logger.warn(`Cron job "${job.name}" notification failed:`, notifyErr);
151
+ }
152
+ }
153
+ catch (err) {
154
+ const errMsg = err instanceof Error ? err.message : String(err);
155
+ logger.error(`Cron job "${job.name}" failed:`, err);
156
+ fs.appendFileSync(logFile, `[${new Date().toISOString()}] ERROR: ${errMsg}\n`);
157
+ try {
158
+ await this.onNotify?.(`❌ [${job.name}] Failed — ${errMsg}`);
159
+ }
160
+ catch { /* notification best-effort */ }
161
+ }
162
+ }
163
+ async handleSystemEvent(job) {
164
+ switch (job.message) {
165
+ case 'health_check':
166
+ logger.info('System event: health check — daemon alive');
167
+ if (this.onNotify) {
168
+ await this.onNotify('🟢 Beecork health check: all systems operational');
169
+ }
170
+ break;
171
+ case 'memory_compaction':
172
+ logger.info('System event: memory compaction (not yet implemented)');
173
+ break;
174
+ default:
175
+ logger.warn(`Unknown system event: ${job.message}`);
176
+ }
177
+ }
178
+ }
179
+ /** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to milliseconds */
180
+ export function intervalToMs(interval) {
181
+ const match = interval.match(/^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/);
182
+ if (!match || match.slice(1).every(g => g === undefined))
183
+ return null;
184
+ const weeks = parseInt(match[1] || '0', 10);
185
+ const days = parseInt(match[2] || '0', 10);
186
+ const hours = parseInt(match[3] || '0', 10);
187
+ const mins = parseInt(match[4] || '0', 10);
188
+ const totalMs = ((weeks * 7 * 24 * 60) + (days * 24 * 60) + (hours * 60) + mins) * 60 * 1000;
189
+ return totalMs > 0 ? totalMs : null;
190
+ }
191
+ /** Convert human interval (30m, 2h, 1d, 1h30m, 2w) to cron expression */
192
+ export function intervalToCron(interval) {
193
+ // Try combined format: 1h30m, 2h, 30m, 1d, 2w
194
+ const match = interval.match(/^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/);
195
+ if (!match || match.slice(1).every(g => g === undefined))
196
+ return null;
197
+ const weeks = parseInt(match[1] || '0', 10);
198
+ const days = parseInt(match[2] || '0', 10);
199
+ const hours = parseInt(match[3] || '0', 10);
200
+ const mins = parseInt(match[4] || '0', 10);
201
+ // Convert to total minutes for simple intervals
202
+ const totalMins = weeks * 7 * 24 * 60 + days * 24 * 60 + hours * 60 + mins;
203
+ if (totalMins <= 0)
204
+ return null;
205
+ // Simple minute interval
206
+ if (totalMins <= 59)
207
+ return `*/${totalMins} * * * *`;
208
+ // Hourly intervals
209
+ if (mins === 0 && days === 0 && weeks === 0 && hours > 0 && hours <= 23)
210
+ return `0 */${hours} * * *`;
211
+ // Daily intervals
212
+ if (mins === 0 && hours === 0 && weeks === 0 && days > 0)
213
+ return `0 0 */${days} * *`;
214
+ // Weekly intervals
215
+ if (mins === 0 && hours === 0 && days === 0 && weeks > 0)
216
+ return `0 0 * * 0`;
217
+ // Combined or large intervals — return null, handled by setInterval in scheduleJob
218
+ return null;
219
+ }
220
+ //# sourceMappingURL=scheduler.js.map
@@ -0,0 +1,12 @@
1
+ import type { CronJob } from '../types.js';
2
+ export declare class CronStore {
3
+ constructor();
4
+ list(): CronJob[];
5
+ get(id: string): CronJob | undefined;
6
+ add(job: CronJob): void;
7
+ update(id: string, updates: Partial<CronJob>): boolean;
8
+ delete(id: string): boolean;
9
+ /** One-time migration from crontab.json to SQLite */
10
+ private migrateFromJson;
11
+ }
12
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1,83 @@
1
+ import fs from 'node:fs';
2
+ import { getDb } from '../db/index.js';
3
+ import { getCrontabPath } from '../util/paths.js';
4
+ import { logger } from '../util/logger.js';
5
+ function rowToJob(row) {
6
+ return {
7
+ id: row.id, name: row.name,
8
+ scheduleType: row.schedule_type,
9
+ schedule: row.schedule, tabName: row.tab_name, message: row.message,
10
+ payloadType: row.payload_type || 'agentTurn',
11
+ enabled: row.enabled === 1, createdAt: row.created_at,
12
+ lastRunAt: row.last_run_at, nextRunAt: row.next_run_at,
13
+ };
14
+ }
15
+ export class CronStore {
16
+ constructor() {
17
+ this.migrateFromJson();
18
+ }
19
+ list() {
20
+ const db = getDb();
21
+ return db.prepare('SELECT * FROM cron_jobs WHERE user_id = ? ORDER BY created_at').all('local').map(rowToJob);
22
+ }
23
+ get(id) {
24
+ const db = getDb();
25
+ const row = db.prepare('SELECT * FROM cron_jobs WHERE id = ?').get(id);
26
+ return row ? rowToJob(row) : undefined;
27
+ }
28
+ add(job) {
29
+ const db = getDb();
30
+ db.prepare(`INSERT INTO cron_jobs (id, name, schedule_type, schedule, tab_name, message, payload_type, enabled, user_id, created_at, last_run_at, next_run_at)
31
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(job.id, job.name, job.scheduleType, job.schedule, job.tabName, job.message, job.payloadType || 'agentTurn', job.enabled ? 1 : 0, 'local', job.createdAt, job.lastRunAt, job.nextRunAt);
32
+ }
33
+ update(id, updates) {
34
+ const db = getDb();
35
+ const existing = this.get(id);
36
+ if (!existing)
37
+ return false;
38
+ const merged = { ...existing, ...updates };
39
+ db.prepare(`UPDATE cron_jobs SET name=?, schedule_type=?, schedule=?, tab_name=?, message=?, enabled=?, last_run_at=?, next_run_at=? WHERE id=?`).run(merged.name, merged.scheduleType, merged.schedule, merged.tabName, merged.message, merged.enabled ? 1 : 0, merged.lastRunAt, merged.nextRunAt, id);
40
+ return true;
41
+ }
42
+ delete(id) {
43
+ const db = getDb();
44
+ const result = db.prepare('DELETE FROM cron_jobs WHERE id = ?').run(id);
45
+ return result.changes > 0;
46
+ }
47
+ /** One-time migration from crontab.json to SQLite */
48
+ migrateFromJson() {
49
+ const jsonPath = getCrontabPath();
50
+ if (!fs.existsSync(jsonPath))
51
+ return;
52
+ const db = getDb();
53
+ const count = db.prepare('SELECT COUNT(*) as count FROM cron_jobs').get();
54
+ if (count.count > 0) {
55
+ // Already migrated, clean up JSON
56
+ try {
57
+ fs.renameSync(jsonPath, jsonPath + '.bak');
58
+ }
59
+ catch { /* ok */ }
60
+ return;
61
+ }
62
+ try {
63
+ const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
64
+ const jobs = data.jobs || [];
65
+ if (jobs.length === 0)
66
+ return;
67
+ const insert = db.prepare(`INSERT OR IGNORE INTO cron_jobs (id, name, schedule_type, schedule, tab_name, message, enabled, user_id, created_at, last_run_at, next_run_at)
68
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
69
+ const tx = db.transaction(() => {
70
+ for (const j of jobs) {
71
+ insert.run(j.id, j.name, j.scheduleType, j.schedule, j.tabName || 'default', j.message, j.enabled ? 1 : 0, 'local', j.createdAt, j.lastRunAt, j.nextRunAt);
72
+ }
73
+ });
74
+ tx();
75
+ fs.renameSync(jsonPath, jsonPath + '.bak');
76
+ logger.info(`Migrated ${jobs.length} cron jobs from JSON to SQLite`);
77
+ }
78
+ catch (err) {
79
+ logger.error('Failed to migrate cron jobs from JSON:', err);
80
+ }
81
+ }
82
+ }
83
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1,7 @@
1
+ import type { Machine } from './registry.js';
2
+ /**
3
+ * Forward a message to a remote Beecork instance via SSH.
4
+ * Writes to the remote machine's pending_messages table.
5
+ */
6
+ export declare function forwardToMachine(machine: Machine, tabName: string, message: string): Promise<boolean>;
7
+ //# sourceMappingURL=forwarder.d.ts.map
@@ -0,0 +1,35 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { logger } from '../util/logger.js';
3
+ import { validateTabName } from '../config.js';
4
+ /**
5
+ * Forward a message to a remote Beecork instance via SSH.
6
+ * Writes to the remote machine's pending_messages table.
7
+ */
8
+ export async function forwardToMachine(machine, tabName, message) {
9
+ if (!machine.host || !machine.sshUser) {
10
+ logger.warn(`Cannot forward to ${machine.name}: no SSH config`);
11
+ return false;
12
+ }
13
+ // Validate inputs to prevent injection via execFileSync args
14
+ if (tabName !== 'default') {
15
+ const tabError = validateTabName(tabName);
16
+ if (tabError) {
17
+ logger.error(`Invalid tab name for forwarding: ${tabError}`);
18
+ return false;
19
+ }
20
+ }
21
+ try {
22
+ // Use execFileSync with array args to prevent shell injection
23
+ execFileSync('ssh', [
24
+ `${machine.sshUser}@${machine.host}`,
25
+ 'beecork', 'send', tabName, message,
26
+ ], { timeout: 30000, encoding: 'utf-8' });
27
+ logger.info(`Message forwarded to ${machine.name} (${machine.host}), tab: ${tabName}`);
28
+ return true;
29
+ }
30
+ catch (err) {
31
+ logger.error(`Failed to forward to ${machine.name}:`, err);
32
+ return false;
33
+ }
34
+ }
35
+ //# sourceMappingURL=forwarder.js.map
@@ -0,0 +1 @@
1
+ export { getMachineId, registerThisMachine, listMachines, type Machine } from './registry.js';
@@ -0,0 +1 @@
1
+ export { getMachineId, registerThisMachine, listMachines } from './registry.js';
@@ -0,0 +1,15 @@
1
+ export interface Machine {
2
+ id: string;
3
+ name: string;
4
+ host: string | null;
5
+ sshUser: string | null;
6
+ projectPaths: string[];
7
+ isPrimary: boolean;
8
+ lastSeenAt: string;
9
+ }
10
+ /** Get or create this machine's unique ID */
11
+ export declare function getMachineId(): string;
12
+ /** Register this machine in the database */
13
+ export declare function registerThisMachine(projectPaths: string[]): Machine;
14
+ /** List all machines */
15
+ export declare function listMachines(): Machine[];
@@ -0,0 +1,46 @@
1
+ import os from 'node:os';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import { getDb } from '../db/index.js';
4
+ import { getBeecorkHome } from '../util/paths.js';
5
+ import { logger } from '../util/logger.js';
6
+ import fs from 'node:fs';
7
+ const MACHINE_ID_PATH = `${getBeecorkHome()}/machine-id`;
8
+ /** Get or create this machine's unique ID */
9
+ export function getMachineId() {
10
+ if (fs.existsSync(MACHINE_ID_PATH)) {
11
+ return fs.readFileSync(MACHINE_ID_PATH, 'utf-8').trim();
12
+ }
13
+ const id = uuidv4();
14
+ fs.writeFileSync(MACHINE_ID_PATH, id, { mode: 0o600 });
15
+ return id;
16
+ }
17
+ /** Register this machine in the database */
18
+ export function registerThisMachine(projectPaths) {
19
+ const db = getDb();
20
+ const id = getMachineId();
21
+ const name = os.hostname();
22
+ db.prepare(`
23
+ INSERT INTO machines (id, name, project_paths, last_seen_at)
24
+ VALUES (?, ?, ?, datetime('now'))
25
+ ON CONFLICT(id) DO UPDATE SET
26
+ name = excluded.name,
27
+ project_paths = excluded.project_paths,
28
+ last_seen_at = datetime('now')
29
+ `).run(id, name, JSON.stringify(projectPaths));
30
+ logger.info(`Machine registered: ${name} (${id.slice(0, 8)}) with ${projectPaths.length} project paths`);
31
+ return { id, name, host: null, sshUser: null, projectPaths, isPrimary: false, lastSeenAt: new Date().toISOString() };
32
+ }
33
+ /** List all machines */
34
+ export function listMachines() {
35
+ const db = getDb();
36
+ const rows = db.prepare('SELECT * FROM machines ORDER BY is_primary DESC, name').all();
37
+ return rows.map(r => ({
38
+ id: r.id,
39
+ name: r.name,
40
+ host: r.host,
41
+ sshUser: r.ssh_user,
42
+ projectPaths: JSON.parse(r.project_paths || '[]'),
43
+ isPrimary: !!r.is_primary,
44
+ lastSeenAt: r.last_seen_at,
45
+ }));
46
+ }
@@ -0,0 +1,8 @@
1
+ import type { MediaGenerator } from './types.js';
2
+ /**
3
+ * Discover and load community media generator packages from node_modules.
4
+ * Convention: packages named `beecork-media-<name>` must export a default
5
+ * class implementing the MediaGenerator interface.
6
+ */
7
+ export declare function loadCommunityGenerators(apiKeys?: Record<string, string>): Promise<MediaGenerator[]>;
8
+ //# sourceMappingURL=loader.d.ts.map
@@ -0,0 +1,46 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { logger } from '../util/logger.js';
4
+ const MEDIA_PREFIX = 'beecork-media-';
5
+ /**
6
+ * Discover and load community media generator packages from node_modules.
7
+ * Convention: packages named `beecork-media-<name>` must export a default
8
+ * class implementing the MediaGenerator interface.
9
+ */
10
+ export async function loadCommunityGenerators(apiKeys) {
11
+ const generators = [];
12
+ const searchPaths = [path.join(process.cwd(), 'node_modules')];
13
+ for (const searchPath of searchPaths) {
14
+ if (!fs.existsSync(searchPath))
15
+ continue;
16
+ try {
17
+ for (const dir of fs.readdirSync(searchPath)) {
18
+ if (!dir.startsWith(MEDIA_PREFIX))
19
+ continue;
20
+ try {
21
+ const pkgPath = path.join(searchPath, dir);
22
+ const pkgJson = JSON.parse(fs.readFileSync(path.join(pkgPath, 'package.json'), 'utf-8'));
23
+ const entryPath = path.join(pkgPath, pkgJson.main || 'index.js');
24
+ if (!fs.existsSync(entryPath))
25
+ continue;
26
+ const module = await import(entryPath);
27
+ const GeneratorClass = module.default || module[Object.keys(module)[0]];
28
+ if (!GeneratorClass || typeof GeneratorClass !== 'function')
29
+ continue;
30
+ const name = dir.slice(MEDIA_PREFIX.length);
31
+ const instance = new GeneratorClass(apiKeys?.[name]);
32
+ if (!instance.id || !instance.name || typeof instance.generate !== 'function')
33
+ continue;
34
+ generators.push(instance);
35
+ logger.info(`Community media generator loaded: ${instance.name} (${instance.id})`);
36
+ }
37
+ catch (err) {
38
+ logger.warn(`Failed to load community generator ${dir}:`, err);
39
+ }
40
+ }
41
+ }
42
+ catch { }
43
+ }
44
+ return generators;
45
+ }
46
+ //# sourceMappingURL=loader.js.map
@@ -0,0 +1,5 @@
1
+ import type { BeecorkConfig } from '../types.js';
2
+ /** Auto-extract memories from a completed session */
3
+ export declare function extractMemories(config: BeecorkConfig, tabName: string, sessionText: string, durationMs: number): Promise<void>;
4
+ /** Inject relevant memories into a prompt */
5
+ export declare function getRelevantMemories(tabName: string): string[];
@@ -0,0 +1,157 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { getDb } from '../db/index.js';
3
+ import { logger } from '../util/logger.js';
4
+ const EXTRACTION_PROMPT = `Extract 0-5 key facts, decisions, or outcomes from this session transcript that would be useful in future sessions. Output ONLY a JSON array of strings. If nothing is worth remembering, output an empty array [].
5
+
6
+ Examples of what to extract:
7
+ - Server addresses, credentials, file paths
8
+ - User preferences ("prefers deployments after 11pm")
9
+ - Decisions made ("chose PostgreSQL over MySQL for X reason")
10
+ - Outcomes ("deploy succeeded", "bug was in auth middleware")
11
+
12
+ Session transcript:
13
+ `;
14
+ /** Auto-extract memories from a completed session */
15
+ export async function extractMemories(config, tabName, sessionText, durationMs) {
16
+ // Only extract from non-trivial sessions
17
+ if (durationMs < 10000 || sessionText.length < 200)
18
+ return;
19
+ // Rate limit: check if we extracted recently for this tab
20
+ const db = getDb();
21
+ const recent = db.prepare(`SELECT COUNT(*) as count FROM memories
22
+ WHERE tab_name = ? AND source = 'auto'
23
+ AND created_at > datetime('now', '-5 minutes')`).get(tabName);
24
+ if (recent.count > 0) {
25
+ logger.debug(`[${tabName}] Skipping memory extraction — too recent`);
26
+ return;
27
+ }
28
+ try {
29
+ const prompt = EXTRACTION_PROMPT + sessionText.slice(0, 5000); // Limit transcript size
30
+ const facts = await runExtractionSession(config, prompt);
31
+ if (facts.length === 0)
32
+ return;
33
+ for (const fact of facts) {
34
+ db.prepare('INSERT INTO memories (content, tab_name, source) VALUES (?, ?, ?)').run(fact, tabName, 'auto');
35
+ }
36
+ // Enforce maxLongTermEntries limit
37
+ const maxEntries = config.memory.maxLongTermEntries ?? 1000;
38
+ const count = db.prepare('SELECT COUNT(*) as c FROM memories').get().c;
39
+ if (count > maxEntries) {
40
+ const excess = count - maxEntries;
41
+ db.prepare('DELETE FROM memories WHERE rowid IN (SELECT rowid FROM memories ORDER BY created_at ASC LIMIT ?)').run(excess);
42
+ logger.info(`[${tabName}] Evicted ${excess} oldest memories (limit: ${maxEntries})`);
43
+ }
44
+ logger.info(`[${tabName}] Auto-extracted ${facts.length} memories`);
45
+ }
46
+ catch (err) {
47
+ logger.error(`[${tabName}] Memory extraction failed:`, err);
48
+ }
49
+ }
50
+ /** Inject relevant memories into a prompt */
51
+ export function getRelevantMemories(tabName) {
52
+ const db = getDb();
53
+ // Get recent global memories + tab-specific memories
54
+ const memories = db.prepare(`SELECT content FROM memories
55
+ WHERE tab_name IS NULL OR tab_name = ?
56
+ ORDER BY created_at DESC LIMIT 20`).all(tabName);
57
+ return memories.map(m => m.content);
58
+ }
59
+ async function runExtractionSession(config, prompt) {
60
+ // Prefer direct API call (cheaper, faster) when API key is available
61
+ if (config.pipe?.anthropicApiKey) {
62
+ return runExtractionViaApi(config.pipe.anthropicApiKey, config.pipe.routingModel, prompt);
63
+ }
64
+ // Fallback: spawn Claude Code subprocess
65
+ return runExtractionViaSubprocess(config, prompt);
66
+ }
67
+ function parseFactsFromText(text) {
68
+ const jsonMatch = text.match(/\[[\s\S]*?\]/);
69
+ if (jsonMatch) {
70
+ const facts = JSON.parse(jsonMatch[0]);
71
+ if (Array.isArray(facts) && facts.every(f => typeof f === 'string')) {
72
+ return facts.slice(0, 5);
73
+ }
74
+ }
75
+ return [];
76
+ }
77
+ let cachedClient = null;
78
+ let cachedApiKey = '';
79
+ async function runExtractionViaApi(apiKey, model, prompt) {
80
+ const { default: Anthropic } = await import('@anthropic-ai/sdk');
81
+ if (!cachedClient || cachedApiKey !== apiKey) {
82
+ cachedClient = new Anthropic({ apiKey });
83
+ cachedApiKey = apiKey;
84
+ }
85
+ const client = cachedClient;
86
+ try {
87
+ const response = await client.messages.create({
88
+ model,
89
+ max_tokens: 500,
90
+ system: 'Extract 0-5 key facts from this session transcript worth remembering across sessions. Output ONLY a JSON array of strings. If nothing is worth remembering, output [].',
91
+ messages: [{ role: 'user', content: prompt }],
92
+ }, { timeout: 15000 });
93
+ const text = response.content.find((b) => b.type === 'text')?.text ?? '[]';
94
+ return parseFactsFromText(text);
95
+ }
96
+ catch (err) {
97
+ logger.warn('API-based memory extraction failed, falling back to subprocess:', err);
98
+ return [];
99
+ }
100
+ }
101
+ async function runExtractionViaSubprocess(config, prompt) {
102
+ return new Promise((resolve, reject) => {
103
+ let resolved = false;
104
+ const safeResolve = (val) => { if (!resolved) {
105
+ resolved = true;
106
+ resolve(val);
107
+ } };
108
+ const safeReject = (err) => { if (!resolved) {
109
+ resolved = true;
110
+ reject(err);
111
+ } };
112
+ const args = [
113
+ '-p',
114
+ '--output-format', 'stream-json',
115
+ '--verbose',
116
+ ...config.claudeCode.defaultFlags,
117
+ '--no-session-persistence',
118
+ prompt,
119
+ ];
120
+ const proc = spawn(config.claudeCode.bin, args, {
121
+ cwd: process.cwd(),
122
+ stdio: ['ignore', 'pipe', 'pipe'],
123
+ });
124
+ let output = '';
125
+ let stdoutBuffer = '';
126
+ proc.stdout.on('data', (chunk) => {
127
+ stdoutBuffer += chunk.toString();
128
+ const lines = stdoutBuffer.split('\n');
129
+ stdoutBuffer = lines.pop() || '';
130
+ for (const line of lines) {
131
+ if (!line.trim())
132
+ continue;
133
+ try {
134
+ const event = JSON.parse(line);
135
+ if (event.type === 'result' && event.result) {
136
+ output = event.result;
137
+ }
138
+ }
139
+ catch { /* skip non-JSON */ }
140
+ }
141
+ });
142
+ proc.on('exit', () => {
143
+ clearTimeout(timer);
144
+ try {
145
+ safeResolve(parseFactsFromText(output));
146
+ }
147
+ catch {
148
+ safeResolve([]);
149
+ }
150
+ });
151
+ proc.on('error', safeReject);
152
+ const timer = setTimeout(() => {
153
+ proc.kill('SIGTERM');
154
+ safeResolve([]);
155
+ }, 30000);
156
+ });
157
+ }
@@ -0,0 +1,9 @@
1
+ import type { RouteDecision, Project } from './types.js';
2
+ export declare class PipeAnthropicClient {
3
+ private client;
4
+ private routingModel;
5
+ constructor(apiKey: string, routingModel: string);
6
+ /** Route a message to the right project/tab (Haiku — fast, cheap) */
7
+ route(message: string, projects: Project[], recentRouting: string[]): Promise<RouteDecision>;
8
+ private complete;
9
+ }
@@ -0,0 +1,50 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import { logger } from '../util/logger.js';
3
+ import { retryWithBackoff } from '../util/retry.js';
4
+ export class PipeAnthropicClient {
5
+ client;
6
+ routingModel;
7
+ constructor(apiKey, routingModel) {
8
+ this.client = new Anthropic({ apiKey });
9
+ this.routingModel = routingModel;
10
+ }
11
+ /** Route a message to the right project/tab (Haiku — fast, cheap) */
12
+ async route(message, projects, recentRouting) {
13
+ const projectList = projects.map(p => `- ${p.name}: ${p.path}${p.languages?.length ? ` (${p.languages.join(', ')})` : ''}${p.description ? ` — ${p.description}` : ''}`).join('\n');
14
+ const response = await this.complete(`You are a message router. Given a user message, determine which project it relates to.
15
+
16
+ Available projects:
17
+ ${projectList || '(no projects discovered yet)'}
18
+
19
+ Recent routing decisions:
20
+ ${recentRouting.slice(0, 5).join('\n') || '(none)'}
21
+
22
+ Respond with ONLY valid JSON: {"tabName": "project-name", "projectPath": "/path", "confidence": 0.0-1.0, "reason": "brief explanation", "needsConfirmation": false}
23
+
24
+ If the message is a general question not related to any project, use tabName "default" with the user's home directory.
25
+ If unsure which project, set confidence below 0.5 and needsConfirmation to true.`, message);
26
+ try {
27
+ return JSON.parse(response);
28
+ }
29
+ catch {
30
+ logger.warn('Failed to parse routing response:', response);
31
+ return { tabName: 'default', projectPath: null, confidence: 0.3, reason: 'Could not parse routing', needsConfirmation: true };
32
+ }
33
+ }
34
+ async complete(systemPrompt, userMessage) {
35
+ try {
36
+ const response = await retryWithBackoff(() => this.client.messages.create({
37
+ model: this.routingModel,
38
+ max_tokens: 500,
39
+ system: systemPrompt,
40
+ messages: [{ role: 'user', content: userMessage }],
41
+ }, { timeout: 30000 }), [1000, 5000, 15000], `Pipe API call (routing)`);
42
+ const textBlock = response.content.find(b => b.type === 'text');
43
+ return textBlock?.text ?? '';
44
+ }
45
+ catch (err) {
46
+ logger.error(`Pipe API call failed (routing):`, err);
47
+ throw err;
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,16 @@
1
+ import type { TabManager } from '../session/manager.js';
2
+ import type { BeecorkConfig } from '../types.js';
3
+ import type { ChatContext, PipeResult } from './types.js';
4
+ export declare class PipeBrain {
5
+ private client;
6
+ private memory;
7
+ private config;
8
+ private tabManager;
9
+ constructor(config: BeecorkConfig, tabManager: TabManager);
10
+ /** Main entry point: route a message to the right tab and send it to Claude. */
11
+ process(message: string, _context: ChatContext): Promise<PipeResult>;
12
+ /** Route a message to the right project/tab */
13
+ private route;
14
+ /** Discover projects on the filesystem */
15
+ discoverProjects(): Promise<number>;
16
+ }
@@ -0,0 +1,89 @@
1
+ import { PipeAnthropicClient } from './anthropic-client.js';
2
+ import { PipeMemoryStore } from './memory-store.js';
3
+ import { scanForProjects } from './project-scanner.js';
4
+ import { parseTabMessage } from '../util/text.js';
5
+ import { logger } from '../util/logger.js';
6
+ export class PipeBrain {
7
+ client;
8
+ memory;
9
+ config;
10
+ tabManager;
11
+ constructor(config, tabManager) {
12
+ this.config = config;
13
+ this.tabManager = tabManager;
14
+ this.client = new PipeAnthropicClient(config.pipe.anthropicApiKey, config.pipe.routingModel);
15
+ this.memory = new PipeMemoryStore();
16
+ }
17
+ /** Main entry point: route a message to the right tab and send it to Claude. */
18
+ async process(message, _context) {
19
+ const decisions = [];
20
+ // Step 1: Route the message to the right tab/project
21
+ const route = await this.route(message, decisions);
22
+ // Step 2: Ensure the tab exists with the right working directory
23
+ if (route.projectPath) {
24
+ this.tabManager.ensureTab(route.tabName, route.projectPath);
25
+ this.memory.updateProjectLastUsed(route.projectPath);
26
+ }
27
+ // Step 3: Send to Claude Code
28
+ let result;
29
+ try {
30
+ result = await this.tabManager.sendMessage(route.tabName, message, { projectPath: route.projectPath ?? undefined });
31
+ }
32
+ catch (err) {
33
+ return {
34
+ tabName: route.tabName,
35
+ response: { text: `Error: ${err instanceof Error ? err.message : err}`, error: true, costUsd: 0, durationMs: 0 },
36
+ decisions,
37
+ };
38
+ }
39
+ return {
40
+ tabName: route.tabName,
41
+ response: { text: result.text, error: result.error, costUsd: result.costUsd, durationMs: result.durationMs },
42
+ decisions,
43
+ };
44
+ }
45
+ /** Route a message to the right project/tab */
46
+ async route(message, decisions) {
47
+ // Check for manual /tab override first
48
+ if (message.startsWith('/tab ')) {
49
+ const parsed = parseTabMessage(message);
50
+ if (parsed.tabName !== 'default') {
51
+ decisions.push(`📌 Manual routing to "${parsed.tabName}"`);
52
+ return { tabName: parsed.tabName, projectPath: null, confidence: 1.0, reason: 'Manual override', needsConfirmation: false };
53
+ }
54
+ }
55
+ const projects = this.memory.getProjects();
56
+ const recentRouting = this.memory.getRecentRouting(5);
57
+ // If no projects and no API key, just use default
58
+ if (projects.length === 0 || !this.config.pipe.anthropicApiKey) {
59
+ decisions.push('📍 Routing to default tab (no projects discovered)');
60
+ return { tabName: 'default', projectPath: null, confidence: 1.0, reason: 'No projects', needsConfirmation: false };
61
+ }
62
+ try {
63
+ const route = await this.client.route(message, projects, recentRouting);
64
+ // Record the routing decision
65
+ this.memory.recordRouting(message, route.tabName, route.projectPath, route.confidence);
66
+ if (route.confidence >= this.config.pipe.confidenceThreshold) {
67
+ decisions.push(`🧠 Routing to "${route.tabName}" (${Math.round(route.confidence * 100)}% confidence) — ${route.reason}`);
68
+ }
69
+ else {
70
+ decisions.push(`🤔 Low confidence routing to "${route.tabName}" (${Math.round(route.confidence * 100)}%) — ${route.reason}. Using default.`);
71
+ route.tabName = 'default';
72
+ }
73
+ return route;
74
+ }
75
+ catch (err) {
76
+ logger.error('Pipe routing failed, using default:', err);
77
+ decisions.push('⚠️ Routing failed, using default tab');
78
+ return { tabName: 'default', projectPath: null, confidence: 0.5, reason: 'Routing error', needsConfirmation: false };
79
+ }
80
+ }
81
+ /** Discover projects on the filesystem */
82
+ async discoverProjects() {
83
+ const projects = scanForProjects(this.config.pipe.projectScanPaths);
84
+ for (const project of projects) {
85
+ this.memory.upsertProject(project);
86
+ }
87
+ return projects.length;
88
+ }
89
+ }
@@ -0,0 +1,8 @@
1
+ import type { Project } from './types.js';
2
+ export declare class PipeMemoryStore {
3
+ getProjects(): Project[];
4
+ upsertProject(project: Project): void;
5
+ updateProjectLastUsed(path: string): void;
6
+ getRecentRouting(limit?: number): string[];
7
+ recordRouting(messagePreview: string, tabName: string, projectPath: string | null, confidence: number): void;
8
+ }
@@ -0,0 +1,39 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+ import { getDb } from '../db/index.js';
3
+ export class PipeMemoryStore {
4
+ // ─── Projects ───
5
+ getProjects() {
6
+ const db = getDb();
7
+ const rows = db.prepare('SELECT * FROM projects ORDER BY last_used_at DESC NULLS LAST').all();
8
+ return rows.map(r => ({
9
+ id: r.id,
10
+ name: r.name,
11
+ path: r.path,
12
+ type: r.type || 'user-project',
13
+ lastUsedAt: r.last_used_at,
14
+ createdAt: r.created_at,
15
+ }));
16
+ }
17
+ upsertProject(project) {
18
+ const db = getDb();
19
+ db.prepare(`INSERT INTO projects (id, name, path, type)
20
+ VALUES (?, ?, ?, ?)
21
+ ON CONFLICT(name) DO UPDATE SET
22
+ path=excluded.path, type=excluded.type, last_used_at=datetime('now')
23
+ `).run(project.id || uuidv4(), project.name, project.path, project.type || 'user-project');
24
+ }
25
+ updateProjectLastUsed(path) {
26
+ const db = getDb();
27
+ db.prepare('UPDATE projects SET last_used_at = datetime("now") WHERE path = ?').run(path);
28
+ }
29
+ // ─── Routing History ───
30
+ getRecentRouting(limit = 10) {
31
+ const db = getDb();
32
+ const rows = db.prepare('SELECT message_preview, tab_name, confidence FROM routing_history ORDER BY created_at DESC LIMIT ?').all(limit);
33
+ return rows.map(r => `"${r.message_preview}" → ${r.tab_name} (${Math.round(r.confidence * 100)}%)`);
34
+ }
35
+ recordRouting(messagePreview, tabName, projectPath, confidence) {
36
+ const db = getDb();
37
+ db.prepare('INSERT INTO routing_history (message_preview, tab_name, project_path, confidence) VALUES (?, ?, ?, ?)').run(messagePreview.slice(0, 200), tabName, projectPath, confidence);
38
+ }
39
+ }
@@ -0,0 +1,6 @@
1
+ import type { Project } from './types.js';
2
+ /**
3
+ * Read projects from the database (populated by discoverProjects() at daemon startup).
4
+ * Falls back to an empty list if the DB is not yet initialized.
5
+ */
6
+ export declare function scanForProjects(_scanPaths: string[]): Project[];
@@ -0,0 +1,26 @@
1
+ import { getDb } from '../db/index.js';
2
+ import { logger } from '../util/logger.js';
3
+ /**
4
+ * Read projects from the database (populated by discoverProjects() at daemon startup).
5
+ * Falls back to an empty list if the DB is not yet initialized.
6
+ */
7
+ export function scanForProjects(_scanPaths) {
8
+ try {
9
+ const db = getDb();
10
+ const rows = db.prepare('SELECT * FROM projects ORDER BY last_used_at DESC').all();
11
+ const projects = rows.map(r => ({
12
+ id: r.id,
13
+ name: r.name,
14
+ path: r.path,
15
+ type: r.type,
16
+ lastUsedAt: r.last_used_at,
17
+ createdAt: r.created_at,
18
+ }));
19
+ logger.info(`Project scanner: found ${projects.length} projects from DB`);
20
+ return projects;
21
+ }
22
+ catch (err) {
23
+ logger.warn('Project scanner: failed to read from DB, returning empty list:', err);
24
+ return [];
25
+ }
26
+ }
@@ -0,0 +1,34 @@
1
+ export interface Project {
2
+ id: string;
3
+ name: string;
4
+ path: string;
5
+ type: string;
6
+ lastUsedAt: string;
7
+ createdAt: string;
8
+ /** Populated at scan time, not persisted in DB */
9
+ description?: string;
10
+ /** Populated at scan time, not persisted in DB */
11
+ languages?: string[];
12
+ }
13
+ export interface RouteDecision {
14
+ tabName: string;
15
+ projectPath: string | null;
16
+ confidence: number;
17
+ reason: string;
18
+ needsConfirmation: boolean;
19
+ }
20
+ export interface ChatContext {
21
+ chatId: number;
22
+ userId: number;
23
+ messageId: number;
24
+ }
25
+ export interface PipeResult {
26
+ tabName: string;
27
+ response: {
28
+ text: string;
29
+ error: boolean;
30
+ costUsd: number;
31
+ durationMs: number;
32
+ };
33
+ decisions: string[];
34
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -273,6 +273,43 @@ export class TabManager {
273
273
  .catch(reject);
274
274
  return;
275
275
  }
276
+ // Silent network-stall recovery. The startup watchdog killed a
277
+ // subprocess that never emitted a single event — almost always a
278
+ // transient overnight DNS/connection blip that recovers within
279
+ // seconds. Mirror the stale-session retry above (capped by retryDepth)
280
+ // so a blip recovers inside a single fire instead of burning the full
281
+ // maxRuntime and counting toward the 5-failure auto-disable.
282
+ if (subprocess.killReason === 'silent') {
283
+ if (retryDepth === 0) {
284
+ logger.warn(`[${tab.name}] Subprocess produced no output (suspected transient network stall) — retrying once.`);
285
+ // Don't touch the session — the subprocess may never have
286
+ // initialized one. Retry with identical args.
287
+ this.executeMessage(tab, prompt, resume, onTextChunk, onToolUse, compactionDepth, forceFresh, retryDepth + 1)
288
+ .then(resolve)
289
+ .catch(reject);
290
+ return;
291
+ }
292
+ // Retry already attempted and it stalled again — surface a
293
+ // network-specific failure instead of an empty "(no output)" so the
294
+ // user checks connectivity, not their auth/subscription. Resolve
295
+ // (not reject) with error:true; the scheduler counts this toward
296
+ // auto-disable exactly like the maxRuntime path, and reports it via
297
+ // the existing task-failure notification.
298
+ logger.warn(`[${tab.name}] Still no output after retry — surfacing suspected network stall.`);
299
+ db.prepare('UPDATE tabs SET status = ?, last_activity_at = ?, pid = NULL WHERE name = ?').run('idle', new Date().toISOString(), tab.name);
300
+ logActivity('task_failed', 'Subprocess silent stall (retry exhausted)', {
301
+ tabName: tab.name,
302
+ });
303
+ resolve({
304
+ text: 'Subprocess produced no output, even after a retry — suspected network/DNS stall. Check connectivity.',
305
+ costUsd: 0,
306
+ durationMs: 0,
307
+ sessionId: subprocess.sessionId,
308
+ error: true,
309
+ });
310
+ this.processNextInQueue(tab.name);
311
+ return;
312
+ }
276
313
  // Store assistant response. Skip empty content (typically failed/error runs) so
277
314
  // it doesn't trigger the hasDbHistory shouldResume override on future calls.
278
315
  if (result.text.trim() !== '') {
@@ -15,6 +15,13 @@ export declare class ClaudeSubprocess {
15
15
  private buffer;
16
16
  private killTimer;
17
17
  private runtimeTimer;
18
+ private startupTimer;
19
+ /**
20
+ * Set when the startup watchdog kills a subprocess that never emitted a
21
+ * single event — distinguishes a transient network/connection stall from a
22
+ * normal exit or a real wall-clock overrun, so the manager can retry once.
23
+ */
24
+ killReason: 'silent' | null;
18
25
  readonly sessionId: string;
19
26
  constructor(tabName: string, workingDir: string, config: BeecorkConfig, sessionId?: string, tabSystemPrompt?: string | null | undefined);
20
27
  send(prompt: string, callbacks: SubprocessCallbacks, resume?: boolean): Promise<void>;
@@ -45,6 +45,13 @@ export class ClaudeSubprocess {
45
45
  buffer = '';
46
46
  killTimer = null;
47
47
  runtimeTimer = null;
48
+ startupTimer = null;
49
+ /**
50
+ * Set when the startup watchdog kills a subprocess that never emitted a
51
+ * single event — distinguishes a transient network/connection stall from a
52
+ * normal exit or a real wall-clock overrun, so the manager can retry once.
53
+ */
54
+ killReason = null;
48
55
  sessionId;
49
56
  constructor(tabName, workingDir, config, sessionId, tabSystemPrompt) {
50
57
  this.tabName = tabName;
@@ -75,6 +82,14 @@ export class ClaudeSubprocess {
75
82
  continue;
76
83
  try {
77
84
  const event = JSON.parse(line);
85
+ // First real event proves claude initialized and the socket is alive
86
+ // — the startup-stall window is over, so disarm the watchdog for good.
87
+ // It never re-arms, so legitimate multi-minute tool runs (which emit
88
+ // nothing on stdout until the tool returns) are never killed.
89
+ if (this.startupTimer) {
90
+ clearTimeout(this.startupTimer);
91
+ this.startupTimer = null;
92
+ }
78
93
  callbacks.onEvent(event);
79
94
  }
80
95
  catch {
@@ -99,6 +114,10 @@ export class ClaudeSubprocess {
99
114
  clearTimeout(this.runtimeTimer);
100
115
  this.runtimeTimer = null;
101
116
  }
117
+ if (this.startupTimer) {
118
+ clearTimeout(this.startupTimer);
119
+ this.startupTimer = null;
120
+ }
102
121
  callbacks.onError(err);
103
122
  });
104
123
  this.proc.on('exit', (code) => {
@@ -111,6 +130,10 @@ export class ClaudeSubprocess {
111
130
  clearTimeout(this.runtimeTimer);
112
131
  this.runtimeTimer = null;
113
132
  }
133
+ if (this.startupTimer) {
134
+ clearTimeout(this.startupTimer);
135
+ this.startupTimer = null;
136
+ }
114
137
  logger.info(`[${this.tabName}] Claude subprocess exited (code: ${code})`);
115
138
  callbacks.onExit(code);
116
139
  });
@@ -126,6 +149,24 @@ export class ClaudeSubprocess {
126
149
  this.kill();
127
150
  }, maxRuntimeMs);
128
151
  }
152
+ // Startup watchdog. A healthy claude emits its init event within seconds of
153
+ // spawn. Total silence this long means it wedged on a stalled network socket
154
+ // (DNS/connection blip with no client-side timeout) and would otherwise sit
155
+ // idle until the 30-min maxRuntime kill. Unlike maxRuntime, this routes
156
+ // through onExit (kill only, no onError) so the manager retries once. It is
157
+ // disarmed on the first event, so it can only fire before claude initializes
158
+ // — long-running tools, which emit nothing on stdout until they return, are
159
+ // never affected.
160
+ const silentTimeoutMs = this.config.claudeCode.silentTimeoutMs ?? 120_000;
161
+ if (silentTimeoutMs > 0) {
162
+ this.startupTimer = setTimeout(() => {
163
+ if (!this.proc)
164
+ return;
165
+ logger.warn(`[${this.tabName}] No output ${silentTimeoutMs}ms after spawn — killing as silent network stall`);
166
+ this.killReason = 'silent';
167
+ this.kill();
168
+ }, silentTimeoutMs);
169
+ }
129
170
  }
130
171
  kill() {
131
172
  if (!this.proc)
@@ -0,0 +1,4 @@
1
+ /** Reserved for future approval mode implementation. Not currently wired into the runtime. */
2
+ export type ToolRisk = 'safe' | 'dangerous';
3
+ /** Classify a tool call as safe or dangerous */
4
+ export declare function classifyTool(toolName: string, input: Record<string, unknown>): ToolRisk;
@@ -0,0 +1,56 @@
1
+ const SAFE_TOOLS = new Set([
2
+ 'Read', 'Glob', 'Grep', 'LSP', 'WebFetch', 'WebSearch',
3
+ 'ToolSearch', 'TaskGet', 'TaskList',
4
+ ]);
5
+ const DANGEROUS_TOOLS = new Set([
6
+ 'Write', 'Edit', 'NotebookEdit',
7
+ 'TaskCreate', 'TaskUpdate', 'TaskStop',
8
+ ]);
9
+ const SAFE_BASH_PATTERNS = [
10
+ /^(ls|cat|head|tail|wc|file|stat|which|type|echo|printf)\b/,
11
+ /^git\s+(status|log|diff|show|branch|tag|remote)\b/,
12
+ /^(pwd|whoami|hostname|date|uname|env|printenv)\b/,
13
+ /^(find|grep|rg|fd|ag)\b/,
14
+ /^(node|python|ruby|go)\s+--?(version|help)/,
15
+ /^npm\s+(list|ls|view|info|outdated|audit)\b/,
16
+ /^curl\s.*-X\s*GET\b/,
17
+ /^curl\s+(?!.*-X\s*(POST|PUT|DELETE|PATCH))(?!.*--data)(?!.*-d\s)/,
18
+ ];
19
+ const DANGEROUS_BASH_PATTERNS = [
20
+ /^rm\b/,
21
+ /^(mv|cp)\b.*--?(force|f)\b/,
22
+ /^chmod\b/,
23
+ /^chown\b/,
24
+ /^git\s+(push|reset|rebase|merge|checkout\s+--)\b/,
25
+ /^(docker|kubectl|terraform|ansible)\b/,
26
+ /^(sudo|su)\b/,
27
+ /^npm\s+(publish|install|uninstall|link)\b/,
28
+ /^(kill|killall|pkill)\b/,
29
+ /^curl\s.*-X\s*(POST|PUT|DELETE|PATCH)\b/,
30
+ ];
31
+ /** Classify a tool call as safe or dangerous */
32
+ export function classifyTool(toolName, input) {
33
+ if (SAFE_TOOLS.has(toolName))
34
+ return 'safe';
35
+ if (DANGEROUS_TOOLS.has(toolName))
36
+ return 'dangerous';
37
+ // Bash tool: inspect the command
38
+ if (toolName === 'Bash') {
39
+ const command = String(input.command || '').trim();
40
+ for (const pattern of SAFE_BASH_PATTERNS) {
41
+ if (pattern.test(command))
42
+ return 'safe';
43
+ }
44
+ for (const pattern of DANGEROUS_BASH_PATTERNS) {
45
+ if (pattern.test(command))
46
+ return 'dangerous';
47
+ }
48
+ // Default: unknown bash commands are dangerous
49
+ return 'dangerous';
50
+ }
51
+ // MCP tools from external servers: default to dangerous
52
+ if (toolName.startsWith('mcp__'))
53
+ return 'dangerous';
54
+ // Unknown tools: default to dangerous
55
+ return 'dangerous';
56
+ }
package/dist/types.d.ts CHANGED
@@ -10,6 +10,15 @@ export interface ClaudeCodeConfig {
10
10
  computerUse?: boolean;
11
11
  /** Hard timeout per subprocess turn. Default 30 min. Set to 0 to disable. */
12
12
  maxRuntimeMs?: number;
13
+ /**
14
+ * Startup watchdog: kill (and retry once) a subprocess that produces ZERO
15
+ * events this long after spawn. A healthy claude emits its init event within
16
+ * seconds; prolonged total silence means it wedged on a stalled network
17
+ * socket (DNS/connection blip with no client-side timeout). Default 2 min.
18
+ * Set to 0 to disable. Disarms on the first event, so it never fires
19
+ * mid-task — long-running tools are unaffected.
20
+ */
21
+ silentTimeoutMs?: number;
13
22
  }
14
23
  export interface MemoryConfig {
15
24
  dbPath: string;
@@ -0,0 +1,2 @@
1
+ export { resolveUser, registerUser, linkIdentity, listUsers, hasAdmin } from './service.js';
2
+ export type { User } from './service.js';
@@ -0,0 +1 @@
1
+ export { resolveUser, registerUser, linkIdentity, listUsers, hasAdmin } from './service.js';
@@ -0,0 +1,17 @@
1
+ export interface User {
2
+ id: string;
3
+ name: string;
4
+ role: 'admin' | 'user';
5
+ budgetUsd: number | null;
6
+ createdAt: string;
7
+ }
8
+ /** Get or create a user from a channel identity */
9
+ export declare function resolveUser(channelId: string, peerId: string): User | null;
10
+ /** Register a new user */
11
+ export declare function registerUser(name: string, channelId: string, peerId: string, role?: 'admin' | 'user'): User;
12
+ /** Link an additional channel identity to an existing user */
13
+ export declare function linkIdentity(userId: string, channelId: string, peerId: string): boolean;
14
+ /** Get all users */
15
+ export declare function listUsers(): User[];
16
+ /** Check if any admin exists */
17
+ export declare function hasAdmin(): boolean;
@@ -0,0 +1,46 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+ import { getDb } from '../db/index.js';
3
+ import { logger } from '../util/logger.js';
4
+ function rowToUser(row) {
5
+ return { id: row.id, name: row.name, role: row.role, budgetUsd: row.budget_usd, createdAt: row.created_at };
6
+ }
7
+ /** Get or create a user from a channel identity */
8
+ export function resolveUser(channelId, peerId) {
9
+ const db = getDb();
10
+ const identity = db.prepare('SELECT user_id FROM identities WHERE channel_id = ? AND peer_id = ?').get(channelId, peerId);
11
+ if (!identity)
12
+ return null;
13
+ const row = db.prepare('SELECT * FROM users WHERE id = ?').get(identity.user_id);
14
+ return row ? rowToUser(row) : null;
15
+ }
16
+ /** Register a new user */
17
+ export function registerUser(name, channelId, peerId, role = 'user') {
18
+ const db = getDb();
19
+ const id = uuidv4();
20
+ db.prepare('INSERT INTO users (id, name, role) VALUES (?, ?, ?)').run(id, name, role);
21
+ db.prepare('INSERT INTO identities (user_id, channel_id, peer_id) VALUES (?, ?, ?)').run(id, channelId, peerId);
22
+ logger.info(`User registered: ${name} (${role}) via ${channelId}:${peerId}`);
23
+ return { id, name, role, budgetUsd: null, createdAt: new Date().toISOString() };
24
+ }
25
+ /** Link an additional channel identity to an existing user */
26
+ export function linkIdentity(userId, channelId, peerId) {
27
+ const db = getDb();
28
+ try {
29
+ db.prepare('INSERT INTO identities (user_id, channel_id, peer_id) VALUES (?, ?, ?)').run(userId, channelId, peerId);
30
+ return true;
31
+ }
32
+ catch {
33
+ return false; // Already linked or conflict
34
+ }
35
+ }
36
+ /** Get all users */
37
+ export function listUsers() {
38
+ const db = getDb();
39
+ return db.prepare('SELECT * FROM users ORDER BY created_at').all().map(rowToUser);
40
+ }
41
+ /** Check if any admin exists */
42
+ export function hasAdmin() {
43
+ const db = getDb();
44
+ const row = db.prepare("SELECT COUNT(*) as c FROM users WHERE role = 'admin'").get();
45
+ return row.c > 0;
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beecork",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "Claude Code always-on infrastructure — a phone number, a memory, and an alarm clock",
5
5
  "type": "module",
6
6
  "bin": {