@stackmemoryai/stackmemory 1.3.0 → 1.3.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.
@@ -5,9 +5,10 @@ const __dirname = __pathDirname(__filename);
5
5
  import { Command } from "commander";
6
6
  import { existsSync, mkdirSync, writeFileSync } from "fs";
7
7
  import { join } from "path";
8
- import { homedir } from "os";
8
+ import { homedir, tmpdir } from "os";
9
9
  import Database from "better-sqlite3";
10
10
  import { logger } from "../../core/monitoring/logger.js";
11
+ import { SymphonyOrchestrator } from "./symphony-orchestrator.js";
11
12
  function getGlobalStorePath() {
12
13
  const dir = join(homedir(), ".stackmemory", "symphony");
13
14
  if (!existsSync(dir)) {
@@ -260,6 +261,34 @@ function createSymphonyCommands() {
260
261
  }
261
262
  globalDb.close();
262
263
  });
264
+ cmd.command("start").description("Start the Symphony orchestrator daemon").option("--team <id>", "Linear team ID").option(
265
+ "--states <states>",
266
+ "Comma-separated issue states to pick up",
267
+ "Todo"
268
+ ).option(
269
+ "--in-progress <state>",
270
+ "State name for in-progress",
271
+ "In Progress"
272
+ ).option(
273
+ "--in-review <state>",
274
+ "State name for completed review",
275
+ "In Review"
276
+ ).option("--poll <ms>", "Polling interval in milliseconds", "30000").option("--concurrency <n>", "Max concurrent agents", "3").option("--workspace-root <path>", "Workspace root directory").option("--repo <path>", "Git repo root for worktrees", process.cwd()).option("--branch <name>", "Base branch for worktrees", "main").option("--retries <n>", "Max retries per issue", "1").option("--turn-timeout <ms>", "Agent turn timeout in ms", "3600000").action(async (options) => {
277
+ const orchestrator = new SymphonyOrchestrator({
278
+ teamId: options.team,
279
+ activeStates: options.states.split(",").map((s) => s.trim()),
280
+ inProgressState: options.inProgress,
281
+ inReviewState: options.inReview,
282
+ pollIntervalMs: parseInt(options.poll, 10),
283
+ maxConcurrent: parseInt(options.concurrency, 10),
284
+ workspaceRoot: options.workspaceRoot || join(tmpdir(), "symphony_workspaces"),
285
+ repoRoot: options.repo,
286
+ baseBranch: options.branch,
287
+ maxRetries: parseInt(options.retries, 10),
288
+ turnTimeoutMs: parseInt(options.turnTimeout, 10)
289
+ });
290
+ await orchestrator.start();
291
+ });
263
292
  return cmd;
264
293
  }
265
294
  export {
@@ -292,11 +292,16 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
292
292
  logger.info("FTS5 index rebuilt");
293
293
  }
294
294
  /**
295
- * Incremental garbage collection: delete expired frames and cascade to related tables.
296
- * Respects retention_policy per frame:
295
+ * Incremental garbage collection with generational compression.
296
+ *
297
+ * Phase 1 — Compress: Apply generational strategies to mature/old frames.
298
+ * - 'digest_only': Strip inputs/outputs/events, keep digest + anchors
299
+ * - 'anchors_only': Strip inputs/outputs/events/digest_json, keep digest_text + anchors
300
+ * - 'keep_all': No compression
301
+ *
302
+ * Phase 2 — Delete: Remove frames past retention cutoffs.
297
303
  * - 'keep_forever': never deleted
298
- * - 'default': deleted after retentionDays (default 90)
299
- * - 'archive': same as default
304
+ * - 'default'/'archive': deleted after retentionDays (default 90)
300
305
  * - 'ttl_30d': deleted after 30 days
301
306
  * - 'ttl_7d': deleted after 7 days
302
307
  */
@@ -314,6 +319,62 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
314
319
  const defaultCutoff = nowSec - retentionDays * 86400;
315
320
  const ttl30dCutoff = nowSec - 30 * 86400;
316
321
  const ttl7dCutoff = nowSec - 7 * 86400;
