@siftd/connect-agent 0.1.0 → 0.2.2

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,1001 @@
1
+ /**
2
+ * Master Orchestrator Agent
3
+ *
4
+ * This is the BRAIN - it orchestrates, remembers, and delegates.
5
+ * It does NOT do the work itself. Claude Code CLI workers do the work.
6
+ */
7
+ import Anthropic from '@anthropic-ai/sdk';
8
+ import { spawn, execSync } from 'child_process';
9
+ import { existsSync } from 'fs';
10
+ import { AdvancedMemoryStore } from './core/memory-advanced.js';
11
+ import { TaskScheduler } from './core/scheduler.js';
12
+ import { SystemIndexer } from './core/system-indexer.js';
13
+ import { BashTool } from './tools/bash.js';
14
+ import { WebTools } from './tools/web.js';
15
+ import { WorkerTools } from './tools/worker.js';
16
+ import { getKnowledgeForPrompt } from './genesis/index.js';
17
+ const SYSTEM_PROMPT = `You're the user's personal assistant - the friendly brain behind their Connect app. You chat with them from any browser, remember everything about them, and dispatch Claude Code workers to do things on their machine.
18
+
19
+ CRITICAL - READ THIS:
20
+ The user is on a WEB BROWSER. They CANNOT run terminal commands. They CANNOT access a shell.
21
+ YOU are their hands. When something needs to be done, YOU do it with your tools.
22
+ NEVER say "run this command" or "you'll need to..." - the user CAN'T do that.
23
+
24
+ YOUR TOOLS (use them!):
25
+ - bash: Run any shell command directly
26
+ - web_search: Search the internet for information
27
+ - fetch_url: Fetch and read web page content
28
+ - spawn_worker: Spawn background Claude Code workers for complex multi-step tasks
29
+ - delegate_to_worker: Delegate complex tasks that need full CLI autonomy
30
+ - remember/search_memory: Persistent memory across conversations
31
+ - start_local_server/open_browser: Serve and preview web projects
32
+
33
+ WHEN TO USE WHAT:
34
+ - Simple commands (ls, cat, git status, npm install) → use bash directly
35
+ - Web research → use web_search + fetch_url
36
+ - Complex multi-file changes, builds, deployments → spawn_worker or delegate_to_worker
37
+ - Quick checks or reads → bash
38
+ - Long-running background tasks → spawn_worker (non-blocking)
39
+
40
+ FILESYSTEM AWARENESS:
41
+ You know the user's filesystem. Their projects, directories, and files are indexed in your memory.
42
+ When they mention "that project", "the connect app", "downloads folder", etc. - SEARCH YOUR MEMORY.
43
+ Use search_memory to find the actual paths before working.
44
+
45
+ WHO YOU ARE:
46
+ Warm, helpful, and genuinely interested in what the user is working on. Like a knowledgeable friend with superpowers - you remember their preferences, track their projects, and make things happen.
47
+
48
+ WHAT YOU CAN DO:
49
+ - Run shell commands directly with bash
50
+ - Search the web and fetch URLs
51
+ - Dispatch Claude Code workers for complex work
52
+ - Start local servers and open browsers
53
+ - Remember things across conversations
54
+ - Schedule tasks for later
55
+ - Have real conversations
56
+
57
+ HOW TO TALK:
58
+ - Be yourself - warm, direct, helpful
59
+ - Keep it conversational
60
+ - Show you remember them
61
+ - Celebrate progress - "Nice, that's done!" not "Task completed successfully."
62
+ - Be honest about what you're doing
63
+ - If something fails, try a different approach or explain what happened
64
+
65
+ SLASH COMMANDS:
66
+ - /reset - Clear conversation and memory context
67
+ - /verbose - Toggle showing tool execution details
68
+ - /help - Show available commands
69
+
70
+ You're the persistent layer that makes AI feel personal. You ACT on behalf of the user - you don't give them homework.`;
71
+ export class MasterOrchestrator {
72
+ client;
73
+ model;
74
+ maxTokens;
75
+ memory;
76
+ scheduler;
77
+ indexer;
78
+ jobs = new Map();
79
+ jobCounter = 0;
80
+ userId;
81
+ workspaceDir;
82
+ claudePath;
83
+ initialized = false;
84
+ // New tools from whatsapp-claude
85
+ bashTool;
86
+ webTools;
87
+ workerTools;
88
+ verboseMode = new Map(); // per-user verbose mode
89
+ constructor(options) {
90
+ this.client = new Anthropic({ apiKey: options.apiKey });
91
+ this.model = options.model || 'claude-sonnet-4-20250514';
92
+ this.maxTokens = options.maxTokens || 4096;
93
+ this.userId = options.userId;
94
+ this.workspaceDir = options.workspaceDir || process.env.HOME || '/tmp';
95
+ // Find claude binary - critical for worker delegation
96
+ this.claudePath = this.findClaudeBinary();
97
+ console.log(`[ORCHESTRATOR] Claude binary: ${this.claudePath}`);
98
+ // Initialize memory with user-specific paths
99
+ this.memory = new AdvancedMemoryStore({
100
+ dbPath: `memories_${options.userId}.db`,
101
+ vectorPath: `./memory_vectors_${options.userId}`,
102
+ voyageApiKey: options.voyageApiKey
103
+ });
104
+ this.scheduler = new TaskScheduler(`scheduled_${options.userId}.json`);
105
+ // Create indexer (will index on first initialize call)
106
+ this.indexer = new SystemIndexer(this.memory);
107
+ // Initialize new tools
108
+ this.bashTool = new BashTool(this.workspaceDir);
109
+ this.webTools = new WebTools();
110
+ this.workerTools = new WorkerTools(this.workspaceDir);
111
+ }
112
+ /**
113
+ * Initialize the orchestrator - indexes filesystem on first call
114
+ */
115
+ async initialize() {
116
+ if (this.initialized)
117
+ return;
118
+ console.log('[ORCHESTRATOR] Initializing...');
119
+ // Index the filesystem into memory
120
+ try {
121
+ const result = await this.indexer.indexHome();
122
+ console.log(`[ORCHESTRATOR] Filesystem indexed: ${result.projects} projects, ${result.directories} directories`);
123
+ }
124
+ catch (error) {
125
+ console.error('[ORCHESTRATOR] Filesystem indexing failed:', error);
126
+ // Continue anyway - indexing is a nice-to-have
127
+ }
128
+ this.initialized = true;
129
+ }
130
+ /**
131
+ * Find the claude binary path
132
+ */
133
+ findClaudeBinary() {
134
+ // Since we spawn with shell: true, just use 'claude' command
135
+ // Shell will resolve PATH correctly (including NVM)
136
+ // Verify it exists by running 'which claude'
137
+ try {
138
+ const path = execSync('which claude', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
139
+ if (path) {
140
+ console.log(`[ORCHESTRATOR] Claude found at: ${path}`);
141
+ return 'claude'; // Use command name, not path (shell resolves it)
142
+ }
143
+ }
144
+ catch {
145
+ console.warn('[ORCHESTRATOR] Warning: claude not found in PATH');
146
+ }
147
+ return 'claude';
148
+ }
149
+ /**
150
+ * Handle slash commands
151
+ */
152
+ handleSlashCommand(message) {
153
+ const lower = message.trim().toLowerCase();
154
+ if (lower === '/help') {
155
+ return `**Available Commands:**
156
+ - \`/reset\` - Clear conversation context
157
+ - \`/verbose\` - Toggle verbose mode (show tool execution details)
158
+ - \`/help\` - Show this help message
159
+
160
+ **What I can do:**
161
+ - Run shell commands, search the web, fetch URLs
162
+ - Spawn Claude Code workers for complex tasks
163
+ - Remember things across our conversations
164
+ - Schedule tasks for later
165
+ - Start local servers and open your browser`;
166
+ }
167
+ if (lower === '/verbose') {
168
+ const current = this.verboseMode.get(this.userId) || false;
169
+ this.verboseMode.set(this.userId, !current);
170
+ return `Verbose mode ${!current ? 'enabled' : 'disabled'}. ${!current ? 'I\'ll show you what tools I\'m using.' : 'Back to concise mode.'}`;
171
+ }
172
+ if (lower === '/reset') {
173
+ // Note: actual conversation reset happens in the agent.ts caller
174
+ return 'Conversation reset. Fresh start!';
175
+ }
176
+ return null;
177
+ }
178
+ /**
179
+ * Check if verbose mode is enabled
180
+ */
181
+ isVerbose() {
182
+ return this.verboseMode.get(this.userId) || false;
183
+ }
184
+ /**
185
+ * Process a user message
186
+ * @param apiKey Optional per-request API key (overrides default)
187
+ */
188
+ async processMessage(message, conversationHistory = [], sendMessage, apiKey) {
189
+ // Handle slash commands first
190
+ const slashResponse = this.handleSlashCommand(message);
191
+ if (slashResponse) {
192
+ return slashResponse;
193
+ }
194
+ // Build context from memory
195
+ const memoryContext = await this.getMemoryContext(message);
196
+ // Build system prompt with genesis knowledge and memory context
197
+ const genesisKnowledge = getKnowledgeForPrompt();
198
+ let systemWithContext = SYSTEM_PROMPT + genesisKnowledge;
199
+ if (memoryContext) {
200
+ systemWithContext += `\n\nRELEVANT MEMORIES:\n${memoryContext}`;
201
+ }
202
+ // Add user message
203
+ const messages = [
204
+ ...conversationHistory,
205
+ { role: 'user', content: message }
206
+ ];
207
+ try {
208
+ const response = await this.runAgentLoop(messages, systemWithContext, sendMessage, apiKey);
209
+ // Auto-remember important things from the conversation
210
+ await this.autoRemember(message, response);
211
+ return response;
212
+ }
213
+ catch (error) {
214
+ const errorMessage = error instanceof Error ? error.message : String(error);
215
+ return `Error: ${errorMessage}`;
216
+ }
217
+ }
218
+ /**
219
+ * Get relevant memory context for a message
220
+ */
221
+ async getMemoryContext(message) {
222
+ try {
223
+ const memories = await this.memory.search(message, { limit: 5 });
224
+ if (memories.length === 0)
225
+ return null;
226
+ return memories.map(m => `[${m.type}] ${m.content}`).join('\n');
227
+ }
228
+ catch {
229
+ return null;
230
+ }
231
+ }
232
+ /**
233
+ * Auto-remember important information from conversations
234
+ */
235
+ async autoRemember(userMessage, response) {
236
+ // Look for preference indicators
237
+ const prefIndicators = ['i prefer', 'i like', 'i always', 'i never', 'i use', 'my favorite'];
238
+ const lower = userMessage.toLowerCase();
239
+ for (const indicator of prefIndicators) {
240
+ if (lower.includes(indicator)) {
241
+ await this.memory.remember(userMessage, {
242
+ type: 'semantic',
243
+ source: 'auto-extract',
244
+ importance: 0.7,
245
+ tags: ['preference', 'user']
246
+ });
247
+ break;
248
+ }
249
+ }
250
+ }
251
+ /**
252
+ * Run the agentic loop
253
+ * @param apiKey Optional per-request API key (creates temporary client)
254
+ */
255
+ async runAgentLoop(messages, system, sendMessage, apiKey) {
256
+ const tools = this.getToolDefinitions();
257
+ let currentMessages = [...messages];
258
+ let iterations = 0;
259
+ const maxIterations = 30; // Increased for complex multi-tool tasks
260
+ // Use per-request client if apiKey provided, otherwise use default
261
+ const client = apiKey ? new Anthropic({ apiKey }) : this.client;
262
+ while (iterations < maxIterations) {
263
+ iterations++;
264
+ const response = await client.messages.create({
265
+ model: this.model,
266
+ max_tokens: this.maxTokens,
267
+ system,
268
+ tools,
269
+ messages: currentMessages
270
+ });
271
+ // Check if done
272
+ if (response.stop_reason === 'end_turn' || !this.hasToolUse(response.content)) {
273
+ return this.extractText(response.content);
274
+ }
275
+ // Process tool calls
276
+ const toolResults = await this.processToolCalls(response.content, sendMessage);
277
+ // Continue conversation
278
+ currentMessages = [
279
+ ...currentMessages,
280
+ { role: 'assistant', content: response.content },
281
+ { role: 'user', content: toolResults }
282
+ ];
283
+ }
284
+ return 'Maximum iterations reached.';
285
+ }
286
+ /**
287
+ * Get tool definitions for the orchestrator
288
+ */
289
+ getToolDefinitions() {
290
+ return [
291
+ // Direct bash execution - for simple commands
292
+ {
293
+ name: 'bash',
294
+ description: 'Execute a shell command directly. Use for simple commands like ls, cat, git status, npm install, etc. For complex multi-step tasks, use spawn_worker instead.',
295
+ input_schema: {
296
+ type: 'object',
297
+ properties: {
298
+ command: {
299
+ type: 'string',
300
+ description: 'The bash command to execute'
301
+ },
302
+ timeout: {
303
+ type: 'number',
304
+ description: 'Timeout in milliseconds (default: 30000, max: 120000)'
305
+ }
306
+ },
307
+ required: ['command']
308
+ }
309
+ },
310
+ // Web tools
311
+ {
312
+ name: 'web_search',
313
+ description: 'Search the web for information. Returns titles, URLs, and snippets from search results.',
314
+ input_schema: {
315
+ type: 'object',
316
+ properties: {
317
+ query: {
318
+ type: 'string',
319
+ description: 'The search query'
320
+ },
321
+ num_results: {
322
+ type: 'number',
323
+ description: 'Number of results to return (default: 5, max: 10)'
324
+ }
325
+ },
326
+ required: ['query']
327
+ }
328
+ },
329
+ {
330
+ name: 'fetch_url',
331
+ description: 'Fetch and parse content from a URL. Returns extracted text content by default.',
332
+ input_schema: {
333
+ type: 'object',
334
+ properties: {
335
+ url: {
336
+ type: 'string',
337
+ description: 'The URL to fetch'
338
+ },
339
+ format: {
340
+ type: 'string',
341
+ enum: ['text', 'html', 'json'],
342
+ description: 'Output format (default: text)'
343
+ },
344
+ timeout: {
345
+ type: 'number',
346
+ description: 'Timeout in milliseconds (default: 10000)'
347
+ }
348
+ },
349
+ required: ['url']
350
+ }
351
+ },
352
+ // Worker tools - for parallel/background tasks
353
+ {
354
+ name: 'spawn_worker',
355
+ description: 'Spawn a Claude Code worker for a complex task. Non-blocking - returns immediately with a job ID. Use check_worker to monitor progress.',
356
+ input_schema: {
357
+ type: 'object',
358
+ properties: {
359
+ task: {
360
+ type: 'string',
361
+ description: 'The task description for the worker'
362
+ },
363
+ timeout: {
364
+ type: 'number',
365
+ description: 'Timeout in milliseconds (default: 300000 / 5 min)'
366
+ },
367
+ priority: {
368
+ type: 'string',
369
+ enum: ['low', 'normal', 'high', 'critical'],
370
+ description: 'Task priority'
371
+ }
372
+ },
373
+ required: ['task']
374
+ }
375
+ },
376
+ {
377
+ name: 'check_worker',
378
+ description: 'Check the status and result of a spawned worker.',
379
+ input_schema: {
380
+ type: 'object',
381
+ properties: {
382
+ job_id: {
383
+ type: 'string',
384
+ description: 'The job ID from spawn_worker'
385
+ }
386
+ },
387
+ required: ['job_id']
388
+ }
389
+ },
390
+ {
391
+ name: 'wait_worker',
392
+ description: 'Wait for a worker to complete and return its result. Blocks until done.',
393
+ input_schema: {
394
+ type: 'object',
395
+ properties: {
396
+ job_id: {
397
+ type: 'string',
398
+ description: 'The job ID to wait for'
399
+ },
400
+ max_wait: {
401
+ type: 'number',
402
+ description: 'Maximum wait time in milliseconds'
403
+ }
404
+ },
405
+ required: ['job_id']
406
+ }
407
+ },
408
+ {
409
+ name: 'list_workers',
410
+ description: 'List all worker jobs with their status.',
411
+ input_schema: {
412
+ type: 'object',
413
+ properties: {
414
+ status: {
415
+ type: 'string',
416
+ description: 'Filter by status (running, completed, failed)'
417
+ }
418
+ },
419
+ required: []
420
+ }
421
+ },
422
+ {
423
+ name: 'cancel_worker',
424
+ description: 'Cancel a running worker job.',
425
+ input_schema: {
426
+ type: 'object',
427
+ properties: {
428
+ job_id: {
429
+ type: 'string',
430
+ description: 'The job ID to cancel'
431
+ }
432
+ },
433
+ required: ['job_id']
434
+ }
435
+ },
436
+ // Delegate tool - for full CLI autonomy
437
+ {
438
+ name: 'delegate_to_worker',
439
+ description: `Delegate a task to Claude Code CLI worker. Use for complex multi-step tasks that need full CLI autonomy.
440
+
441
+ Use this for ANY task that involves:
442
+ - Reading, writing, or editing files
443
+ - Running shell commands
444
+ - Exploring codebases
445
+ - Making code changes
446
+ - Building, testing, deploying
447
+ - ANY filesystem or system operation
448
+
449
+ The worker will execute the task autonomously and return results.
450
+ Be specific about what you want done.`,
451
+ input_schema: {
452
+ type: 'object',
453
+ properties: {
454
+ task: {
455
+ type: 'string',
456
+ description: 'Detailed description of what the worker should do'
457
+ },
458
+ context: {
459
+ type: 'string',
460
+ description: 'Additional context (user preferences, project info, etc)'
461
+ },
462
+ working_directory: {
463
+ type: 'string',
464
+ description: 'Directory to work in (defaults to user home)'
465
+ }
466
+ },
467
+ required: ['task']
468
+ }
469
+ },
470
+ {
471
+ name: 'remember',
472
+ description: 'Store something in persistent memory. Use for user preferences, project info, important facts.',
473
+ input_schema: {
474
+ type: 'object',
475
+ properties: {
476
+ content: {
477
+ type: 'string',
478
+ description: 'What to remember'
479
+ },
480
+ type: {
481
+ type: 'string',
482
+ enum: ['episodic', 'semantic', 'procedural'],
483
+ description: 'Memory type: episodic (events), semantic (facts), procedural (how-to)'
484
+ },
485
+ importance: {
486
+ type: 'number',
487
+ description: 'Importance 0-1 (higher = remembered longer)'
488
+ }
489
+ },
490
+ required: ['content']
491
+ }
492
+ },
493
+ {
494
+ name: 'search_memory',
495
+ description: 'Search your memory for relevant information. Use before delegating to include user context.',
496
+ input_schema: {
497
+ type: 'object',
498
+ properties: {
499
+ query: {
500
+ type: 'string',
501
+ description: 'What to search for'
502
+ },
503
+ type: {
504
+ type: 'string',
505
+ enum: ['episodic', 'semantic', 'procedural'],
506
+ description: 'Filter by memory type'
507
+ }
508
+ },
509
+ required: ['query']
510
+ }
511
+ },
512
+ {
513
+ name: 'schedule_task',
514
+ description: 'Schedule a task for future execution.',
515
+ input_schema: {
516
+ type: 'object',
517
+ properties: {
518
+ task: {
519
+ type: 'string',
520
+ description: 'The task to execute later'
521
+ },
522
+ when: {
523
+ type: 'string',
524
+ description: 'When to run (e.g., "in 30 minutes", "daily at 9:00", "tomorrow at 14:00")'
525
+ }
526
+ },
527
+ required: ['task', 'when']
528
+ }
529
+ },
530
+ {
531
+ name: 'list_scheduled',
532
+ description: 'List all scheduled tasks.',
533
+ input_schema: {
534
+ type: 'object',
535
+ properties: {},
536
+ required: []
537
+ }
538
+ },
539
+ {
540
+ name: 'memory_stats',
541
+ description: 'Get statistics about your memory system.',
542
+ input_schema: {
543
+ type: 'object',
544
+ properties: {},
545
+ required: []
546
+ }
547
+ },
548
+ {
549
+ name: 'open_browser',
550
+ description: 'Open a URL or file in the user\'s default browser. Use for opening localhost servers, web pages, or local HTML files.',
551
+ input_schema: {
552
+ type: 'object',
553
+ properties: {
554
+ url: {
555
+ type: 'string',
556
+ description: 'URL to open (e.g., http://localhost:8080, https://example.com, or file path)'
557
+ }
558
+ },
559
+ required: ['url']
560
+ }
561
+ },
562
+ {
563
+ name: 'start_local_server',
564
+ description: 'Start a persistent HTTP server to serve a web project. Server runs in background and survives after this call. Use before open_browser for local web projects.',
565
+ input_schema: {
566
+ type: 'object',
567
+ properties: {
568
+ directory: {
569
+ type: 'string',
570
+ description: 'Directory to serve (absolute path)'
571
+ },
572
+ port: {
573
+ type: 'number',
574
+ description: 'Port to serve on (default: 8080)'
575
+ }
576
+ },
577
+ required: ['directory']
578
+ }
579
+ },
580
+ {
581
+ name: 'stop_local_server',
582
+ description: 'Stop a running local server on a specific port.',
583
+ input_schema: {
584
+ type: 'object',
585
+ properties: {
586
+ port: {
587
+ type: 'number',
588
+ description: 'Port of the server to stop'
589
+ }
590
+ },
591
+ required: ['port']
592
+ }
593
+ }
594
+ ];
595
+ }
596
+ /**
597
+ * Process tool calls
598
+ */
599
+ async processToolCalls(content, sendMessage) {
600
+ const toolUseBlocks = content.filter((block) => block.type === 'tool_use');
601
+ const results = [];
602
+ for (const toolUse of toolUseBlocks) {
603
+ const input = toolUse.input;
604
+ let result;
605
+ // Notify user of action
606
+ if (sendMessage) {
607
+ const preview = this.formatToolPreview(toolUse.name, input);
608
+ if (preview)
609
+ await sendMessage(preview);
610
+ }
611
+ switch (toolUse.name) {
612
+ // New direct tools
613
+ case 'bash':
614
+ result = await this.bashTool.execute(input.command, input.timeout);
615
+ break;
616
+ case 'web_search':
617
+ result = await this.webTools.webSearch(input.query, { numResults: input.num_results });
618
+ break;
619
+ case 'fetch_url':
620
+ result = await this.webTools.fetchUrl(input.url, {
621
+ format: input.format,
622
+ timeout: input.timeout
623
+ });
624
+ break;
625
+ // Worker tools
626
+ case 'spawn_worker':
627
+ result = await this.workerTools.spawnWorker(input.task, {
628
+ timeout: input.timeout,
629
+ priority: input.priority
630
+ });
631
+ break;
632
+ case 'check_worker':
633
+ result = await this.workerTools.checkWorker(input.job_id);
634
+ break;
635
+ case 'wait_worker':
636
+ result = await this.workerTools.waitWorker(input.job_id, input.max_wait);
637
+ break;
638
+ case 'list_workers':
639
+ result = await this.workerTools.listWorkers(input.status);
640
+ break;
641
+ case 'cancel_worker':
642
+ result = await this.workerTools.cancelWorker(input.job_id);
643
+ break;
644
+ // Legacy delegate tool
645
+ case 'delegate_to_worker':
646
+ result = await this.delegateToWorker(input.task, input.context, input.working_directory);
647
+ break;
648
+ case 'remember':
649
+ result = await this.executeRemember(input.content, input.type, input.importance);
650
+ break;
651
+ case 'search_memory':
652
+ result = await this.executeSearchMemory(input.query, input.type);
653
+ break;
654
+ case 'schedule_task':
655
+ result = await this.executeScheduleTask(input.task, input.when);
656
+ break;
657
+ case 'list_scheduled':
658
+ result = await this.executeListScheduled();
659
+ break;
660
+ case 'memory_stats':
661
+ result = await this.executeMemoryStats();
662
+ break;
663
+ case 'open_browser':
664
+ result = await this.executeOpenBrowser(input.url);
665
+ break;
666
+ case 'start_local_server':
667
+ result = await this.executeStartServer(input.directory, input.port);
668
+ break;
669
+ case 'stop_local_server':
670
+ result = await this.executeStopServer(input.port);
671
+ break;
672
+ default:
673
+ result = { success: false, output: `Unknown tool: ${toolUse.name}` };
674
+ }
675
+ // Anthropic API requires content to be non-empty when is_error is true
676
+ const content = result.output || result.error || 'Unknown error';
677
+ results.push({
678
+ type: 'tool_result',
679
+ tool_use_id: toolUse.id,
680
+ content,
681
+ is_error: !result.success
682
+ });
683
+ }
684
+ return results;
685
+ }
686
+ /**
687
+ * Delegate task to Claude Code CLI worker with retry logic
688
+ */
689
+ async delegateToWorker(task, context, workingDir, retryCount = 0) {
690
+ const maxRetries = 2;
691
+ const id = `worker_${Date.now()}_${++this.jobCounter}`;
692
+ const cwd = workingDir || this.workspaceDir;
693
+ // Build prompt for worker
694
+ let prompt = task;
695
+ if (context) {
696
+ prompt = `Context: ${context}\n\nTask: ${task}`;
697
+ }
698
+ prompt += '\n\nKeep your response concise and focused on results.';
699
+ console.log(`[ORCHESTRATOR] Delegating to worker ${id}: ${task.slice(0, 80)}...`);
700
+ return new Promise((resolve) => {
701
+ const job = {
702
+ id,
703
+ task: task.slice(0, 200),
704
+ status: 'running',
705
+ startTime: Date.now(),
706
+ output: ''
707
+ };
708
+ // Escape single quotes in prompt for shell safety
709
+ // Replace ' with '\'' (end quote, escaped quote, start quote)
710
+ const escapedPrompt = prompt.replace(/'/g, "'\\''");
711
+ // Spawn using bash -l -c to ensure proper PATH resolution (NVM, etc.)
712
+ // This is more reliable than shell: true which has quote escaping issues
713
+ const child = spawn('bash', ['-l', '-c', `claude -p '${escapedPrompt}' --dangerously-skip-permissions`], {
714
+ cwd,
715
+ env: { ...process.env },
716
+ stdio: ['pipe', 'pipe', 'pipe']
717
+ });
718
+ job.process = child;
719
+ this.jobs.set(id, job);
720
+ // Timeout after 5 minutes
721
+ const timeout = setTimeout(() => {
722
+ if (job.status === 'running') {
723
+ job.status = 'timeout';
724
+ job.endTime = Date.now();
725
+ child.kill('SIGTERM');
726
+ resolve({
727
+ success: false,
728
+ output: `Worker timed out after 5 minutes.\nPartial output:\n${job.output.slice(-1000)}`
729
+ });
730
+ }
731
+ }, 5 * 60 * 1000);
732
+ child.stdout?.on('data', (data) => {
733
+ job.output += data.toString();
734
+ });
735
+ child.stderr?.on('data', (data) => {
736
+ // Ignore status messages
737
+ const text = data.toString();
738
+ if (!text.includes('Checking') && !text.includes('Connected')) {
739
+ job.output += text;
740
+ }
741
+ });
742
+ child.on('close', (code) => {
743
+ clearTimeout(timeout);
744
+ job.status = code === 0 ? 'completed' : 'failed';
745
+ job.endTime = Date.now();
746
+ const duration = ((job.endTime - job.startTime) / 1000).toFixed(1);
747
+ console.log(`[ORCHESTRATOR] Worker ${id} finished in ${duration}s (code: ${code})`);
748
+ if (code !== 0 || job.output.length === 0) {
749
+ console.log(`[ORCHESTRATOR] Worker ${id} output: ${job.output.slice(0, 200) || '(empty)'}`);
750
+ }
751
+ resolve({
752
+ success: code === 0,
753
+ output: job.output.trim() || '(No output)'
754
+ });
755
+ });
756
+ child.on('error', async (err) => {
757
+ clearTimeout(timeout);
758
+ job.status = 'failed';
759
+ job.endTime = Date.now();
760
+ console.error(`[ORCHESTRATOR] Worker ${id} spawn error:`, err.message);
761
+ // Retry on ENOENT (intermittent spawn failures)
762
+ if (err.message.includes('ENOENT') && retryCount < maxRetries) {
763
+ console.log(`[ORCHESTRATOR] Retrying worker (attempt ${retryCount + 2}/${maxRetries + 1})...`);
764
+ await new Promise(r => setTimeout(r, 500)); // Small delay before retry
765
+ const retryResult = await this.delegateToWorker(task, context, workingDir, retryCount + 1);
766
+ resolve(retryResult);
767
+ return;
768
+ }
769
+ resolve({
770
+ success: false,
771
+ output: `Worker error: ${err.message}`
772
+ });
773
+ });
774
+ });
775
+ }
776
+ /**
777
+ * Execute remember tool
778
+ */
779
+ async executeRemember(content, type, importance) {
780
+ try {
781
+ const id = await this.memory.remember(content, {
782
+ type: type || undefined,
783
+ importance
784
+ });
785
+ return { success: true, output: `Remembered (${id})` };
786
+ }
787
+ catch (error) {
788
+ return { success: false, output: `Failed to remember: ${error}` };
789
+ }
790
+ }
791
+ /**
792
+ * Execute search memory tool
793
+ */
794
+ async executeSearchMemory(query, type) {
795
+ try {
796
+ const memories = await this.memory.search(query, {
797
+ type: type || undefined,
798
+ limit: 10
799
+ });
800
+ if (memories.length === 0) {
801
+ return { success: true, output: 'No relevant memories found.' };
802
+ }
803
+ const output = memories.map(m => `[${m.type}] (${(m.importance * 100).toFixed(0)}%) ${m.content}`).join('\n\n');
804
+ return { success: true, output };
805
+ }
806
+ catch (error) {
807
+ return { success: false, output: `Memory search failed: ${error}` };
808
+ }
809
+ }
810
+ /**
811
+ * Execute schedule task tool
812
+ */
813
+ async executeScheduleTask(task, when) {
814
+ try {
815
+ const taskId = this.scheduler.schedule(task, when, this.userId);
816
+ const scheduled = this.scheduler.get(taskId);
817
+ return {
818
+ success: true,
819
+ output: `Scheduled: ${taskId}\nWhen: ${scheduled ? this.scheduler.formatSchedule(scheduled.schedule) : when}`
820
+ };
821
+ }
822
+ catch (error) {
823
+ return { success: false, output: `Scheduling failed: ${error}` };
824
+ }
825
+ }
826
+ /**
827
+ * Execute list scheduled tool
828
+ */
829
+ async executeListScheduled() {
830
+ const tasks = this.scheduler.list();
831
+ if (tasks.length === 0) {
832
+ return { success: true, output: 'No scheduled tasks.' };
833
+ }
834
+ const output = tasks.map(t => `${t.enabled ? '✓' : '⏸'} ${t.id}: ${t.task.slice(0, 50)}... (${this.scheduler.formatSchedule(t.schedule)})`).join('\n');
835
+ return { success: true, output };
836
+ }
837
+ /**
838
+ * Execute memory stats tool
839
+ */
840
+ async executeMemoryStats() {
841
+ const stats = this.memory.stats();
842
+ return {
843
+ success: true,
844
+ output: `Memories: ${stats.total} (episodic: ${stats.byType.episodic}, semantic: ${stats.byType.semantic}, procedural: ${stats.byType.procedural})\nAvg importance: ${(stats.avgImportance * 100).toFixed(0)}%\nAssociations: ${stats.totalAssociations}`
845
+ };
846
+ }
847
+ /**
848
+ * Open a URL in the user's default browser
849
+ */
850
+ async executeOpenBrowser(url) {
851
+ try {
852
+ // Use 'open' on macOS, 'xdg-open' on Linux, 'start' on Windows
853
+ const platform = process.platform;
854
+ let command;
855
+ if (platform === 'darwin') {
856
+ command = 'open';
857
+ }
858
+ else if (platform === 'win32') {
859
+ command = 'start';
860
+ }
861
+ else {
862
+ command = 'xdg-open';
863
+ }
864
+ execSync(`${command} "${url}"`, { stdio: 'ignore' });
865
+ console.log(`[ORCHESTRATOR] Opened browser: ${url}`);
866
+ return { success: true, output: `Opened ${url} in browser` };
867
+ }
868
+ catch (error) {
869
+ const msg = error instanceof Error ? error.message : String(error);
870
+ return { success: false, output: `Failed to open browser: ${msg}` };
871
+ }
872
+ }
873
+ /**
874
+ * Start a persistent HTTP server
875
+ */
876
+ async executeStartServer(directory, port = 8080) {
877
+ try {
878
+ // First, check if something is already running on this port
879
+ try {
880
+ const existing = execSync(`lsof -i :${port} -t`, { encoding: 'utf8' }).trim();
881
+ if (existing) {
882
+ return {
883
+ success: true,
884
+ output: `Server already running on port ${port}. URL: http://localhost:${port}`
885
+ };
886
+ }
887
+ }
888
+ catch {
889
+ // No process on port, good to start
890
+ }
891
+ // Verify directory exists
892
+ if (!existsSync(directory)) {
893
+ return { success: false, output: `Directory not found: ${directory}` };
894
+ }
895
+ // Start server with nohup so it persists
896
+ const cmd = `cd "${directory}" && nohup python3 -m http.server ${port} > /dev/null 2>&1 &`;
897
+ execSync(cmd, { shell: '/bin/bash' });
898
+ // Wait a moment for server to start
899
+ await new Promise(resolve => setTimeout(resolve, 1000));
900
+ // Verify it started
901
+ try {
902
+ execSync(`lsof -i :${port} -t`, { encoding: 'utf8' });
903
+ console.log(`[ORCHESTRATOR] Started server on port ${port} for ${directory}`);
904
+ return {
905
+ success: true,
906
+ output: `Started HTTP server on http://localhost:${port} serving ${directory}`
907
+ };
908
+ }
909
+ catch {
910
+ return { success: false, output: `Server failed to start on port ${port}` };
911
+ }
912
+ }
913
+ catch (error) {
914
+ const msg = error instanceof Error ? error.message : String(error);
915
+ return { success: false, output: `Failed to start server: ${msg}` };
916
+ }
917
+ }
918
+ /**
919
+ * Stop a running server on a port
920
+ */
921
+ async executeStopServer(port) {
922
+ try {
923
+ const pid = execSync(`lsof -i :${port} -t`, { encoding: 'utf8' }).trim();
924
+ if (pid) {
925
+ execSync(`kill ${pid}`);
926
+ console.log(`[ORCHESTRATOR] Stopped server on port ${port} (PID: ${pid})`);
927
+ return { success: true, output: `Stopped server on port ${port}` };
928
+ }
929
+ return { success: false, output: `No server running on port ${port}` };
930
+ }
931
+ catch {
932
+ return { success: false, output: `No server running on port ${port}` };
933
+ }
934
+ }
935
+ /**
936
+ * Format tool preview for user
937
+ */
938
+ formatToolPreview(name, input) {
939
+ // Only show previews in verbose mode
940
+ if (!this.isVerbose()) {
941
+ // Always show these regardless of verbose mode
942
+ if (name === 'delegate_to_worker' || name === 'spawn_worker') {
943
+ return `Working on: ${(input.task || '').slice(0, 60)}...`;
944
+ }
945
+ return null;
946
+ }
947
+ switch (name) {
948
+ case 'bash':
949
+ return `Running: ${input.command.slice(0, 60)}`;
950
+ case 'web_search':
951
+ return `Searching web: ${input.query}`;
952
+ case 'fetch_url':
953
+ return `Fetching: ${input.url}`;
954
+ case 'spawn_worker':
955
+ return `Spawning worker: ${input.task.slice(0, 60)}...`;
956
+ case 'check_worker':
957
+ return `Checking worker: ${input.job_id}`;
958
+ case 'wait_worker':
959
+ return `Waiting for worker: ${input.job_id}`;
960
+ case 'list_workers':
961
+ return 'Listing workers...';
962
+ case 'cancel_worker':
963
+ return `Canceling worker: ${input.job_id}`;
964
+ case 'delegate_to_worker':
965
+ return `Delegating: ${input.task.slice(0, 60)}...`;
966
+ case 'remember':
967
+ return `Remembering: ${input.content.slice(0, 50)}...`;
968
+ case 'search_memory':
969
+ return `Searching memory: ${input.query}`;
970
+ case 'schedule_task':
971
+ return `Scheduling: ${input.task} for ${input.when}`;
972
+ case 'open_browser':
973
+ return `Opening browser: ${input.url}`;
974
+ case 'start_local_server':
975
+ return `Starting server on port ${input.port || 8080}...`;
976
+ case 'stop_local_server':
977
+ return `Stopping server on port ${input.port}...`;
978
+ default:
979
+ return null;
980
+ }
981
+ }
982
+ hasToolUse(content) {
983
+ return content.some(block => block.type === 'tool_use');
984
+ }
985
+ extractText(content) {
986
+ const text = content
987
+ .filter(block => block.type === 'text')
988
+ .map(block => block.text)
989
+ .join('\n')
990
+ .trim();
991
+ // Never return empty - provide fallback
992
+ return text || 'Task completed.';
993
+ }
994
+ /**
995
+ * Shutdown cleanly
996
+ */
997
+ shutdown() {
998
+ this.scheduler.shutdown();
999
+ this.memory.close();
1000
+ }
1001
+ }