swarm-code 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js CHANGED
@@ -23,7 +23,7 @@ const DEFAULTS = {
23
23
  max_session_budget_usd: 10.0,
24
24
  default_agent: "opencode",
25
25
  default_model: "anthropic/claude-sonnet-4-6",
26
- auto_model_selection: false,
26
+ auto_model_selection: true,
27
27
  compression_strategy: "structured",
28
28
  compression_max_tokens: 1000,
29
29
  worktree_base_dir: ".swarm-worktrees",
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Hook runner — executes user-defined commands at lifecycle points.
3
+ *
4
+ * Hooks provide deterministic control flow (not LLM-decided).
5
+ * They run shell commands and surface only errors — success is silent.
6
+ *
7
+ * Lifecycle points:
8
+ * - post_thread: After a thread commits (before compression)
9
+ * - post_merge: After merge_threads() completes
10
+ * - post_session: When the session ends
11
+ */
12
+ export interface HookConfig {
13
+ command: string;
14
+ on_failure: "warn" | "block";
15
+ }
16
+ export interface HooksConfig {
17
+ post_thread: HookConfig[];
18
+ post_merge: HookConfig[];
19
+ post_session: HookConfig[];
20
+ }
21
+ export interface HookResult {
22
+ success: boolean;
23
+ output: string;
24
+ command: string;
25
+ }
26
+ /**
27
+ * Load hooks from swarm_config.yaml hooks section, or from .swarm/hooks.yaml.
28
+ */
29
+ export declare function loadHooks(projectDir: string): HooksConfig;
30
+ /**
31
+ * Run hooks for a lifecycle point.
32
+ * Returns results for each hook. On "block" failure, throws.
33
+ * Success output is swallowed — only errors are surfaced.
34
+ */
35
+ export declare function runHooks(hooks: HookConfig[], cwd: string, label: string): HookResult[];
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Hook runner — executes user-defined commands at lifecycle points.
3
+ *
4
+ * Hooks provide deterministic control flow (not LLM-decided).
5
+ * They run shell commands and surface only errors — success is silent.
6
+ *
7
+ * Lifecycle points:
8
+ * - post_thread: After a thread commits (before compression)
9
+ * - post_merge: After merge_threads() completes
10
+ * - post_session: When the session ends
11
+ */
12
+ import { execSync } from "node:child_process";
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ const DEFAULT_HOOKS = {
16
+ post_thread: [],
17
+ post_merge: [],
18
+ post_session: [],
19
+ };
20
+ /**
21
+ * Load hooks from swarm_config.yaml hooks section, or from .swarm/hooks.yaml.
22
+ */
23
+ export function loadHooks(projectDir) {
24
+ const hooksFile = path.join(projectDir, ".swarm", "hooks.yaml");
25
+ if (!fs.existsSync(hooksFile))
26
+ return { ...DEFAULT_HOOKS };
27
+ try {
28
+ const raw = fs.readFileSync(hooksFile, "utf-8");
29
+ return parseHooksYaml(raw);
30
+ }
31
+ catch {
32
+ return { ...DEFAULT_HOOKS };
33
+ }
34
+ }
35
+ function parseHooksYaml(raw) {
36
+ const hooks = { post_thread: [], post_merge: [], post_session: [] };
37
+ let currentSection = null;
38
+ for (const line of raw.split("\n")) {
39
+ const trimmed = line.trim();
40
+ if (!trimmed || trimmed.startsWith("#"))
41
+ continue;
42
+ if (trimmed === "post_thread:" || trimmed === "post_merge:" || trimmed === "post_session:") {
43
+ currentSection = trimmed.replace(":", "");
44
+ continue;
45
+ }
46
+ if (currentSection && trimmed.startsWith("- command:")) {
47
+ const command = trimmed
48
+ .replace("- command:", "")
49
+ .trim()
50
+ .replace(/^["']|["']$/g, "");
51
+ if (command) {
52
+ hooks[currentSection].push({ command, on_failure: "warn" });
53
+ }
54
+ }
55
+ if (currentSection && trimmed.startsWith("on_failure:")) {
56
+ const val = trimmed.replace("on_failure:", "").trim();
57
+ const last = hooks[currentSection][hooks[currentSection].length - 1];
58
+ if (last && (val === "warn" || val === "block")) {
59
+ last.on_failure = val;
60
+ }
61
+ }
62
+ }
63
+ return hooks;
64
+ }
65
+ /**
66
+ * Run hooks for a lifecycle point.
67
+ * Returns results for each hook. On "block" failure, throws.
68
+ * Success output is swallowed — only errors are surfaced.
69
+ */
70
+ export function runHooks(hooks, cwd, label) {
71
+ const results = [];
72
+ for (const hook of hooks) {
73
+ try {
74
+ execSync(hook.command, {
75
+ cwd,
76
+ stdio: ["ignore", "pipe", "pipe"],
77
+ timeout: 60_000,
78
+ encoding: "utf-8",
79
+ });
80
+ // Success — silent (context-efficient per harness engineering best practice)
81
+ results.push({ success: true, output: "", command: hook.command });
82
+ }
83
+ catch (err) {
84
+ const stderr = err.stderr || err.stdout || err.message || "unknown error";
85
+ // Only surface error output
86
+ const output = `[${label}] Hook failed: ${hook.command}\n${stderr}`.trim();
87
+ results.push({ success: false, output, command: hook.command });
88
+ if (hook.on_failure === "block") {
89
+ throw new Error(output);
90
+ }
91
+ }
92
+ }
93
+ return results;
94
+ }
95
+ //# sourceMappingURL=runner.js.map
@@ -80,8 +80,7 @@ function parseInteractiveArgs(args) {
80
80
  // Silently ignore unknown flags and positional args
81
81
  }
82
82
  if (!dir) {
83
- logError("--dir <path> is required for interactive swarm mode");
84
- process.exit(1);
83
+ dir = process.cwd();
85
84
  }
86
85
  return {
87
86
  dir: path.resolve(dir),
@@ -1582,30 +1582,73 @@ async function interactive() {
1582
1582
  console.log();
1583
1583
  }
1584
1584
  else {
1585
- // Agent works without keys (e.g. OpenCode) — set up Ollama directly
1586
- console.log(` ${c.bold}${agent.name}${c.reset} ${c.dim}— setting up Ollama for local models:${c.reset}\n`);
1587
- const ok = await ensureOllamaSetup(setupRl, "ollama/deepseek-coder-v2");
1588
- if (ok)
1589
- usesOllama = true;
1585
+ // Agent works without keys (e.g. OpenCode) — choose backend
1586
+ console.log(` ${c.bold}${agent.name}${c.reset} ${c.dim}— choose your backend:${c.reset}\n`);
1587
+ console.log(` ${c.dim}1${c.reset} Ollama ${c.dim}Run models locally (free, requires download)${c.reset}`);
1588
+ console.log(` ${c.dim}2${c.reset} OpenRouter ${c.dim}Cloud API for 200+ models (requires API key)${c.reset}`);
1589
+ console.log();
1590
+ const backendChoice = await questionWithEsc(setupRl, ` ${c.cyan}Backend [1-2]:${c.reset} `);
1591
+ const pickedOpenRouter = backendChoice !== null && backendChoice.trim() === "2";
1592
+ if (pickedOpenRouter) {
1593
+ // OpenRouter setup
1594
+ console.log();
1595
+ const orKey = await questionWithEsc(setupRl, ` ${c.cyan}OPENROUTER_API_KEY:${c.reset} `);
1596
+ if (orKey?.trim()) {
1597
+ process.env.OPENROUTER_API_KEY = orKey.trim();
1598
+ try {
1599
+ const envPath = path.join(process.cwd(), ".env");
1600
+ let envContent = "";
1601
+ try {
1602
+ envContent = fs.readFileSync(envPath, "utf-8");
1603
+ }
1604
+ catch { }
1605
+ if (!envContent.includes("OPENROUTER_API_KEY")) {
1606
+ fs.appendFileSync(envPath, `\nOPENROUTER_API_KEY=${orKey.trim()}\n`);
1607
+ }
1608
+ console.log(` ${c.green}✓${c.reset} OpenRouter configured`);
1609
+ }
1610
+ catch {
1611
+ console.log(` ${c.green}✓${c.reset} OpenRouter key set for this session`);
1612
+ }
1613
+ currentModelId = "openrouter/auto";
1614
+ saveModelPreference(currentModelId);
1615
+ }
1616
+ else {
1617
+ console.log(` ${c.dim}No key provided — you can set OPENROUTER_API_KEY in .env later${c.reset}`);
1618
+ }
1619
+ }
1620
+ else {
1621
+ // Ollama setup
1622
+ console.log();
1623
+ const ok = await ensureOllamaSetup(setupRl, "ollama/deepseek-coder-v2");
1624
+ if (ok)
1625
+ usesOllama = true;
1626
+ }
1590
1627
  console.log();
1591
1628
  }
1592
1629
  }
1593
1630
  // ── Step 3: Set default model ────────────────────────────────
1594
- const activeProvider = Object.keys(PROVIDER_KEYS).find((p) => process.env[providerEnvKey(p)]);
1595
- if (activeProvider) {
1596
- currentProviderName = activeProvider;
1597
- const defaultModel = getDefaultModelForProvider(activeProvider);
1598
- if (defaultModel) {
1599
- currentModelId = defaultModel;
1631
+ if (currentModelId.startsWith("openrouter/")) {
1632
+ // Already set during OpenRouter setup
1633
+ console.log(` ${c.green}✓${c.reset} Default model: ${c.bold}${currentModelId}${c.reset} ${c.dim}(OpenRouter)${c.reset}`);
1634
+ }
1635
+ else {
1636
+ const activeProvider = Object.keys(PROVIDER_KEYS).find((p) => process.env[providerEnvKey(p)]);
1637
+ if (activeProvider) {
1638
+ currentProviderName = activeProvider;
1639
+ const defaultModel = getDefaultModelForProvider(activeProvider);
1640
+ if (defaultModel) {
1641
+ currentModelId = defaultModel;
1642
+ saveModelPreference(currentModelId);
1643
+ console.log(` ${c.green}✓${c.reset} Default model: ${c.bold}${currentModelId}${c.reset}`);
1644
+ }
1645
+ }
1646
+ else if (usesOllama) {
1647
+ currentModelId = "ollama/deepseek-coder-v2";
1600
1648
  saveModelPreference(currentModelId);
1601
- console.log(` ${c.green}✓${c.reset} Default model: ${c.bold}${currentModelId}${c.reset}`);
1649
+ console.log(` ${c.green}✓${c.reset} Default model: ${c.bold}${currentModelId}${c.reset} ${c.dim}(local)${c.reset}`);
1602
1650
  }
1603
1651
  }
1604
- else if (usesOllama) {
1605
- currentModelId = "ollama/deepseek-coder-v2";
1606
- saveModelPreference(currentModelId);
1607
- console.log(` ${c.green}✓${c.reset} Default model: ${c.bold}${currentModelId}${c.reset} ${c.dim}(local)${c.reset}`);
1608
- }
1609
1652
  console.log();
1610
1653
  setupRl.close();
1611
1654
  }
@@ -1637,12 +1680,12 @@ async function interactive() {
1637
1680
  }
1638
1681
  }
1639
1682
  if (!currentModel) {
1640
- if (currentModelId.startsWith("ollama/")) {
1641
- // Ollama model selected this interactive REPL mode needs a cloud API.
1642
- // Redirect to swarm mode which works with OpenCode + Ollama.
1643
- console.log(`\n ${c.green}✓${c.reset} Ollama model selected: ${c.bold}${currentModelId}${c.reset}`);
1683
+ if (currentModelId.startsWith("ollama/") || currentModelId.startsWith("openrouter/")) {
1684
+ // Non-pi-ai model — redirect to swarm mode which uses OpenCode natively.
1685
+ const backend = currentModelId.startsWith("ollama/") ? "Ollama" : "OpenRouter";
1686
+ console.log(`\n ${c.green}✓${c.reset} ${backend} model selected: ${c.bold}${currentModelId}${c.reset}`);
1644
1687
  console.log(`\n ${c.dim}This interactive REPL uses direct LLM API calls.${c.reset}`);
1645
- console.log(` ${c.dim}To use Ollama models with OpenCode, run:${c.reset}\n`);
1688
+ console.log(` ${c.dim}To use ${backend} with OpenCode, run:${c.reset}\n`);
1646
1689
  console.log(` ${c.bold}swarm --dir ./your-project "your task"${c.reset}\n`);
1647
1690
  process.exit(0);
1648
1691
  }
package/dist/main.d.ts CHANGED
@@ -10,6 +10,6 @@
10
10
  * swarm run → single-shot RLM CLI run
11
11
  * swarm viewer → browse trajectory files
12
12
  * swarm benchmark → run benchmarks
13
- * swarm → interactive terminal (RLM mode, default)
13
+ * swarm → interactive REPL (uses current directory)
14
14
  */
15
15
  export declare function buildHelp(): string;
package/dist/main.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * swarm run → single-shot RLM CLI run
11
11
  * swarm viewer → browse trajectory files
12
12
  * swarm benchmark → run benchmarks
13
- * swarm → interactive terminal (RLM mode, default)
13
+ * swarm → interactive REPL (uses current directory)
14
14
  */
15
15
  import { bold, coral, cyan, dim, isTTY, symbols, termWidth, yellow } from "./ui/theme.js";
16
16
  export function buildHelp() {
@@ -45,8 +45,10 @@ export function buildHelp() {
45
45
  lines.push(` ${yellow("swarm mcp")} ${dim("Start MCP server (stdio)")}`);
46
46
  lines.push(` ${yellow("swarm mcp")} --dir ./project ${dim("Start with default directory")}`);
47
47
  lines.push("");
48
+ lines.push(` ${bold("INTERACTIVE")} ${dim("(default — uses current directory)")}`);
49
+ lines.push(` ${yellow("swarm")} ${dim("Interactive REPL in current dir")}`);
50
+ lines.push("");
48
51
  lines.push(` ${bold("RLM MODE")} ${dim("(text processing, inherited from rlm-cli)")}`);
49
- lines.push(` ${yellow("swarm")} ${dim("Interactive terminal (default)")}`);
50
52
  lines.push(` ${yellow("swarm run")} [options] "<query>" ${dim("Run a single query")}`);
51
53
  lines.push(` ${yellow("swarm viewer")} ${dim("Browse saved trajectory files")}`);
52
54
  lines.push(` ${yellow("swarm benchmark")} <name> [--idx] ${dim("Run benchmark")}`);
@@ -125,13 +127,14 @@ async function main() {
125
127
  }
126
128
  return;
127
129
  }
128
- const command = args[0] || "interactive";
130
+ const command = args[0] || "";
131
+ // Default: no command → interactive swarm mode using current directory
132
+ if (!command || command === "interactive" || command === "i") {
133
+ const { runInteractiveSwarm } = await import("./interactive-swarm.js");
134
+ await runInteractiveSwarm(["--dir", process.cwd(), ...args.slice(command ? 1 : 0)]);
135
+ return;
136
+ }
129
137
  switch (command) {
130
- case "interactive":
131
- case "i": {
132
- await import("./interactive.js");
133
- break;
134
- }
135
138
  case "viewer":
136
139
  case "view": {
137
140
  process.argv = [process.argv[0], process.argv[1], ...args.slice(1)];
@@ -56,11 +56,11 @@ ${agentDescriptions}
56
56
 
57
57
  1. **Analyze first**: Use \`llm_query()\` or direct Python to understand the codebase/task
58
58
  2. **Decompose**: Break the task into independent, parallelizable units
59
- 3. **Extract context**: For each thread, extract ONLY the relevant code/context — don't send everything
59
+ 3. **Extract context**: For each thread, extract ONLY the relevant code/context — don't send everything. Keep thread context under 5000 chars; agents have access to the full worktree
60
60
  4. **Spawn threads**: Use \`async_thread()\` + \`asyncio.gather()\` for parallel work
61
61
  5. **Inspect results**: Check each thread's result for success/failure
62
62
  6. **Merge**: Call \`merge_threads()\` to integrate changes
63
- 7. **Verify**: Optionally spawn a test thread to verify the merged result
63
+ 7. **Verify**: ALWAYS spawn a verification thread after merging — run the project's test/typecheck/lint commands. If verification fails, fix before calling FINAL()
64
64
  8. **Report**: Call \`FINAL()\` with a summary
65
65
 
66
66
  ## Episode Quality & Caching
@@ -77,7 +77,8 @@ ${agentDescriptions}
77
77
  4. Use \`print()\` for intermediate output visible in the next iteration
78
78
  5. Max ${config.max_threads} concurrent threads, ${config.max_total_threads} total per session
79
79
  6. Thread timeout: ${config.thread_timeout_ms / 1000}s per thread
80
- 7. Don't call FINAL prematurely — verify thread results first
80
+ 7. Don't call FINAL prematurely — verify thread results first. Always run verification after merge.
81
+ 8. Prefer cheap models for sub-agent threads (haiku, gpt-4o-mini) — save premium models for complex work
81
82
  8. The REPL persists state — variables survive across iterations
82
83
 
83
84
  ## Examples
package/dist/swarm.js CHANGED
@@ -25,6 +25,7 @@ await import("./agents/claude-code.js");
25
25
  await import("./agents/codex.js");
26
26
  await import("./agents/aider.js");
27
27
  import { randomBytes } from "node:crypto";
28
+ import { loadHooks, runHooks } from "./hooks/runner.js";
28
29
  import { EpisodicMemory } from "./memory/episodic.js";
29
30
  import { buildSwarmSystemPrompt } from "./prompts/orchestrator.js";
30
31
  import { classifyTaskComplexity, describeAvailableAgents, FailureTracker, routeTask } from "./routing/model-router.js";
@@ -296,6 +297,7 @@ function resolveModel(modelId) {
296
297
  export async function runSwarmMode(rawArgs) {
297
298
  const args = parseSwarmArgs(rawArgs);
298
299
  const config = loadConfig();
300
+ const hooks = loadHooks(args.dir);
299
301
  // Configure UI
300
302
  if (args.json)
301
303
  setJsonMode(true);
@@ -420,6 +422,12 @@ export async function runSwarmMode(rawArgs) {
420
422
  }
421
423
  // Thread handler
422
424
  const threadHandler = async (task, threadContext, agentBackend, model, files) => {
425
+ // Context-size guard: warn and truncate if orchestrator sends too much context
426
+ const MAX_THREAD_CONTEXT = 50_000;
427
+ if (threadContext.length > MAX_THREAD_CONTEXT) {
428
+ logWarn(`Thread context too large (${(threadContext.length / 1024).toFixed(0)}KB) — truncating to ${MAX_THREAD_CONTEXT / 1000}KB. Agents have full worktree access; pass only relevant excerpts.`);
429
+ threadContext = `${threadContext.slice(0, MAX_THREAD_CONTEXT)}\n\n[... truncated — ${threadContext.length - MAX_THREAD_CONTEXT} chars removed ...]`;
430
+ }
423
431
  let resolvedAgent = agentBackend || config.default_agent;
424
432
  let resolvedModel = model || config.default_model;
425
433
  let routeSlot = "";
@@ -449,6 +457,18 @@ export async function runSwarmMode(rawArgs) {
449
457
  },
450
458
  files,
451
459
  });
460
+ // Run post-thread hooks (typecheck, lint, etc.) — success is silent, only errors surface
461
+ if (result.success && hooks.post_thread.length > 0) {
462
+ try {
463
+ const worktreePath = path.join(args.dir, config.worktree_base_dir, `wt-${threadId}`);
464
+ if (fs.existsSync(worktreePath)) {
465
+ runHooks(hooks.post_thread, worktreePath, "post_thread");
466
+ }
467
+ }
468
+ catch (hookErr) {
469
+ logWarn(`Post-thread hook failed: ${hookErr.message}`);
470
+ }
471
+ }
452
472
  // Track failure in the failure tracker for routing adjustments
453
473
  if (!result.success) {
454
474
  failureTracker.recordFailure(resolvedAgent, resolvedModel, task, result.summary || "unknown error");
@@ -494,6 +514,26 @@ export async function runSwarmMode(rawArgs) {
494
514
  const summary = results
495
515
  .map((r) => (r.success ? `Merged ${r.branch}: ${r.message}` : `FAILED ${r.branch}: ${r.message}`))
496
516
  .join("\n");
517
+ // Run post-merge hooks (tests, etc.) — success is silent
518
+ if (merged > 0 && hooks.post_merge.length > 0) {
519
+ try {
520
+ const hookResults = runHooks(hooks.post_merge, args.dir, "post_merge");
521
+ const hookFailures = hookResults.filter((r) => !r.success);
522
+ if (hookFailures.length > 0) {
523
+ const hookOutput = hookFailures.map((r) => r.output).join("\n");
524
+ return {
525
+ result: `${summary}\n\nPost-merge verification failed:\n${hookOutput}`,
526
+ success: false,
527
+ };
528
+ }
529
+ }
530
+ catch (hookErr) {
531
+ return {
532
+ result: `${summary}\n\nPost-merge hook blocked: ${hookErr.message}`,
533
+ success: false,
534
+ };
535
+ }
536
+ }
497
537
  return {
498
538
  result: summary || "No threads to merge",
499
539
  success: results.every((r) => r.success),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swarm-code",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Open-source swarm-native coding agent orchestrator — spawns parallel coding agents in isolated git worktrees, built on RLM (arXiv:2512.24601)",
5
5
  "type": "module",
6
6
  "bin": {