swarm-code 0.1.0

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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +384 -0
  3. package/bin/swarm.mjs +45 -0
  4. package/dist/agents/aider.d.ts +12 -0
  5. package/dist/agents/aider.js +182 -0
  6. package/dist/agents/claude-code.d.ts +9 -0
  7. package/dist/agents/claude-code.js +216 -0
  8. package/dist/agents/codex.d.ts +14 -0
  9. package/dist/agents/codex.js +193 -0
  10. package/dist/agents/direct-llm.d.ts +9 -0
  11. package/dist/agents/direct-llm.js +78 -0
  12. package/dist/agents/mock.d.ts +9 -0
  13. package/dist/agents/mock.js +77 -0
  14. package/dist/agents/opencode.d.ts +23 -0
  15. package/dist/agents/opencode.js +571 -0
  16. package/dist/agents/provider.d.ts +11 -0
  17. package/dist/agents/provider.js +31 -0
  18. package/dist/cli.d.ts +15 -0
  19. package/dist/cli.js +285 -0
  20. package/dist/compression/compressor.d.ts +28 -0
  21. package/dist/compression/compressor.js +265 -0
  22. package/dist/config.d.ts +42 -0
  23. package/dist/config.js +170 -0
  24. package/dist/core/repl.d.ts +69 -0
  25. package/dist/core/repl.js +336 -0
  26. package/dist/core/rlm.d.ts +63 -0
  27. package/dist/core/rlm.js +409 -0
  28. package/dist/core/runtime.py +335 -0
  29. package/dist/core/types.d.ts +131 -0
  30. package/dist/core/types.js +19 -0
  31. package/dist/env.d.ts +10 -0
  32. package/dist/env.js +75 -0
  33. package/dist/interactive-swarm.d.ts +20 -0
  34. package/dist/interactive-swarm.js +1041 -0
  35. package/dist/interactive.d.ts +10 -0
  36. package/dist/interactive.js +1765 -0
  37. package/dist/main.d.ts +15 -0
  38. package/dist/main.js +242 -0
  39. package/dist/mcp/server.d.ts +15 -0
  40. package/dist/mcp/server.js +72 -0
  41. package/dist/mcp/session.d.ts +73 -0
  42. package/dist/mcp/session.js +184 -0
  43. package/dist/mcp/tools.d.ts +15 -0
  44. package/dist/mcp/tools.js +377 -0
  45. package/dist/memory/episodic.d.ts +132 -0
  46. package/dist/memory/episodic.js +390 -0
  47. package/dist/prompts/orchestrator.d.ts +5 -0
  48. package/dist/prompts/orchestrator.js +191 -0
  49. package/dist/routing/model-router.d.ts +130 -0
  50. package/dist/routing/model-router.js +515 -0
  51. package/dist/swarm.d.ts +14 -0
  52. package/dist/swarm.js +557 -0
  53. package/dist/threads/cache.d.ts +58 -0
  54. package/dist/threads/cache.js +198 -0
  55. package/dist/threads/manager.d.ts +85 -0
  56. package/dist/threads/manager.js +659 -0
  57. package/dist/ui/banner.d.ts +14 -0
  58. package/dist/ui/banner.js +42 -0
  59. package/dist/ui/dashboard.d.ts +33 -0
  60. package/dist/ui/dashboard.js +151 -0
  61. package/dist/ui/index.d.ts +10 -0
  62. package/dist/ui/index.js +11 -0
  63. package/dist/ui/log.d.ts +39 -0
  64. package/dist/ui/log.js +126 -0
  65. package/dist/ui/onboarding.d.ts +14 -0
  66. package/dist/ui/onboarding.js +518 -0
  67. package/dist/ui/spinner.d.ts +25 -0
  68. package/dist/ui/spinner.js +113 -0
  69. package/dist/ui/summary.d.ts +18 -0
  70. package/dist/ui/summary.js +113 -0
  71. package/dist/ui/theme.d.ts +63 -0
  72. package/dist/ui/theme.js +97 -0
  73. package/dist/viewer.d.ts +12 -0
  74. package/dist/viewer.js +1284 -0
  75. package/dist/worktree/manager.d.ts +45 -0
  76. package/dist/worktree/manager.js +266 -0
  77. package/dist/worktree/merge.d.ts +28 -0
  78. package/dist/worktree/merge.js +138 -0
  79. package/package.json +69 -0
