beecork 1.6.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.
- package/dist/cron/scheduler.d.ts +22 -0
- package/dist/cron/scheduler.js +220 -0
- package/dist/cron/store.d.ts +12 -0
- package/dist/cron/store.js +83 -0
- package/dist/machines/forwarder.d.ts +7 -0
- package/dist/machines/forwarder.js +35 -0
- package/dist/machines/index.d.ts +1 -0
- package/dist/machines/index.js +1 -0
- package/dist/machines/registry.d.ts +15 -0
- package/dist/machines/registry.js +46 -0
- package/dist/media/loader.d.ts +8 -0
- package/dist/media/loader.js +46 -0
- package/dist/memory/extractor.d.ts +5 -0
- package/dist/memory/extractor.js +157 -0
- package/dist/pipe/anthropic-client.d.ts +9 -0
- package/dist/pipe/anthropic-client.js +50 -0
- package/dist/pipe/brain.d.ts +16 -0
- package/dist/pipe/brain.js +89 -0
- package/dist/pipe/memory-store.d.ts +8 -0
- package/dist/pipe/memory-store.js +39 -0
- package/dist/pipe/project-scanner.d.ts +6 -0
- package/dist/pipe/project-scanner.js +26 -0
- package/dist/pipe/types.d.ts +34 -0
- package/dist/pipe/types.js +1 -0
- package/dist/session/manager.js +37 -0
- package/dist/session/subprocess.d.ts +7 -0
- package/dist/session/subprocess.js +41 -0
- package/dist/session/tool-classifier.d.ts +4 -0
- package/dist/session/tool-classifier.js +56 -0
- package/dist/types.d.ts +9 -0
- package/dist/users/index.d.ts +2 -0
- package/dist/users/index.js +1 -0
- package/dist/users/service.d.ts +17 -0
- package/dist/users/service.js +46 -0
- package/package.json +14 -14
|
@@ -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 {};
|
package/dist/session/manager.js
CHANGED
|
@@ -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 @@
|
|
|
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.
|
|
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": {
|
|
@@ -23,29 +23,29 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
26
|
-
"@whiskeysockets/baileys": "^
|
|
27
|
-
"better-sqlite3": "^12.
|
|
26
|
+
"@whiskeysockets/baileys": "^7.0.0-rc11",
|
|
27
|
+
"better-sqlite3": "^12.10.0",
|
|
28
28
|
"commander": "^14.0.3",
|
|
29
29
|
"cron-parser": "^5.5.0",
|
|
30
|
-
"discord.js": "^14.26.
|
|
30
|
+
"discord.js": "^14.26.4",
|
|
31
31
|
"node-telegram-bot-api": "^0.67.0",
|
|
32
|
-
"pino": "^
|
|
32
|
+
"pino": "^10.3.1",
|
|
33
33
|
"qrcode-terminal": "^0.12.0",
|
|
34
|
-
"uuid": "^
|
|
34
|
+
"uuid": "^14.0.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
-
"@tailwindcss/cli": "^4.
|
|
37
|
+
"@tailwindcss/cli": "^4.3.0",
|
|
38
38
|
"@types/better-sqlite3": "^7.6.13",
|
|
39
|
-
"@types/node": "^24.
|
|
39
|
+
"@types/node": "^24.12.4",
|
|
40
40
|
"@types/node-telegram-bot-api": "^0.64.14",
|
|
41
|
-
"eslint": "^10.
|
|
41
|
+
"eslint": "^10.4.0",
|
|
42
42
|
"eslint-config-prettier": "^10.1.8",
|
|
43
43
|
"prettier": "^3.8.3",
|
|
44
|
-
"tailwindcss": "^4.
|
|
45
|
-
"tsx": "^4.
|
|
46
|
-
"typescript": "^6.0.
|
|
47
|
-
"typescript-eslint": "^8.
|
|
48
|
-
"vitest": "^4.1.
|
|
44
|
+
"tailwindcss": "^4.3.0",
|
|
45
|
+
"tsx": "^4.22.0",
|
|
46
|
+
"typescript": "^6.0.3",
|
|
47
|
+
"typescript-eslint": "^8.59.3",
|
|
48
|
+
"vitest": "^4.1.6"
|
|
49
49
|
},
|
|
50
50
|
"files": [
|
|
51
51
|
"dist/",
|