claudehq 1.0.1 → 1.0.3

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,635 @@
1
+ /**
2
+ * Orchestration Executor - Manages agent spawning and execution
3
+ *
4
+ * Handles spawning Claude agents via tmux, managing their lifecycle,
5
+ * and coordinating execution based on dependency graphs.
6
+ */
7
+
8
+ const { execFile } = require('child_process');
9
+ const crypto = require('crypto');
10
+ const path = require('path');
11
+ const fs = require('fs');
12
+
13
+ const { AGENT_STATUS, ORCHESTRATION_STATUS } = require('../core/config');
14
+ const { eventBus, EventTypes } = require('../core/event-bus');
15
+ const { broadcastUpdate } = require('../core/sse');
16
+ const orchestrationData = require('../data/orchestration');
17
+
18
+ // Track active agent sessions
19
+ const activeAgents = new Map(); // agentId -> { tmuxSession, orchestrationId }
20
+
21
+ /**
22
+ * Generate a unique tmux session name for an agent
23
+ * @param {string} agentId - Agent ID
24
+ * @returns {string} Tmux session name
25
+ */
26
+ function generateTmuxSessionName(agentId) {
27
+ const shortId = crypto.randomBytes(3).toString('hex');
28
+ return `orch-${agentId.slice(-8)}-${shortId}`;
29
+ }
30
+
31
+ /**
32
+ * Build the claude command with appropriate flags
33
+ * @param {Object} agent - Agent configuration
34
+ * @returns {string} Claude command string
35
+ */
36
+ function buildClaudeCommand(agent) {
37
+ const args = [];
38
+
39
+ // Model selection
40
+ if (agent.model && agent.model !== 'sonnet') {
41
+ args.push(`--model ${agent.model}`);
42
+ }
43
+
44
+ // Always continue conversation if available
45
+ args.push('-c');
46
+
47
+ // Skip permissions for orchestrated agents (they're managed)
48
+ args.push('--dangerously-skip-permissions');
49
+
50
+ // If there's an initial prompt, use -p flag for headless mode initially
51
+ // then switch to interactive
52
+ if (agent.prompt) {
53
+ // We'll send the prompt after spawning
54
+ }
55
+
56
+ return `claude ${args.join(' ')}`;
57
+ }
58
+
59
+ /**
60
+ * Spawn an agent in a new tmux window
61
+ * @param {string} orchestrationId - Orchestration ID
62
+ * @param {string} agentId - Agent ID
63
+ * @returns {Promise<Object>} Result with session info or error
64
+ */
65
+ async function spawnAgent(orchestrationId, agentId) {
66
+ const agent = orchestrationData.getAgent(orchestrationId, agentId);
67
+
68
+ if (!agent) {
69
+ return { error: 'Agent not found' };
70
+ }
71
+
72
+ if (agent.status !== AGENT_STATUS.PENDING) {
73
+ return { error: `Agent is not in pending state (current: ${agent.status})` };
74
+ }
75
+
76
+ // Check dependencies
77
+ const orchestration = orchestrationData.getOrchestration(orchestrationId);
78
+ for (const depId of agent.dependencies) {
79
+ const depAgent = orchestration.agents.find(a => a.id === depId);
80
+ if (!depAgent || depAgent.status !== AGENT_STATUS.COMPLETED) {
81
+ return { error: `Dependency ${depId} is not completed` };
82
+ }
83
+ }
84
+
85
+ // Update status to spawning
86
+ orchestrationData.updateAgent(orchestrationId, agentId, {
87
+ status: AGENT_STATUS.SPAWNING
88
+ });
89
+
90
+ const tmuxSessionName = generateTmuxSessionName(agentId);
91
+ const cwd = agent.workingDirectory || process.cwd();
92
+
93
+ // Ensure working directory exists
94
+ if (!fs.existsSync(cwd)) {
95
+ orchestrationData.updateAgent(orchestrationId, agentId, {
96
+ status: AGENT_STATUS.FAILED,
97
+ error: `Working directory does not exist: ${cwd}`
98
+ });
99
+ return { error: `Working directory does not exist: ${cwd}` };
100
+ }
101
+
102
+ const claudeCmd = buildClaudeCommand(agent);
103
+
104
+ return new Promise((resolve) => {
105
+ execFile('tmux', [
106
+ 'new-session', '-d', '-s', tmuxSessionName, '-c', cwd, claudeCmd
107
+ ], (error) => {
108
+ if (error) {
109
+ console.error(`Failed to spawn agent ${agentId}: ${error.message}`);
110
+ orchestrationData.updateAgent(orchestrationId, agentId, {
111
+ status: AGENT_STATUS.FAILED,
112
+ error: `Failed to spawn: ${error.message}`
113
+ });
114
+ resolve({ error: error.message });
115
+ return;
116
+ }
117
+
118
+ // Track the active agent
119
+ activeAgents.set(agentId, {
120
+ tmuxSession: tmuxSessionName,
121
+ orchestrationId
122
+ });
123
+
124
+ // Update agent with tmux session info
125
+ orchestrationData.updateAgent(orchestrationId, agentId, {
126
+ status: AGENT_STATUS.RUNNING,
127
+ tmuxWindow: tmuxSessionName,
128
+ startedAt: new Date().toISOString()
129
+ });
130
+
131
+ console.log(` Spawned agent "${agent.name}" (${agentId.slice(-8)}) -> tmux:${tmuxSessionName}`);
132
+
133
+ // If there's an initial prompt, send it after a short delay
134
+ if (agent.prompt) {
135
+ setTimeout(() => {
136
+ sendPromptToAgent(orchestrationId, agentId, agent.prompt);
137
+ }, 1000);
138
+ }
139
+
140
+ resolve({
141
+ success: true,
142
+ tmuxSession: tmuxSessionName,
143
+ agentId
144
+ });
145
+ });
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Send a prompt to an agent
151
+ * @param {string} orchestrationId - Orchestration ID
152
+ * @param {string} agentId - Agent ID
153
+ * @param {string} prompt - Prompt text
154
+ * @returns {Promise<Object>} Result
155
+ */
156
+ async function sendPromptToAgent(orchestrationId, agentId, prompt) {
157
+ const agentInfo = activeAgents.get(agentId);
158
+
159
+ if (!agentInfo) {
160
+ return { error: 'Agent not found or not running' };
161
+ }
162
+
163
+ return new Promise((resolve, reject) => {
164
+ const tempFile = `/tmp/orch-prompt-${Date.now()}.txt`;
165
+ fs.writeFileSync(tempFile, prompt);
166
+
167
+ execFile('tmux', ['load-buffer', tempFile], (err) => {
168
+ if (err) {
169
+ fs.unlinkSync(tempFile);
170
+ return resolve({ error: `tmux load-buffer failed: ${err.message}` });
171
+ }
172
+
173
+ execFile('tmux', ['paste-buffer', '-t', agentInfo.tmuxSession], (err2) => {
174
+ fs.unlinkSync(tempFile);
175
+
176
+ if (err2) {
177
+ return resolve({ error: `tmux paste-buffer failed: ${err2.message}` });
178
+ }
179
+
180
+ setTimeout(() => {
181
+ execFile('tmux', ['send-keys', '-t', agentInfo.tmuxSession, 'Enter'], (err3) => {
182
+ if (err3) {
183
+ return resolve({ error: `tmux send-keys failed: ${err3.message}` });
184
+ }
185
+
186
+ eventBus.emit(EventTypes.AGENT_OUTPUT, {
187
+ orchestrationId,
188
+ agentId,
189
+ type: 'prompt_sent',
190
+ prompt
191
+ });
192
+
193
+ resolve({ success: true });
194
+ });
195
+ }, 50);
196
+ });
197
+ });
198
+ });
199
+ }
200
+
201
+ /**
202
+ * Kill an agent
203
+ * @param {string} orchestrationId - Orchestration ID
204
+ * @param {string} agentId - Agent ID
205
+ * @returns {Promise<Object>} Result
206
+ */
207
+ async function killAgent(orchestrationId, agentId) {
208
+ const agentInfo = activeAgents.get(agentId);
209
+
210
+ if (!agentInfo) {
211
+ // Agent may not be spawned yet, just update status
212
+ orchestrationData.updateAgent(orchestrationId, agentId, {
213
+ status: AGENT_STATUS.CANCELLED,
214
+ completedAt: new Date().toISOString()
215
+ });
216
+ return { success: true };
217
+ }
218
+
219
+ return new Promise((resolve) => {
220
+ execFile('tmux', ['kill-session', '-t', agentInfo.tmuxSession], (error) => {
221
+ if (error) {
222
+ console.log(` Note: tmux session ${agentInfo.tmuxSession} may already be dead`);
223
+ }
224
+
225
+ activeAgents.delete(agentId);
226
+
227
+ orchestrationData.updateAgent(orchestrationId, agentId, {
228
+ status: AGENT_STATUS.CANCELLED,
229
+ completedAt: new Date().toISOString()
230
+ });
231
+
232
+ console.log(` Killed agent ${agentId.slice(-8)}`);
233
+ resolve({ success: true });
234
+ });
235
+ });
236
+ }
237
+
238
+ /**
239
+ * Mark an agent as completed
240
+ * @param {string} orchestrationId - Orchestration ID
241
+ * @param {string} agentId - Agent ID
242
+ * @param {Object} result - Optional result data
243
+ * @returns {Object} Result
244
+ */
245
+ function completeAgent(orchestrationId, agentId, result = {}) {
246
+ activeAgents.delete(agentId);
247
+
248
+ orchestrationData.updateAgent(orchestrationId, agentId, {
249
+ status: AGENT_STATUS.COMPLETED,
250
+ completedAt: new Date().toISOString(),
251
+ output: result.output || null
252
+ });
253
+
254
+ // Check if any dependent agents can now run
255
+ checkAndSpawnReadyAgents(orchestrationId);
256
+
257
+ // Check if orchestration is complete
258
+ if (orchestrationData.isOrchestrationComplete(orchestrationId)) {
259
+ orchestrationData.updateOrchestration(orchestrationId, {
260
+ status: ORCHESTRATION_STATUS.COMPLETED,
261
+ completedAt: new Date().toISOString()
262
+ });
263
+ }
264
+
265
+ return { success: true };
266
+ }
267
+
268
+ /**
269
+ * Mark an agent as failed
270
+ * @param {string} orchestrationId - Orchestration ID
271
+ * @param {string} agentId - Agent ID
272
+ * @param {string} error - Error message
273
+ * @returns {Object} Result
274
+ */
275
+ function failAgent(orchestrationId, agentId, error) {
276
+ activeAgents.delete(agentId);
277
+
278
+ orchestrationData.updateAgent(orchestrationId, agentId, {
279
+ status: AGENT_STATUS.FAILED,
280
+ completedAt: new Date().toISOString(),
281
+ error
282
+ });
283
+
284
+ // Check if orchestration should fail
285
+ const orchestration = orchestrationData.getOrchestration(orchestrationId);
286
+ const failOnError = orchestration.metadata?.failOnError !== false;
287
+
288
+ if (failOnError) {
289
+ // Mark orchestration as failed
290
+ orchestrationData.updateOrchestration(orchestrationId, {
291
+ status: ORCHESTRATION_STATUS.FAILED,
292
+ completedAt: new Date().toISOString()
293
+ });
294
+ } else {
295
+ // Continue with other agents
296
+ checkAndSpawnReadyAgents(orchestrationId);
297
+
298
+ if (orchestrationData.isOrchestrationComplete(orchestrationId)) {
299
+ orchestrationData.updateOrchestration(orchestrationId, {
300
+ status: ORCHESTRATION_STATUS.COMPLETED,
301
+ completedAt: new Date().toISOString()
302
+ });
303
+ }
304
+ }
305
+
306
+ return { success: true };
307
+ }
308
+
309
+ /**
310
+ * Check for agents ready to run and spawn them
311
+ * @param {string} orchestrationId - Orchestration ID
312
+ */
313
+ async function checkAndSpawnReadyAgents(orchestrationId) {
314
+ const orchestration = orchestrationData.getOrchestration(orchestrationId);
315
+
316
+ if (!orchestration || orchestration.status !== ORCHESTRATION_STATUS.RUNNING) {
317
+ return;
318
+ }
319
+
320
+ const readyAgents = orchestrationData.getReadyAgents(orchestrationId);
321
+
322
+ for (const agent of readyAgents) {
323
+ console.log(` Auto-spawning ready agent: ${agent.name}`);
324
+ await spawnAgent(orchestrationId, agent.id);
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Start an orchestration (spawn all ready agents)
330
+ * @param {string} orchestrationId - Orchestration ID
331
+ * @returns {Promise<Object>} Result
332
+ */
333
+ async function startOrchestration(orchestrationId) {
334
+ const orchestration = orchestrationData.getOrchestration(orchestrationId);
335
+
336
+ if (!orchestration) {
337
+ return { error: 'Orchestration not found' };
338
+ }
339
+
340
+ if (orchestration.status === ORCHESTRATION_STATUS.RUNNING) {
341
+ return { error: 'Orchestration is already running' };
342
+ }
343
+
344
+ if (orchestration.status === ORCHESTRATION_STATUS.COMPLETED) {
345
+ return { error: 'Orchestration is already completed' };
346
+ }
347
+
348
+ if (orchestration.agents.length === 0) {
349
+ return { error: 'Orchestration has no agents' };
350
+ }
351
+
352
+ // Update orchestration status
353
+ orchestrationData.updateOrchestration(orchestrationId, {
354
+ status: ORCHESTRATION_STATUS.RUNNING,
355
+ startedAt: new Date().toISOString()
356
+ });
357
+
358
+ // Spawn all ready agents (those with no dependencies or all deps completed)
359
+ const readyAgents = orchestrationData.getReadyAgents(orchestrationId);
360
+
361
+ if (readyAgents.length === 0) {
362
+ return { error: 'No agents are ready to run (check dependencies)' };
363
+ }
364
+
365
+ console.log(` Starting orchestration "${orchestration.name}" with ${readyAgents.length} ready agents`);
366
+
367
+ const results = [];
368
+ for (const agent of readyAgents) {
369
+ const result = await spawnAgent(orchestrationId, agent.id);
370
+ results.push({ agentId: agent.id, ...result });
371
+ }
372
+
373
+ return {
374
+ success: true,
375
+ orchestrationId,
376
+ spawnedAgents: results.filter(r => r.success).length,
377
+ results
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Stop an orchestration (kill all running agents)
383
+ * @param {string} orchestrationId - Orchestration ID
384
+ * @returns {Promise<Object>} Result
385
+ */
386
+ async function stopOrchestration(orchestrationId) {
387
+ const orchestration = orchestrationData.getOrchestration(orchestrationId);
388
+
389
+ if (!orchestration) {
390
+ return { error: 'Orchestration not found' };
391
+ }
392
+
393
+ console.log(` Stopping orchestration "${orchestration.name}"`);
394
+
395
+ // Kill all running/spawning agents
396
+ const runningAgents = orchestration.agents.filter(a =>
397
+ a.status === AGENT_STATUS.RUNNING || a.status === AGENT_STATUS.SPAWNING
398
+ );
399
+
400
+ for (const agent of runningAgents) {
401
+ await killAgent(orchestrationId, agent.id);
402
+ }
403
+
404
+ // Mark pending agents as cancelled
405
+ const pendingAgents = orchestration.agents.filter(a => a.status === AGENT_STATUS.PENDING);
406
+ for (const agent of pendingAgents) {
407
+ orchestrationData.updateAgent(orchestrationId, agent.id, {
408
+ status: AGENT_STATUS.CANCELLED
409
+ });
410
+ }
411
+
412
+ // Update orchestration status
413
+ orchestrationData.updateOrchestration(orchestrationId, {
414
+ status: ORCHESTRATION_STATUS.PAUSED
415
+ });
416
+
417
+ return {
418
+ success: true,
419
+ killedAgents: runningAgents.length,
420
+ cancelledAgents: pendingAgents.length
421
+ };
422
+ }
423
+
424
+ /**
425
+ * Check health of all active agents
426
+ */
427
+ function checkAgentHealth() {
428
+ execFile('tmux', ['list-sessions', '-F', '#{session_name}'], (error, stdout) => {
429
+ if (error) {
430
+ // No tmux sessions - mark all agents as offline/failed
431
+ for (const [agentId, info] of activeAgents) {
432
+ console.log(` Agent ${agentId.slice(-8)} tmux session died`);
433
+ failAgent(info.orchestrationId, agentId, 'Tmux session died');
434
+ }
435
+ return;
436
+ }
437
+
438
+ const activeSessions = new Set(stdout.trim().split('\n').filter(Boolean));
439
+
440
+ for (const [agentId, info] of activeAgents) {
441
+ if (!activeSessions.has(info.tmuxSession)) {
442
+ console.log(` Agent ${agentId.slice(-8)} tmux session no longer exists`);
443
+ // Session died - this could mean the agent completed or crashed
444
+ // For now, mark as completed (user can check output)
445
+ completeAgent(info.orchestrationId, agentId, {
446
+ output: 'Session ended'
447
+ });
448
+ }
449
+ }
450
+ });
451
+ }
452
+
453
+ /**
454
+ * Get status of an agent's tmux session
455
+ * @param {string} agentId - Agent ID
456
+ * @returns {Promise<Object>} Status info
457
+ */
458
+ async function getAgentTmuxStatus(agentId) {
459
+ const agentInfo = activeAgents.get(agentId);
460
+
461
+ if (!agentInfo) {
462
+ return { active: false };
463
+ }
464
+
465
+ return new Promise((resolve) => {
466
+ execFile('tmux', ['capture-pane', '-t', agentInfo.tmuxSession, '-p', '-S', '-30'],
467
+ { timeout: 2000, maxBuffer: 1024 * 1024 },
468
+ (error, stdout) => {
469
+ if (error) {
470
+ resolve({ active: false, error: error.message });
471
+ return;
472
+ }
473
+
474
+ resolve({
475
+ active: true,
476
+ tmuxSession: agentInfo.tmuxSession,
477
+ recentOutput: stdout
478
+ });
479
+ }
480
+ );
481
+ });
482
+ }
483
+
484
+ /**
485
+ * Link a Claude session event to an orchestration agent
486
+ * @param {string} claudeSessionId - Claude session ID from hook event
487
+ * @param {string} orchestrationId - Orchestration ID
488
+ * @param {string} agentId - Agent ID
489
+ */
490
+ function linkClaudeSessionToAgent(claudeSessionId, orchestrationId, agentId) {
491
+ orchestrationData.updateAgent(orchestrationId, agentId, {
492
+ sessionId: claudeSessionId
493
+ });
494
+ }
495
+
496
+ /**
497
+ * Find agent by Claude session ID
498
+ * @param {string} claudeSessionId - Claude session ID
499
+ * @returns {Object|null} Agent info or null
500
+ */
501
+ function findAgentByClaudeSession(claudeSessionId) {
502
+ const orchestrations = orchestrationData.listOrchestrations();
503
+
504
+ for (const orch of orchestrations) {
505
+ for (const agent of orch.agents) {
506
+ if (agent.sessionId === claudeSessionId) {
507
+ return { orchestrationId: orch.id, agent };
508
+ }
509
+ }
510
+ }
511
+
512
+ return null;
513
+ }
514
+
515
+ /**
516
+ * Start health check interval
517
+ */
518
+ let healthCheckInterval = null;
519
+
520
+ function startHealthChecks() {
521
+ if (healthCheckInterval) {
522
+ clearInterval(healthCheckInterval);
523
+ }
524
+ healthCheckInterval = setInterval(checkAgentHealth, 5000);
525
+ console.log(' Orchestration health monitoring started (5s interval)');
526
+ }
527
+
528
+ function stopHealthChecks() {
529
+ if (healthCheckInterval) {
530
+ clearInterval(healthCheckInterval);
531
+ healthCheckInterval = null;
532
+ }
533
+ }
534
+
535
+ // Register event handlers for SSE broadcasts
536
+ function registerEventHandlers() {
537
+ // Orchestration events
538
+ eventBus.on(EventTypes.ORCHESTRATION_CREATED, (data) => {
539
+ broadcastUpdate('orchestration_created', data);
540
+ });
541
+
542
+ eventBus.on(EventTypes.ORCHESTRATION_UPDATED, (data) => {
543
+ broadcastUpdate('orchestration_updated', data);
544
+ });
545
+
546
+ eventBus.on(EventTypes.ORCHESTRATION_STARTED, (data) => {
547
+ broadcastUpdate('orchestration_started', data);
548
+ });
549
+
550
+ eventBus.on(EventTypes.ORCHESTRATION_COMPLETED, (data) => {
551
+ broadcastUpdate('orchestration_completed', data);
552
+ });
553
+
554
+ eventBus.on(EventTypes.ORCHESTRATION_FAILED, (data) => {
555
+ broadcastUpdate('orchestration_failed', data);
556
+ });
557
+
558
+ eventBus.on(EventTypes.ORCHESTRATION_PAUSED, (data) => {
559
+ broadcastUpdate('orchestration_paused', data);
560
+ });
561
+
562
+ eventBus.on(EventTypes.ORCHESTRATION_DELETED, (data) => {
563
+ broadcastUpdate('orchestration_deleted', data);
564
+ });
565
+
566
+ // Agent events
567
+ eventBus.on(EventTypes.AGENT_CREATED, (data) => {
568
+ broadcastUpdate('agent_created', data);
569
+ });
570
+
571
+ eventBus.on(EventTypes.AGENT_SPAWNED, (data) => {
572
+ broadcastUpdate('agent_spawned', data);
573
+ });
574
+
575
+ eventBus.on(EventTypes.AGENT_STATUS_CHANGED, (data) => {
576
+ broadcastUpdate('agent_status_changed', data);
577
+ });
578
+
579
+ eventBus.on(EventTypes.AGENT_OUTPUT, (data) => {
580
+ broadcastUpdate('agent_output', data);
581
+ });
582
+
583
+ eventBus.on(EventTypes.AGENT_COMPLETED, (data) => {
584
+ broadcastUpdate('agent_completed', data);
585
+ });
586
+
587
+ eventBus.on(EventTypes.AGENT_FAILED, (data) => {
588
+ broadcastUpdate('agent_failed', data);
589
+ });
590
+
591
+ eventBus.on(EventTypes.AGENT_KILLED, (data) => {
592
+ broadcastUpdate('agent_killed', data);
593
+ });
594
+
595
+ console.log(' Orchestration event handlers registered');
596
+ }
597
+
598
+ /**
599
+ * Initialize the executor
600
+ */
601
+ function init() {
602
+ registerEventHandlers();
603
+ startHealthChecks();
604
+ }
605
+
606
+ module.exports = {
607
+ // Initialization
608
+ init,
609
+ startHealthChecks,
610
+ stopHealthChecks,
611
+ registerEventHandlers,
612
+
613
+ // Agent lifecycle
614
+ spawnAgent,
615
+ killAgent,
616
+ completeAgent,
617
+ failAgent,
618
+ sendPromptToAgent,
619
+
620
+ // Orchestration lifecycle
621
+ startOrchestration,
622
+ stopOrchestration,
623
+ checkAndSpawnReadyAgents,
624
+
625
+ // Status
626
+ getAgentTmuxStatus,
627
+ checkAgentHealth,
628
+
629
+ // Session linking
630
+ linkClaudeSessionToAgent,
631
+ findAgentByClaudeSession,
632
+
633
+ // Direct access
634
+ activeAgents
635
+ };