@@ -0,0 +1,659 @@
1
+ /**
2
+ * Thread manager — spawns and manages coding agent threads in isolated worktrees.
3
+ *
4
+ * Features:
5
+ * - AsyncSemaphore for proper concurrency gating (no polling)
6
+ * - AbortSignal propagation for thread cancellation
7
+ * - Per-thread retry logic with exponential backoff
8
+ * - Error classification: retryable (transient) vs fatal (permanent)
9
+ * - Agent/model re-routing on failure (fallback to alternative combos)
10
+ * - Budget tracking and enforcement
11
+ * - Per-thread error isolation
12
+ */
13
+ import { randomBytes } from "node:crypto";
14
+ import { getAgent, listAgents } from "../agents/provider.js";
15
+ import { compressResult } from "../compression/compressor.js";
16
+ import { MODEL_PRICING as PRICING } from "../core/types.js";
17
+ import { AGENT_CAPABILITIES } from "../routing/model-router.js";
18
+ import { WorktreeManager } from "../worktree/manager.js";
19
+ import { ThreadCache } from "./cache.js";
20
+ // ── Async Semaphore ─────────────────────────────────────────────────────────
21
+ /**
22
+ * Promise-based semaphore for concurrency control.
23
+ * acquire() resolves when a slot is available, release() frees a slot.
24
+ */
25
+ export class AsyncSemaphore {
26
+ current = 0;
27
+ max;
28
+ waiters = [];
29
+ constructor(max) {
30
+ this.max = max;
31
+ }
32
+ async acquire() {
33
+ if (this.current < this.max) {
34
+ this.current++;
35
+ return;
36
+ }
37
+ await new Promise((resolve) => {
38
+ this.waiters.push(resolve);
39
+ });
40
+ // current already accounts for this slot — release() transferred it directly
41
+ }
42
+ release() {
43
+ const next = this.waiters.shift();
44
+ if (next) {
45
+ // Transfer the slot directly to the waiter (current stays the same)
46
+ next();
47
+ }
48
+ else {
49
+ if (this.current <= 0)
50
+ return; // Guard against double-release
51
+ this.current--;
52
+ }
53
+ }
54
+ get activeCount() {
55
+ return this.current;
56
+ }
57
+ get waitingCount() {
58
+ return this.waiters.length;
59
+ }
60
+ }
61
+ // ── Budget Tracker ──────────────────────────────────────────────────────────
62
+ class BudgetTracker {
63
+ totalSpent = 0;
64
+ threadCosts = new Map();
65
+ sessionLimit;
66
+ perThreadLimit;
67
+ totalInputTokens = 0;
68
+ totalOutputTokens = 0;
69
+ actualCostCount = 0;
70
+ estimatedCostCount = 0;
71
+ constructor(sessionLimit, perThreadLimit) {
72
+ this.sessionLimit = sessionLimit;
73
+ this.perThreadLimit = perThreadLimit;
74
+ }
75
+ /** Estimate cost for a thread based on model and assumed token usage. */
76
+ estimateThreadCost(model) {
77
+ const modelName = model.includes("/") ? model.split("/").pop() : model;
78
+ const pricing = PRICING[modelName];
79
+ if (!pricing)
80
+ return 0.05;
81
+ // Assume ~4K input tokens, ~2K output tokens per thread execution
82
+ const inputTokens = 4000;
83
+ const outputTokens = 2000;
84
+ return (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000;
85
+ }
86
+ /**
87
+ * Calculate actual cost from real token usage.
88
+ * Returns null if pricing for the model is unknown.
89
+ */
90
+ calculateActualCost(model, usage) {
91
+ const modelName = model.includes("/") ? model.split("/").pop() : model;
92
+ const pricing = PRICING[modelName];
93
+ if (!pricing)
94
+ return null;
95
+ return (usage.inputTokens * pricing.input + usage.outputTokens * pricing.output) / 1_000_000;
96
+ }
97
+ /** Check if we can afford to spawn a thread. */
98
+ canAfford(model) {
99
+ const estimate = this.estimateThreadCost(model);
100
+ if (this.totalSpent + estimate > this.sessionLimit) {
101
+ return {
102
+ allowed: false,
103
+ reason: `Session budget exceeded: $${this.totalSpent.toFixed(4)} spent of $${this.sessionLimit.toFixed(2)} limit (next thread ~$${estimate.toFixed(4)})`,
104
+ };
105
+ }
106
+ if (estimate > this.perThreadLimit) {
107
+ return {
108
+ allowed: false,
109
+ reason: `Thread cost estimate ($${estimate.toFixed(4)}) exceeds per-thread limit ($${this.perThreadLimit.toFixed(2)})`,
110
+ };
111
+ }
112
+ return { allowed: true };
113
+ }
114
+ /**
115
+ * Record cost for a completed thread.
116
+ * Uses actual usage when available, falls back to estimate.
117
+ */
118
+ recordCost(threadId, model, usage) {
119
+ let cost;
120
+ let isEstimate;
121
+ if (usage && (usage.inputTokens > 0 || usage.outputTokens > 0)) {
122
+ // Use real token counts
123
+ const actual = this.calculateActualCost(model, usage);
124
+ if (actual !== null) {
125
+ cost = actual;
126
+ isEstimate = false;
127
+ this.actualCostCount++;
128
+ }
129
+ else {
130
+ // Have tokens but no pricing — estimate
131
+ cost = this.estimateThreadCost(model);
132
+ isEstimate = true;
133
+ this.estimatedCostCount++;
134
+ }
135
+ this.totalInputTokens += usage.inputTokens;
136
+ this.totalOutputTokens += usage.outputTokens;
137
+ }
138
+ else {
139
+ // No usage data — estimate
140
+ cost = this.estimateThreadCost(model);
141
+ isEstimate = true;
142
+ this.estimatedCostCount++;
143
+ }
144
+ this.threadCosts.set(threadId, cost);
145
+ this.totalSpent += cost;
146
+ return { cost, isEstimate };
147
+ }
148
+ get spent() {
149
+ return this.totalSpent;
150
+ }
151
+ getState() {
152
+ return {
153
+ totalSpentUsd: this.totalSpent,
154
+ threadCosts: new Map(this.threadCosts),
155
+ sessionLimitUsd: this.sessionLimit,
156
+ perThreadLimitUsd: this.perThreadLimit,
157
+ totalTokens: {
158
+ input: this.totalInputTokens,
159
+ output: this.totalOutputTokens,
160
+ },
161
+ actualCostThreads: this.actualCostCount,
162
+ estimatedCostThreads: this.estimatedCostCount,
163
+ };
164
+ }
165
+ }
166
+ // ── Error Classification ────────────────────────────────────────────────────
167
+ /** Patterns that indicate transient/retryable errors. */
168
+ const RETRYABLE_PATTERNS = [
169
+ /timeout/i,
170
+ /timed?\s*out/i,
171
+ /ECONNRESET/i,
172
+ /ECONNREFUSED/i,
173
+ /EPIPE/i,
174
+ /rate limit/i,
175
+ /429/,
176
+ /503/,
177
+ /502/,
178
+ /500/,
179
+ /too many requests/i,
180
+ /temporarily unavailable/i,
181
+ /server error/i,
182
+ /overloaded/i,
183
+ /capacity/i,
184
+ /lock file/i,
185
+ /index\.lock/i,
186
+ ];
187
+ /** Patterns that indicate permanent/fatal errors (don't retry). */
188
+ const FATAL_PATTERNS = [
189
+ /authentication/i,
190
+ /unauthorized/i,
191
+ /forbidden/i,
192
+ /invalid api key/i,
193
+ /model not found/i,
194
+ /permission denied/i,
195
+ /quota exceeded/i,
196
+ /billing/i,
197
+ ];
198
+ /** Classify an error as retryable or fatal. Default: retryable (optimistic). */
199
+ function isRetryableError(error) {
200
+ // Check fatal patterns first (takes priority)
201
+ if (FATAL_PATTERNS.some((p) => p.test(error)))
202
+ return false;
203
+ // Check retryable patterns
204
+ if (RETRYABLE_PATTERNS.some((p) => p.test(error)))
205
+ return true;
206
+ // Default: retryable (be optimistic — the retry might work with a different agent)
207
+ return true;
208
+ }
209
+ /** Calculate exponential backoff delay with jitter. */
210
+ function backoffDelay(attempt, baseMs = 1000) {
211
+ // Exponential: 1s, 2s, 4s, 8s... capped at 30s
212
+ const exponential = Math.min(baseMs * 2 ** (attempt - 1), 30000);
213
+ // Add jitter (±25%)
214
+ const jitter = exponential * 0.25 * (Math.random() * 2 - 1);
215
+ return Math.max(100, exponential + jitter);
216
+ }
217
+ /**
218
+ * Pick an alternative agent/model combo for retry.
219
+ * Avoids the agent that just failed and prefers agents with different default models.
220
+ * Uses attempt number to cycle through alternatives on subsequent retries.
221
+ */
222
+ function pickAlternativeAgent(failedAgent, failedModel, _config, attempt = 1) {
223
+ const available = listAgents().filter((name) => name !== failedAgent && name !== "mock");
224
+ if (available.length === 0)
225
+ return null;
226
+ // Build candidates, preferring agents with different default models
227
+ const candidates = [];
228
+ for (const name of available) {
229
+ const cap = AGENT_CAPABILITIES[name];
230
+ if (cap && cap.defaultModel !== failedModel) {
231
+ candidates.push({ agent: name, model: cap.defaultModel });
232
+ }
233
+ }
234
+ // Also include agents with same model (but different agent) as lower priority
235
+ for (const name of available) {
236
+ const cap = AGENT_CAPABILITIES[name];
237
+ if (cap && cap.defaultModel === failedModel) {
238
+ candidates.push({ agent: name, model: cap.defaultModel });
239
+ }
240
+ }
241
+ // If no capabilities known, add all available with failedModel
242
+ if (candidates.length === 0) {
243
+ for (const name of available) {
244
+ candidates.push({ agent: name, model: failedModel });
245
+ }
246
+ }
247
+ if (candidates.length === 0)
248
+ return null;
249
+ // Cycle through candidates based on attempt number
250
+ const idx = (attempt - 1) % candidates.length;
251
+ return candidates[idx];
252
+ }
253
+ export class ThreadManager {
254
+ threads = new Map();
255
+ totalSpawned = 0;
256
+ semaphore;
257
+ worktreeManager;
258
+ config;
259
+ budget;
260
+ threadCache;
261
+ episodicMemory;
262
+ onThreadProgress;
263
+ sessionAbort;
264
+ threadAbortControllers = new Map();
265
+ constructor(repoRoot, config, onThreadProgress, sessionAbort) {
266
+ this.config = config;
267
+ this.semaphore = new AsyncSemaphore(config.max_threads);
268
+ this.worktreeManager = new WorktreeManager(repoRoot, config.worktree_base_dir);
269
+ this.budget = new BudgetTracker(config.max_session_budget_usd, config.max_thread_budget_usd);
270
+ this.threadCache = new ThreadCache(100, config.thread_cache_persist ? config.thread_cache_dir : undefined, config.thread_cache_ttl_hours);
271
+ this.onThreadProgress = onThreadProgress;
272
+ this.sessionAbort = sessionAbort;
273
+ }
274
+ /** Set the episodic memory store for recording thread outcomes. */
275
+ setEpisodicMemory(memory) {
276
+ this.episodicMemory = memory;
277
+ }
278
+ async init() {
279
+ await this.worktreeManager.init();
280
+ await this.threadCache.init();
281
+ }
282
+ /**
283
+ * Spawn a thread — creates a worktree, runs the agent, returns compressed result.
284
+ * Checks the subthread cache first; on cache hit, returns immediately (Slate-style reuse).
285
+ * Retries up to config.thread_retries times on failure.
286
+ * Error-isolated: a failure here never throws — always returns a CompressedResult.
287
+ */
288
+ async spawnThread(threadConfig) {
289
+ // Subthread cache lookup — return cached result for identical tasks
290
+ const cacheAgent = threadConfig.agent.backend || this.config.default_agent;
291
+ const cacheModel = threadConfig.agent.model || this.config.default_model;
292
+ const cacheFiles = threadConfig.files || [];
293
+ const cached = this.threadCache.get(threadConfig.task, cacheFiles, cacheAgent, cacheModel);
294
+ if (cached) {
295
+ const threadId = threadConfig.id || randomBytes(6).toString("hex");
296
+ this.onThreadProgress?.(threadId, "completed", "cache hit");
297
+ return cached;
298
+ }
299
+ // Enforce total thread limit
300
+ if (this.totalSpawned >= this.config.max_total_threads) {
301
+ return {
302
+ success: false,
303
+ summary: `Thread limit reached (${this.config.max_total_threads} max per session)`,
304
+ filesChanged: [],
305
+ diffStats: "",
306
+ durationMs: 0,
307
+ estimatedCostUsd: 0,
308
+ };
309
+ }
310
+ // Preliminary budget check (definitive check happens inside semaphore)
311
+ const model = threadConfig.agent.model || this.config.default_model;
312
+ const budgetCheck = this.budget.canAfford(model);
313
+ if (!budgetCheck.allowed) {
314
+ return {
315
+ success: false,
316
+ summary: `Budget exceeded: ${budgetCheck.reason}`,
317
+ filesChanged: [],
318
+ diffStats: "",
319
+ durationMs: 0,
320
+ estimatedCostUsd: 0,
321
+ };
322
+ }
323
+ // Check session abort
324
+ if (this.sessionAbort?.aborted) {
325
+ return {
326
+ success: false,
327
+ summary: "Session aborted",
328
+ filesChanged: [],
329
+ diffStats: "",
330
+ durationMs: 0,
331
+ estimatedCostUsd: 0,
332
+ };
333
+ }
334
+ const threadId = threadConfig.id || randomBytes(6).toString("hex");
335
+ const maxAttempts = this.config.thread_retries + 1;
336
+ const state = {
337
+ id: threadId,
338
+ config: threadConfig,
339
+ status: "pending",
340
+ phase: "queued",
341
+ startedAt: Date.now(),
342
+ attempt: 0,
343
+ maxAttempts,
344
+ estimatedCostUsd: 0,
345
+ };
346
+ this.threads.set(threadId, state);
347
+ this.totalSpawned++;
348
+ // Create per-thread abort controller (linked to session abort)
349
+ const threadAc = new AbortController();
350
+ this.threadAbortControllers.set(threadId, threadAc);
351
+ const onSessionAbort = () => threadAc.abort();
352
+ if (this.sessionAbort) {
353
+ if (this.sessionAbort.aborted) {
354
+ threadAc.abort();
355
+ }
356
+ else {
357
+ this.sessionAbort.addEventListener("abort", onSessionAbort, { once: true });
358
+ }
359
+ }
360
+ // Retry loop with exponential backoff and agent re-routing
361
+ let lastResult;
362
+ let currentConfig = threadConfig;
363
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
364
+ state.attempt = attempt;
365
+ if (attempt > 1) {
366
+ // Exponential backoff before retry (abort-aware)
367
+ const delay = backoffDelay(attempt - 1);
368
+ state.phase = "retrying";
369
+ this.onThreadProgress?.(threadId, "retrying", `attempt ${attempt}/${maxAttempts}, backoff ${(delay / 1000).toFixed(1)}s`);
370
+ // Race the delay against the abort signal so cancellation is immediate
371
+ await new Promise((resolve) => {
372
+ const timer = setTimeout(resolve, delay);
373
+ if (threadAc.signal.aborted) {
374
+ clearTimeout(timer);
375
+ resolve();
376
+ return;
377
+ }
378
+ const onAbort = () => {
379
+ clearTimeout(timer);
380
+ resolve();
381
+ };
382
+ threadAc.signal.addEventListener("abort", onAbort, { once: true });
383
+ });
384
+ if (threadAc.signal.aborted)
385
+ break;
386
+ }
387
+ lastResult = await this.executeThread(threadId, currentConfig, state, threadAc.signal);
388
+ if (lastResult.success || threadAc.signal.aborted) {
389
+ break;
390
+ }
391
+ // Don't retry on cancellation or budget issues
392
+ if (state.status === "cancelled")
393
+ break;
394
+ // Classify the error — don't retry fatal errors
395
+ const errorMsg = state.error || lastResult.summary || "";
396
+ if (!isRetryableError(errorMsg)) {
397
+ this.onThreadProgress?.(threadId, "failed", `fatal error, not retrying: ${errorMsg.slice(0, 80)}`);
398
+ break;
399
+ }
400
+ // Try re-routing to a different agent/model on retry
401
+ if (attempt < maxAttempts) {
402
+ const currentAgent = currentConfig.agent.backend || this.config.default_agent;
403
+ const currentModel = currentConfig.agent.model || this.config.default_model;
404
+ const alt = pickAlternativeAgent(currentAgent, currentModel, this.config, attempt);
405
+ if (alt) {
406
+ currentConfig = {
407
+ ...currentConfig,
408
+ agent: { backend: alt.agent, model: alt.model },
409
+ };
410
+ this.onThreadProgress?.(threadId, "retrying", `re-routing: ${currentAgent} → ${alt.agent}`);
411
+ }
412
+ }
413
+ }
414
+ this.sessionAbort?.removeEventListener("abort", onSessionAbort);
415
+ this.threadAbortControllers.delete(threadId);
416
+ return lastResult;
417
+ }
418
+ /**
419
+ * Execute a single thread attempt. Acquires semaphore, creates worktree,
420
+ * runs agent, captures diff, compresses result.
421
+ */
422
+ async executeThread(threadId, threadConfig, state, signal) {
423
+ // Wait for a concurrency slot
424
+ state.phase = "queued";
425
+ this.onThreadProgress?.(threadId, "queued", this.semaphore.waitingCount > 0 ? `waiting (${this.semaphore.waitingCount} ahead)` : undefined);
426
+ await this.semaphore.acquire();
427
+ try {
428
+ if (signal.aborted) {
429
+ state.status = "cancelled";
430
+ state.phase = "cancelled";
431
+ state.completedAt = Date.now();
432
+ return this.failResult(state, "Thread cancelled before start");
433
+ }
434
+ // Definitive budget check inside semaphore (prevents race condition)
435
+ const threadModel = threadConfig.agent.model || this.config.default_model;
436
+ const budgetCheck = this.budget.canAfford(threadModel);
437
+ if (!budgetCheck.allowed) {
438
+ state.status = "failed";
439
+ state.phase = "failed";
440
+ state.completedAt = Date.now();
441
+ return {
442
+ success: false,
443
+ summary: `Budget exceeded: ${budgetCheck.reason}`,
444
+ filesChanged: [],
445
+ diffStats: "",
446
+ durationMs: 0,
447
+ estimatedCostUsd: 0,
448
+ };
449
+ }
450
+ state.status = "running";
451
+ // Create worktree
452
+ state.phase = "creating_worktree";
453
+ this.onThreadProgress?.(threadId, "creating_worktree");
454
+ const wtInfo = await this.worktreeManager.create(threadId);
455
+ state.worktreePath = wtInfo.path;
456
+ state.branchName = wtInfo.branch;
457
+ // Run agent
458
+ state.phase = "agent_running";
459
+ this.onThreadProgress?.(threadId, "agent_running");
460
+ const agent = getAgent(threadConfig.agent.backend || this.config.default_agent);
461
+ let fullTask = threadConfig.task;
462
+ if (threadConfig.context) {
463
+ fullTask = `Context:\n${threadConfig.context}\n\nTask:\n${threadConfig.task}`;
464
+ }
465
+ // Combine thread timeout with cancellation signal
466
+ const timeoutSignal = AbortSignal.timeout(this.config.thread_timeout_ms);
467
+ const combinedAc = new AbortController();
468
+ const onAbort = () => combinedAc.abort();
469
+ signal.addEventListener("abort", onAbort, { once: true });
470
+ timeoutSignal.addEventListener("abort", onAbort, { once: true });
471
+ let agentResult;
472
+ try {
473
+ agentResult = await agent.run({
474
+ task: fullTask,
475
+ workDir: wtInfo.path,
476
+ model: threadConfig.agent.model || this.config.default_model,
477
+ files: threadConfig.files,
478
+ signal: combinedAc.signal,
479
+ });
480
+ }
481
+ finally {
482
+ signal.removeEventListener("abort", onAbort);
483
+ timeoutSignal.removeEventListener("abort", onAbort);
484
+ }
485
+ if (signal.aborted) {
486
+ state.status = "cancelled";
487
+ state.phase = "cancelled";
488
+ state.completedAt = Date.now();
489
+ await this.cleanupWorktree(threadId);
490
+ return this.failResult(state, "Thread cancelled during execution");
491
+ }
492
+ // Capture diff
493
+ state.phase = "capturing_diff";
494
+ this.onThreadProgress?.(threadId, "capturing_diff");
495
+ const diff = await this.worktreeManager.getDiff(threadId);
496
+ const diffStats = await this.worktreeManager.getDiffStats(threadId);
497
+ const filesChanged = await this.worktreeManager.getChangedFiles(threadId);
498
+ if (filesChanged.length > 0) {
499
+ await this.worktreeManager.commit(threadId, `swarm: ${threadConfig.task.slice(0, 72)}`);
500
+ }
501
+ // Compress
502
+ state.phase = "compressing";
503
+ this.onThreadProgress?.(threadId, "compressing");
504
+ const compressed = await compressResult({
505
+ agentOutput: agentResult.output,
506
+ diff,
507
+ diffStats,
508
+ filesChanged,
509
+ success: agentResult.success,
510
+ durationMs: agentResult.durationMs,
511
+ error: agentResult.error,
512
+ }, this.config.compression_strategy, this.config.compression_max_tokens);
513
+ // Record cost — uses real usage when available, falls back to estimate
514
+ const model = threadConfig.agent.model || this.config.default_model;
515
+ const { cost, isEstimate } = this.budget.recordCost(threadId, model, agentResult.usage);
516
+ state.estimatedCostUsd = cost;
517
+ const costLabel = isEstimate ? `~$${cost.toFixed(4)}` : `$${cost.toFixed(4)}`;
518
+ const usageLabel = agentResult.usage
519
+ ? ` (${agentResult.usage.inputTokens}+${agentResult.usage.outputTokens} tokens)`
520
+ : "";
521
+ const result = {
522
+ success: agentResult.success,
523
+ summary: compressed,
524
+ filesChanged,
525
+ diffStats,
526
+ durationMs: Date.now() - state.startedAt,
527
+ estimatedCostUsd: cost,
528
+ usage: agentResult.usage,
529
+ costIsEstimate: isEstimate,
530
+ };
531
+ state.status = "completed";
532
+ state.phase = "completed";
533
+ state.result = result;
534
+ state.completedAt = Date.now();
535
+ this.onThreadProgress?.(threadId, "completed", `${filesChanged.length} files, ${costLabel}${usageLabel}`);
536
+ // Cache successful results for subthread reuse
537
+ if (result.success) {
538
+ const cfg = state.config;
539
+ this.threadCache.set(cfg.task, cfg.files || [], cfg.agent.backend || this.config.default_agent, cfg.agent.model || this.config.default_model, result);
540
+ // Record episode in episodic memory (fire-and-forget)
541
+ // Only records if auto-routing is NOT active (swarm.ts records richer episodes with slot/complexity)
542
+ if (this.episodicMemory && !this.config.auto_model_selection) {
543
+ this.episodicMemory
544
+ .record({
545
+ task: cfg.task,
546
+ agent: cfg.agent.backend || this.config.default_agent,
547
+ model: cfg.agent.model || this.config.default_model,
548
+ slot: "",
549
+ complexity: "",
550
+ success: true,
551
+ durationMs: result.durationMs,
552
+ estimatedCostUsd: cost,
553
+ filesChanged: filesChanged,
554
+ summary: compressed,
555
+ })
556
+ .catch(() => { }); // Non-fatal
557
+ }
558
+ }
559
+ return result;
560
+ }
561
+ catch (err) {
562
+ const errorMsg = err instanceof Error ? err.message : String(err);
563
+ state.status = "failed";
564
+ state.phase = "failed";
565
+ state.error = errorMsg;
566
+ state.completedAt = Date.now();
567
+ this.onThreadProgress?.(threadId, "failed", errorMsg.slice(0, 100));
568
+ // Record estimated cost for failed threads (agent may have consumed tokens before failure)
569
+ const errModel = threadConfig.agent.model || this.config.default_model;
570
+ const { cost } = this.budget.recordCost(threadId, errModel);
571
+ state.estimatedCostUsd = cost;
572
+ // Cleanup worktree on failure
573
+ await this.cleanupWorktree(threadId);
574
+ return this.failResult(state, errorMsg);
575
+ }
576
+ finally {
577
+ this.semaphore.release();
578
+ }
579
+ }
580
+ /** Cancel a specific running thread. */
581
+ cancelThread(threadId) {
582
+ const ac = this.threadAbortControllers.get(threadId);
583
+ if (!ac)
584
+ return false;
585
+ ac.abort();
586
+ const state = this.threads.get(threadId);
587
+ if (state) {
588
+ state.status = "cancelled";
589
+ state.phase = "cancelled";
590
+ }
591
+ return true;
592
+ }
593
+ /** Cancel all running threads. */
594
+ cancelAll() {
595
+ for (const [id, ac] of this.threadAbortControllers) {
596
+ ac.abort();
597
+ const state = this.threads.get(id);
598
+ if (state && (state.status === "running" || state.status === "pending")) {
599
+ state.status = "cancelled";
600
+ state.phase = "cancelled";
601
+ }
602
+ }
603
+ }
604
+ /** Get all thread states. */
605
+ getThreads() {
606
+ return [...this.threads.values()];
607
+ }
608
+ /** Get a specific thread's state. */
609
+ getThread(threadId) {
610
+ return this.threads.get(threadId);
611
+ }
612
+ /** Get the worktree manager for merge operations. */
613
+ getWorktreeManager() {
614
+ return this.worktreeManager;
615
+ }
616
+ /** Get current budget state. */
617
+ getBudgetState() {
618
+ return this.budget.getState();
619
+ }
620
+ /** Get subthread cache stats. */
621
+ getCacheStats() {
622
+ return this.threadCache.getStats();
623
+ }
624
+ /** Get concurrency stats. */
625
+ getConcurrencyStats() {
626
+ return {
627
+ active: this.semaphore.activeCount,
628
+ waiting: this.semaphore.waitingCount,
629
+ total: this.totalSpawned,
630
+ max: this.config.max_threads,
631
+ };
632
+ }
633
+ /** Cleanup all worktrees. */
634
+ async cleanup() {
635
+ this.cancelAll();
636
+ if (this.config.auto_cleanup_worktrees) {
637
+ await this.worktreeManager.destroyAll();
638
+ }
639
+ }
640
+ async cleanupWorktree(threadId) {
641
+ try {
642
+ await this.worktreeManager.destroy(threadId, true);
643
+ }
644
+ catch {
645
+ // Non-fatal
646
+ }
647
+ }
648
+ failResult(state, message) {
649
+ return {
650
+ success: false,
651
+ summary: `Thread failed (attempt ${state.attempt}/${state.maxAttempts}): ${message}`,
652
+ filesChanged: [],
653
+ diffStats: "",
654
+ durationMs: Date.now() - (state.startedAt || Date.now()),
655
+ estimatedCostUsd: state.estimatedCostUsd,
656
+ };
657
+ }
658
+ }
659
+ //# sourceMappingURL=manager.js.map
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Startup banner — the first thing users see when running swarm.
3
+ */
4
+ /** Render the swarm startup banner. */
5
+ export declare function renderBanner(config: {
6
+ dir: string;
7
+ model: string;
8
+ provider: string;
9
+ agent: string;
10
+ routing: string;
11
+ query: string;
12
+ dryRun: boolean;
13
+ memorySize?: number;
14
+ }): void;