322
+ let framesCompressed = 0;
323
+ const gc = options.generationalGc;
324
+ if (gc && !dryRun) {
325
+ const youngDays = gc.youngCutoffDays ?? 1;
326
+ const matureDays = gc.matureCutoffDays ?? 7;
327
+ const oldDays = gc.oldCutoffDays ?? 30;
328
+ const youngCutoff = nowSec - youngDays * 86400;
329
+ const matureCutoff = nowSec - matureDays * 86400;
330
+ const matureStrategy = gc.mature_strategy ?? "digest_only";
331
+ if (matureStrategy !== "keep_all") {
332
+ const matureFrames = this.db.prepare(
333
+ `SELECT frame_id FROM frames
334
+ WHERE created_at < ? AND created_at >= ?
335
+ AND state = 'closed'
336
+ AND retention_policy != 'keep_forever'
337
+ AND inputs != '{}'
338
+ AND run_id NOT IN (SELECT value FROM json_each(?))
339
+ LIMIT ?`
340
+ ).all(
341
+ youngCutoff,
342
+ matureCutoff,
343
+ JSON.stringify(protectedRunIds),
344
+ batchSize
345
+ );
346
+ if (matureFrames.length > 0) {
347
+ framesCompressed += this.compressFrames(
348
+ matureFrames.map((f) => f.frame_id),
349
+ matureStrategy
350
+ );
351
+ }
352
+ }
353
+ const oldStrategy = gc.old_strategy ?? "anchors_only";
354
+ if (oldStrategy !== "keep_all") {
355
+ const oldCutoff = nowSec - oldDays * 86400;
356
+ const oldFrames = this.db.prepare(
357
+ `SELECT frame_id FROM frames
358
+ WHERE created_at < ? AND created_at >= ?
359
+ AND state = 'closed'
360
+ AND retention_policy != 'keep_forever'
361
+ AND inputs != '{}'
362
+ AND run_id NOT IN (SELECT value FROM json_each(?))
363
+ LIMIT ?`
364
+ ).all(
365
+ matureCutoff,
366
+ oldCutoff,
367
+ JSON.stringify(protectedRunIds),
368
+ batchSize
369
+ );
370
+ if (oldFrames.length > 0) {
371
+ framesCompressed += this.compressFrames(
372
+ oldFrames.map((f) => f.frame_id),
373
+ oldStrategy
374
+ );
375
+ }
376
+ }
377
+ }
317
378
  const candidates = this.db.prepare(
318
379
  `SELECT frame_id FROM frames
319
380
  WHERE (
@@ -340,7 +401,8 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
340
401
  eventsDeleted: 0,
341
402
  anchorsDeleted: 0,
342
403
  embeddingsDeleted: 0,
343
- ftsEntriesDeleted: 0
404
+ ftsEntriesDeleted: 0,
405
+ framesCompressed
344
406
  };
345
407
  }
346
408
  if (dryRun) {
@@ -362,8 +424,9 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
362
424
  eventsDeleted: eventsCount,
363
425
  anchorsDeleted: anchorsCount,
364
426
  embeddingsDeleted: embeddingsCount,
365
- ftsEntriesDeleted: frameIds.length
427
+ ftsEntriesDeleted: frameIds.length,
366
428
  // FTS has one entry per frame
429
+ framesCompressed
367
430
  };
368
431
  }
369
432
  const placeholders = frameIds.map(() => "?").join(",");
@@ -392,14 +455,16 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
392
455
  framesDeleted: frameIds.length,
393
456
  eventsDeleted,
394
457
  anchorsDeleted,
395
- embeddingsDeleted
458
+ embeddingsDeleted,
459
+ framesCompressed
396
460
  });
397
461
  return {
398
462
  framesDeleted: frameIds.length,
399
463
  eventsDeleted,
400
464
  anchorsDeleted,
401
465
  embeddingsDeleted,
402
- ftsEntriesDeleted: frameIds.length
466
+ ftsEntriesDeleted: frameIds.length,
467
+ framesCompressed
403
468
  };
404
469
  }
405
470
  /**
@@ -432,6 +497,85 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
432
497
  if (nowSec - frame.created_at < 86400) score += 0.1;
433
498
  return Math.round(Math.min(score, 1) * 100) / 100;
434
499
  }
500
+ /**
501
+ * Compress a frame by stripping data according to strategy.
502
+ *
503
+ * - 'digest_only': Remove inputs, outputs, events. Keep digest_text, digest_json, anchors.
504
+ * - 'anchors_only': Remove inputs, outputs, events, digest_json. Keep digest_text, anchors.
505
+ *
506
+ * Returns true if the frame was compressed, false if not found.
507
+ */
508
+ compressFrame(frameId, strategy) {
509
+ if (!this.db)
510
+ throw new DatabaseError(
511
+ "Database not connected",
512
+ ErrorCode.DB_CONNECTION_FAILED
513
+ );
514
+ const frame = this.db.prepare("SELECT frame_id FROM frames WHERE frame_id = ?").get(frameId);
515
+ if (!frame) return false;
516
+ this.db.prepare("BEGIN").run();
517
+ try {
518
+ if (strategy === "digest_only") {
519
+ this.db.prepare(
520
+ "UPDATE frames SET inputs = '{}', outputs = '{}' WHERE frame_id = ?"
521
+ ).run(frameId);
522
+ } else {
523
+ this.db.prepare(
524
+ "UPDATE frames SET inputs = '{}', outputs = '{}', digest_json = '{}' WHERE frame_id = ?"
525
+ ).run(frameId);
526
+ }
527
+ this.db.prepare("DELETE FROM events WHERE frame_id = ?").run(frameId);
528
+ if (this.vecEnabled) {
529
+ this.db.prepare("DELETE FROM frame_embeddings WHERE frame_id = ?").run(frameId);
530
+ }
531
+ this.db.prepare("COMMIT").run();
532
+ return true;
533
+ } catch (error) {
534
+ this.db.prepare("ROLLBACK").run();
535
+ throw error;
536
+ }
537
+ }
538
+ /**
539
+ * Compress multiple frames in a single transaction.
540
+ * Returns count of frames compressed.
541
+ */
542
+ compressFrames(frameIds, strategy) {
543
+ if (!this.db || frameIds.length === 0) return 0;
544
+ const placeholders = frameIds.map(() => "?").join(",");
545
+ this.db.prepare("BEGIN").run();
546
+ try {
547
+ if (strategy === "digest_only") {
548
+ this.db.prepare(
549
+ `UPDATE frames SET inputs = '{}', outputs = '{}' WHERE frame_id IN (${placeholders})`
550
+ ).run(...frameIds);
551
+ } else {
552
+ this.db.prepare(
553
+ `UPDATE frames SET inputs = '{}', outputs = '{}', digest_json = '{}' WHERE frame_id IN (${placeholders})`
554
+ ).run(...frameIds);
555
+ }
556
+ this.db.prepare(`DELETE FROM events WHERE frame_id IN (${placeholders})`).run(...frameIds);
557
+ if (this.vecEnabled) {
558
+ this.db.prepare(
559
+ `DELETE FROM frame_embeddings WHERE frame_id IN (${placeholders})`
560
+ ).run(...frameIds);
561
+ }
562
+ this.db.prepare("COMMIT").run();
563
+ return frameIds.length;
564
+ } catch (error) {
565
+ this.db.prepare("ROLLBACK").run();
566
+ throw error;
567
+ }
568
+ }
569
+ /**
570
+ * Get database file size in bytes.
571
+ */
572
+ getDatabaseSize() {
573
+ if (!this.db) return 0;
574
+ const result = this.db.prepare(
575
+ "SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()"
576
+ ).get();
577
+ return result?.size ?? 0;
578
+ }
435
579
  /**
436
580
  * Recompute importance scores for frames still at default score (0.5).
437
581
  * Processes oldest frames first in batches.
@@ -4,8 +4,7 @@ const __filename = __fileURLToPath(import.meta.url);
4
4
  const __dirname = __pathDirname(__filename);
5
5
  import { logger } from "../../core/monitoring/logger.js";
6
6
  import { STRUCTURED_RESPONSE_SUFFIX } from "../../orchestrators/multimodal/constants.js";
7
- import { exec } from "child_process";
8
- import { promisify } from "util";
7
+ import { spawn } from "child_process";
9
8
  import * as fs from "fs";
10
9
  import * as path from "path";
11
10
  import * as os from "os";
@@ -18,12 +17,10 @@ import {
18
17
  createProvider
19
18
  } from "../../core/extensions/provider-adapter.js";
20
19
  import { AnthropicBatchClient } from "../anthropic/batch-client.js";
21
- const execAsync = promisify(exec);
22
20
  class ClaudeCodeSubagentClient {
23
21
  tempDir;
24
- activeSubagents = /* @__PURE__ */ new Map();
25
22
  mockMode;
26
- constructor(mockMode = true) {
23
+ constructor(mockMode = false) {
27
24
  this.mockMode = mockMode;
28
25
  this.tempDir = path.join(os.tmpdir(), "stackmemory-rlm");
29
26
  if (!fs.existsSync(this.tempDir)) {
@@ -148,7 +145,8 @@ class ClaudeCodeSubagentClient {
148
145
  }
149
146
  }
150
147
  /**
151
- * Original CLI-based subagent execution (unchanged behavior)
148
+ * Execute subagent via Claude Code CLI (`claude -p --output-format stream-json`).
149
+ * Spawns a real Claude Code process with full tool use.
152
150
  */
153
151
  async executeSubagentViaCLI(request, startTime, subagentId) {
154
152
  try {
@@ -158,31 +156,24 @@ class ClaudeCodeSubagentClient {
158
156
  contextFile,
159
157
  JSON.stringify(request.context, null, 2)
160
158
  );
161
- const resultFile = path.join(this.tempDir, `${subagentId}-result.json`);
162
- const taskCommand = this.buildTaskCommand(
163
- request,
164
- prompt,
165
- contextFile,
166
- resultFile
167
- );
168
- const result = await this.executeTaskTool(taskCommand, request.timeout);
169
- let subagentResult = {};
170
- if (fs.existsSync(resultFile)) {
171
- const resultContent = await fs.promises.readFile(resultFile, "utf-8");
172
- try {
173
- subagentResult = JSON.parse(resultContent);
174
- } catch {
175
- subagentResult = { rawOutput: resultContent };
176
- }
177
- }
159
+ const fullPrompt = `${prompt}
160
+
161
+ Context (JSON): ${JSON.stringify(request.context)}`;
162
+ const result = await this.spawnClaude(fullPrompt, request.timeout);
178
163
  this.cleanup(subagentId);
164
+ let parsed;
165
+ try {
166
+ parsed = JSON.parse(result.text);
167
+ } catch {
168
+ parsed = { rawOutput: result.text };
169
+ }
179
170
  return {
180
171
  success: true,
181
- result: subagentResult,
182
- output: result.stdout,
172
+ result: parsed,
173
+ output: result.text,
183
174
  duration: Date.now() - startTime,
184
175
  subagentType: request.type,
185
- tokens: this.estimateTokens(prompt + JSON.stringify(subagentResult))
176
+ tokens: this.estimateTokens(fullPrompt + result.text)
186
177
  };
187
178
  } catch (error) {
188
179
  logger.error(`Subagent CLI execution failed: ${request.type}`, {
@@ -347,66 +338,88 @@ class ClaudeCodeSubagentClient {
347
338
  return (request.systemPrompt || prompts[request.type] || prompts.planning) + STRUCTURED_RESPONSE_SUFFIX;
348
339
  }
349
340
  /**
350
- * Build Task tool command
351
- * This creates a command that Claude Code's Task tool can execute
352
- */
353
- buildTaskCommand(request, prompt, contextFile, resultFile) {
354
- const scriptContent = `
355
- #!/bin/bash
356
- # Subagent execution script for ${request.type}
357
-
358
- # Read context
359
- CONTEXT=$(cat "${contextFile}")
360
-
361
- # Execute task based on type
362
- case "${request.type}" in
363
- "testing")
364
- # For testing subagent, actually run tests
365
- echo "Generating and running tests..."
366
- # The subagent will generate test files and run them
367
- ;;
368
- "linting")
369
- # For linting subagent, run actual linters
370
- echo "Running linters..."
371
- npm run lint || true
372
- ;;
373
- "code")
374
- # For code generation, create implementation files
375
- echo "Generating implementation..."
376
- ;;
377
- *)
378
- # Default behavior
379
- echo "Executing ${request.type} task..."
380
- ;;
381
- esac
382
-
383
- # Write result
384
- echo '{"status": "completed", "type": "${request.type}"}' > "${resultFile}"
385
- `;
386
- const scriptFile = path.join(this.tempDir, `${request.type}-script.sh`);
387
- fs.writeFileSync(scriptFile, scriptContent);
388
- fs.chmodSync(scriptFile, "755");
389
- return scriptFile;
390
- }
391
- /**
392
- * Execute via Task tool (simulated for now)
393
- * In production, this would use Claude Code's actual Task tool API
341
+ * Spawn `claude -p --output-format stream-json` and collect the result.
342
+ * Parses stream-json events to extract the final assistant text.
394
343
  */
395
- async executeTaskTool(command, timeout) {
396
- try {
397
- const result = await execAsync(command, {
398
- timeout: timeout || 3e5,
399
- // 5 minutes default
400
- maxBuffer: 10 * 1024 * 1024
401
- // 10MB buffer
344
+ spawnClaude(prompt, timeout) {
345
+ return new Promise((resolve, reject) => {
346
+ const args = ["-p", "--output-format", "stream-json", prompt];
347
+ const claude = spawn("claude", args, {
348
+ cwd: process.cwd(),
349
+ env: { ...process.env },
350
+ stdio: ["pipe", "pipe", "pipe"]
402
351
  });
403
- return result;
404
- } catch (error) {
405
- if (error.killed || error.signal === "SIGTERM") {
406
- throw new Error(`Subagent timeout after ${timeout}ms`);
407
- }
408
- throw error;
409
- }
352
+ const timeoutMs = timeout || 3e5;
353
+ const timer = setTimeout(() => {
354
+ claude.kill("SIGTERM");
355
+ reject(new Error(`Subagent timeout after ${timeoutMs}ms`));
356
+ }, timeoutMs);
357
+ let lastAssistantText = "";
358
+ let toolUseCount = 0;
359
+ let lineBuffer = "";
360
+ let stderr = "";
361
+ claude.stdout.on("data", (chunk) => {
362
+ lineBuffer += chunk.toString();
363
+ const lines = lineBuffer.split("\n");
364
+ lineBuffer = lines.pop() || "";
365
+ for (const line of lines) {
366
+ if (!line.trim()) continue;
367
+ try {
368
+ const event = JSON.parse(line);
369
+ if (event.type === "assistant" && event.message) {
370
+ const textBlocks = (event.message.content || []).filter((b) => b.type === "text").map((b) => b.text);
371
+ if (textBlocks.length > 0) {
372
+ lastAssistantText = textBlocks.join("\n");
373
+ }
374
+ const toolBlocks = (event.message.content || []).filter(
375
+ (b) => b.type === "tool_use"
376
+ );
377
+ toolUseCount += toolBlocks.length;
378
+ }
379
+ if (event.type === "result" && event.result) {
380
+ lastAssistantText = event.result;
381
+ }
382
+ } catch {
383
+ }
384
+ }
385
+ });
386
+ claude.stderr.on("data", (data) => {
387
+ stderr += data.toString();
388
+ });
389
+ claude.on("close", (code) => {
390
+ clearTimeout(timer);
391
+ if (lineBuffer.trim()) {
392
+ try {
393
+ const event = JSON.parse(lineBuffer);
394
+ if (event.type === "result" && event.result) {
395
+ lastAssistantText = event.result;
396
+ }
397
+ } catch {
398
+ }
399
+ }
400
+ logger.info("Claude subagent completed", {
401
+ code,
402
+ toolUseCount,
403
+ outputLength: lastAssistantText.length
404
+ });
405
+ if (code === 0 && lastAssistantText) {
406
+ resolve({ text: lastAssistantText, toolUseCount });
407
+ } else if (code === 0) {
408
+ resolve({
409
+ text: "(Claude completed but produced no text output)",
410
+ toolUseCount
411
+ });
412
+ } else {
413
+ reject(
414
+ new Error(`Claude exited code ${code}: ${stderr.slice(0, 500)}`)
415
+ );
416
+ }
417
+ });
418
+ claude.on("error", (err) => {
419
+ clearTimeout(timer);
420
+ reject(new Error(`Failed to spawn claude: ${err.message}`));
421
+ });
422
+ });
410
423
  }
411
424
  /**
412
425
  * Get mock response for testing
@@ -528,84 +541,11 @@ function greetUser(name: string): string {
528
541
  }
529
542
  }
530
543
  }
531
- /**
532
- * Create a mock Task tool response for development
533
- * This simulates what Claude Code's Task tool would return
534
- */
535
- async mockTaskToolExecution(request) {
536
- const startTime = Date.now();
537
- await new Promise(
538
- (resolve) => setTimeout(resolve, 1e3 + Math.random() * 2e3)
539
- );
540
- const mockResponses = {
541
- planning: {
542
- tasks: [
543
- { id: "1", type: "analyze", description: "Analyze requirements" },
544
- { id: "2", type: "implement", description: "Implement solution" },
545
- { id: "3", type: "test", description: "Test implementation" },
546
- { id: "4", type: "review", description: "Review and improve" }
547
- ],
548
- dependencies: { "2": ["1"], "3": ["2"], "4": ["3"] }
549
- },
550
- code: {
551
- implementation: `
552
- export class Solution {
553
- constructor(private config: any) {}
554
-
555
- async execute(input: string): Promise<string> {
556
- // Implementation generated by Code subagent
557
- return this.process(input);
558
- }
559
-
560
- private process(input: string): string {
561
- return \`Processed: \${input}\`;
562
- }
563
- }`,
564
- files: ["src/solution.ts"]
565
- },
566
- testing: {
567
- tests: `
568
- describe('Solution', () => {
569
- it('should process input correctly', () => {
570
- const solution = new Solution({});
571
- const result = solution.execute('test');
572
- expect(result).toBe('Processed: test');
573
- });
574
-
575
- it('should handle edge cases', () => {
576
- // Edge case tests
577
- });
578
- });`,
579
- coverage: { lines: 95, branches: 88, functions: 100 }
580
- },
581
- review: {
582
- quality: 0.82,
583
- issues: [
584
- { severity: "high", message: "Missing error handling" },
585
- { severity: "medium", message: "Could improve type safety" }
586
- ],
587
- suggestions: [
588
- "Add try-catch blocks",
589
- "Use stricter TypeScript types",
590
- "Add input validation"
591
- ]
592
- }
593
- };
594
- return {
595
- success: true,
596
- result: mockResponses[request.type] || { status: "completed" },
597
- output: `Mock ${request.type} subagent completed successfully`,
598
- duration: Date.now() - startTime,
599
- subagentType: request.type,
600
- tokens: Math.floor(Math.random() * 5e3) + 1e3
601
- };
602
- }
603
544
  /**
604
545
  * Get active subagent statistics
605
546
  */
606
547
  getStats() {
607
548
  return {
608
- activeSubagents: this.activeSubagents.size,
609
549
  tempDir: this.tempDir
610
550
  };
611
551
  }
@@ -613,10 +553,6 @@ describe('Solution', () => {
613
553
  * Cleanup all resources
614
554
  */
615
555
  async cleanupAll() {
616
- for (const [id, controller] of this.activeSubagents) {
617
- controller.abort();
618
- }
619
- this.activeSubagents.clear();
620
556
  if (fs.existsSync(this.tempDir)) {
621
557
  const files = await fs.promises.readdir(this.tempDir);
622
558
  for (const file of files) {
@@ -3,6 +3,7 @@ import { dirname as __pathDirname } from 'path';
3
3
  const __filename = __fileURLToPath(import.meta.url);
4
4
  const __dirname = __pathDirname(__filename);
5
5
  import { v4 as uuidv4 } from "uuid";
6
+ import { spawn } from "child_process";
6
7
  import { logger } from "../../core/monitoring/logger.js";
7
8
  class ClaudeCodeTaskCoordinator {
8
9
  activeTasks = /* @__PURE__ */ new Map();
@@ -165,11 +166,16 @@ class ClaudeCodeTaskCoordinator {
165
166
  completedTasks: this.completedTasks.length
166
167
  });
167
168
  if (this.activeTasks.size > 0) {
168
- const timeoutPromise = new Promise(
169
- (resolve) => setTimeout(resolve, 3e4)
170
- );
169
+ let timer;
170
+ const timeoutPromise = new Promise((resolve) => {
171
+ timer = setTimeout(resolve, 3e4);
172
+ });
171
173
  const completionPromise = this.waitForTaskCompletion();
172
- await Promise.race([completionPromise, timeoutPromise]);
174
+ try {
175
+ await Promise.race([completionPromise, timeoutPromise]);
176
+ } finally {
177
+ clearTimeout(timer);
178
+ }
173
179
  if (this.activeTasks.size > 0) {
174
180
  logger.warn("Force terminating active tasks", {
175
181
  remainingTasks: this.activeTasks.size
@@ -205,26 +211,68 @@ class ClaudeCodeTaskCoordinator {
205
211
  agentType: agentConfig.type,
206
212
  promptTokens: this.estimateTokenUsage(prompt, "")
207
213
  });
208
- return this.simulateClaudeCodeExecution(agentName, prompt, agentConfig);
214
+ return this.spawnClaudeCode(agentName, prompt, agentConfig);
209
215
  }
210
216
  /**
211
- * Simulate Claude Code execution (temporary until real integration)
217
+ * Spawn Claude Code CLI as a subprocess.
218
+ * Uses `claude --print` for non-interactive execution with stdout capture.
219
+ * Workspace cwd is inherited from the coordinator's process.
212
220
  */
213
- simulateClaudeCodeExecution(agentName, prompt, agentConfig) {
214
- return new Promise((resolve) => {
215
- const executionTime = agentConfig.type === "oracle" ? 2e3 + Math.random() * 3e3 : 1e3 + Math.random() * 2e3;
216
- setTimeout(() => {
217
- const result = `Claude Code agent '${agentName}' completed task successfully.
218
-
219
- Agent Capabilities Used: ${agentConfig.capabilities.slice(0, 3).join(", ")}
220
- Task Type: ${agentConfig.type}
221
- Specializations: ${agentConfig.specializations.join(", ")}
222
-
223
- Simulated output based on prompt context: ${prompt.substring(0, 100)}...
224
-
225
- This simulation will be replaced with actual Claude Code Task tool integration.`;
226
- resolve(result);
227
- }, executionTime);
221
+ spawnClaudeCode(agentName, prompt, agentConfig) {
222
+ return new Promise((resolve, reject) => {
223
+ const args = ["--print"];
224
+ if (agentConfig.type === "oracle") {
225
+ args.push("--model", "opus");
226
+ }
227
+ if (agentConfig.capabilities.includes("code_implementation")) {
228
+ args.push("--allowedTools", "Edit,Write,Bash,Read,Glob,Grep");
229
+ } else {
230
+ args.push("--allowedTools", "Read,Glob,Grep,Bash");
231
+ }
232
+ args.push(prompt);
233
+ logger.info("Spawning claude CLI", {
234
+ agentName,
235
+ agentType: agentConfig.type,
236
+ cwd: process.cwd(),
237
+ promptLength: prompt.length
238
+ });
239
+ const proc = spawn("claude", args, {
240
+ cwd: process.cwd(),
241
+ env: { ...process.env },
242
+ stdio: ["pipe", "pipe", "pipe"]
243
+ });
244
+ let stdout = "";
245
+ let stderr = "";
246
+ proc.stdout.on("data", (data) => {
247
+ stdout += data.toString();
248
+ });
249
+ proc.stderr.on("data", (data) => {
250
+ stderr += data.toString();
251
+ });
252
+ proc.on("error", (err) => {
253
+ logger.error("Failed to spawn claude CLI", {
254
+ agentName,
255
+ error: err.message
256
+ });
257
+ reject(new Error(`Failed to spawn claude: ${err.message}`));
258
+ });
259
+ proc.on("close", (code) => {
260
+ if (code === 0) {
261
+ logger.debug("Claude CLI completed", {
262
+ agentName,
263
+ outputLength: stdout.length
264
+ });
265
+ resolve(stdout.trim());
266
+ } else {
267
+ const errMsg = stderr.slice(0, 500) || `exit code ${code}`;
268
+ logger.warn("Claude CLI failed", {
269
+ agentName,
270
+ exitCode: code,
271
+ stderr: errMsg
272
+ });
273
+ reject(new Error(`Claude CLI exited with code ${code}: ${errMsg}`));
274
+ }
275
+ });
228
276
  });
229
277
  }
230
278
  /**