swarm-code 0.1.23 → 0.1.24

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.
@@ -13,10 +13,38 @@
13
13
  * lazily and shutting them down when the session ends.
14
14
  */
15
15
  import { spawn } from "node:child_process";
16
+ import * as fs from "node:fs";
16
17
  import * as http from "node:http";
17
18
  import * as os from "node:os";
19
+ import * as path from "node:path";
18
20
  import { registerAgent } from "./provider.js";
19
21
  // ── Helpers ──────────────────────────────────────────────────────────────────
22
+ /**
23
+ * Ensure model string is in `provider/model` format as required by OpenCode.
24
+ * If no provider prefix is present, infer it from the model name.
25
+ */
26
+ function normalizeModelForOpenCode(model) {
27
+ if (model.includes("/"))
28
+ return model;
29
+ // Infer provider from model name prefix patterns
30
+ if (model.startsWith("gpt-") || model.startsWith("o1") || model.startsWith("o3") || model.startsWith("o4")) {
31
+ return `openai/${model}`;
32
+ }
33
+ if (model.startsWith("claude-")) {
34
+ return `anthropic/${model}`;
35
+ }
36
+ if (model.startsWith("gemini-")) {
37
+ return `google/${model}`;
38
+ }
39
+ if (model.startsWith("deepseek-")) {
40
+ return `deepseek/${model}`;
41
+ }
42
+ if (model.startsWith("llama-") || model.startsWith("codellama-")) {
43
+ return `ollama/${model}`;
44
+ }
45
+ // Can't infer — return as-is and let OpenCode handle it
46
+ return model;
47
+ }
20
48
  async function commandExists(cmd) {
21
49
  return new Promise((resolve) => {
22
50
  const proc = spawn("which", [cmd], { stdio: "pipe" });
@@ -87,6 +115,15 @@ function extractFromParsed(parsed) {
87
115
  /** Whitelist of env vars safe to pass to agent subprocess. */
88
116
  function buildAgentEnv() {
89
117
  const homeDir = os.homedir();
118
+ // Isolate OpenCode's data dir to prevent stale auth tokens (e.g. expired
119
+ // OAuth) from causing "Model not found" errors on startup.
120
+ const agentDataDir = path.join(homeDir, ".swarm", "agent-data");
121
+ try {
122
+ fs.mkdirSync(agentDataDir, { recursive: true });
123
+ }
124
+ catch {
125
+ // Non-fatal
126
+ }
90
127
  return {
91
128
  PATH: process.env.PATH,
92
129
  HOME: homeDir,
@@ -94,9 +131,12 @@ function buildAgentEnv() {
94
131
  SHELL: process.env.SHELL,
95
132
  TERM: process.env.TERM,
96
133
  LANG: process.env.LANG,
134
+ TMPDIR: process.env.TMPDIR,
135
+ XDG_DATA_HOME: agentDataDir,
97
136
  ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
98
137
  OPENAI_API_KEY: process.env.OPENAI_API_KEY,
99
138
  GEMINI_API_KEY: process.env.GEMINI_API_KEY,
139
+ OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
100
140
  GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME,
101
141
  GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL,
102
142
  GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME,
@@ -290,7 +330,7 @@ async function runViaHttpApi(serverUrl, task, model, signal) {
290
330
  parts: [{ type: "text", text: task }],
291
331
  };
292
332
  if (model)
293
- msgBody.model = model;
333
+ msgBody.model = normalizeModelForOpenCode(model);
294
334
  const msgRes = await httpPost(`${serverUrl}/session/${sessionId}/message`, msgBody, signal);
295
335
  if (!msgRes)
296
336
  return null;
@@ -401,7 +441,7 @@ function runViaAttach(serverUrl, task, workDir, model, signal, onOutput) {
401
441
  const startTime = Date.now();
402
442
  const args = ["run", "--attach", serverUrl, "--format", "json"];
403
443
  if (model)
404
- args.push("--model", model);
444
+ args.push("--model", normalizeModelForOpenCode(model));
405
445
  args.push(task);
406
446
  return runSubprocess(args, workDir, startTime, signal, onOutput);
407
447
  }
@@ -414,7 +454,7 @@ function runViaSubprocess(task, workDir, model, signal, onOutput) {
414
454
  const startTime = Date.now();
415
455
  const args = ["run", "--format", "json"];
416
456
  if (model)
417
- args.push("--model", model);
457
+ args.push("--model", normalizeModelForOpenCode(model));
418
458
  args.push(task);
419
459
  return runSubprocess(args, workDir, startTime, signal, onOutput);
420
460
  }
@@ -44,6 +44,7 @@ import { runOnboarding } from "./ui/onboarding.js";
44
44
  import { RunLogger } from "./ui/run-log.js";
45
45
  // UI system
46
46
  import { Spinner } from "./ui/spinner.js";
47
+ import { StreamingFeed } from "./ui/streaming-feed.js";
47
48
  import { bold, coral, cyan, dim, green, isTTY, red, symbols, termWidth, truncate, yellow } from "./ui/theme.js";
48
49
  import { mergeAllThreads, mergeThreadBranch } from "./worktree/merge.js";
49
50
  function parseInteractiveArgs(args) {
@@ -808,13 +809,17 @@ export async function runInteractiveSwarm(rawArgs) {
808
809
  const repl = new PythonRepl();
809
810
  const sessionAc = new AbortController();
810
811
  const dashboard = new ThreadDashboard();
812
+ const streamingFeed = new StreamingFeed();
811
813
  spinner.setDashboard(dashboard);
814
+ spinner.setStreamingFeed(streamingFeed);
812
815
  const threadProgress = (threadId, phase, detail) => {
813
816
  if (phase === "completed" || phase === "failed" || phase === "cancelled") {
814
817
  dashboard.complete(threadId, phase, detail);
818
+ streamingFeed.completeThread(threadId, phase, detail);
815
819
  }
816
820
  else {
817
821
  dashboard.update(threadId, phase, detail);
822
+ streamingFeed.updateThread(threadId, phase, detail);
818
823
  }
819
824
  };
820
825
  // Enable OpenCode server mode
@@ -824,6 +829,9 @@ export async function runInteractiveSwarm(rawArgs) {
824
829
  }
825
830
  // Initialize thread manager
826
831
  const threadManager = new ThreadManager(args.dir, config, threadProgress, sessionAc.signal);
832
+ threadManager.setThreadOutputCallback((threadId, chunk) => {
833
+ streamingFeed.appendOutput(threadId, chunk);
834
+ });
827
835
  await threadManager.init();
828
836
  if (episodicMemory) {
829
837
  threadManager.setEpisodicMemory(episodicMemory);
@@ -873,6 +881,7 @@ export async function runInteractiveSwarm(rawArgs) {
873
881
  cleanupCalled = true;
874
882
  spinner.stop();
875
883
  dashboard.clear();
884
+ streamingFeed.clear();
876
885
  sessionAc.abort();
877
886
  repl.shutdown();
878
887
  await threadManager.cleanup();
package/dist/swarm.js CHANGED
@@ -38,6 +38,7 @@ import { isJsonMode, logAnswer, logError, logRouter, logSuccess, logVerbose, log
38
38
  import { runOnboarding } from "./ui/onboarding.js";
39
39
  // UI system
40
40
  import { Spinner } from "./ui/spinner.js";
41
+ import { StreamingFeed } from "./ui/streaming-feed.js";
41
42
  import { renderSummary } from "./ui/summary.js";
42
43
  import { mergeAllThreads } from "./worktree/merge.js";
43
44
  function parseSwarmArgs(args) {
@@ -286,16 +287,20 @@ export async function runSwarmMode(rawArgs) {
286
287
  // Start REPL
287
288
  const repl = new PythonRepl();
288
289
  const ac = new AbortController();
289
- // Thread dashboard for live status
290
+ // Thread dashboard and streaming feed for live status
290
291
  const dashboard = new ThreadDashboard();
292
+ const streamingFeed = new StreamingFeed();
291
293
  spinner.setDashboard(dashboard);
294
+ spinner.setStreamingFeed(streamingFeed);
292
295
  // Progress callback for thread events
293
296
  const threadProgress = (threadId, phase, detail) => {
294
297
  if (phase === "completed" || phase === "failed" || phase === "cancelled") {
295
298
  dashboard.complete(threadId, phase, detail);
299
+ streamingFeed.completeThread(threadId, phase, detail);
296
300
  }
297
301
  else {
298
302
  dashboard.update(threadId, phase, detail);
303
+ streamingFeed.updateThread(threadId, phase, detail);
299
304
  }
300
305
  };
301
306
  // Enable OpenCode server mode for persistent connections (reduces cold-start)
@@ -305,6 +310,9 @@ export async function runSwarmMode(rawArgs) {
305
310
  }
306
311
  // Initialize thread manager
307
312
  const threadManager = new ThreadManager(args.dir, config, threadProgress, ac.signal);
313
+ threadManager.setThreadOutputCallback((threadId, chunk) => {
314
+ streamingFeed.appendOutput(threadId, chunk);
315
+ });
308
316
  await threadManager.init();
309
317
  if (episodicMemory) {
310
318
  threadManager.setEpisodicMemory(episodicMemory);
@@ -375,12 +383,17 @@ export async function runSwarmMode(rawArgs) {
375
383
  logRouter(`${route.reason} [slot: ${route.slot}]`);
376
384
  }
377
385
  const threadId = randomBytes(6).toString("hex");
378
- // Update dashboard with task info
386
+ // Update dashboard and streaming feed with task info
379
387
  dashboard.update(threadId, "queued", undefined, {
380
388
  task,
381
389
  agent: resolvedAgent,
382
390
  model: resolvedModel,
383
391
  });
392
+ streamingFeed.updateThread(threadId, "queued", undefined, {
393
+ task,
394
+ agent: resolvedAgent,
395
+ model: resolvedModel,
396
+ });
384
397
  const result = await threadManager.spawnThread({
385
398
  id: threadId,
386
399
  task,
@@ -520,6 +533,7 @@ export async function runSwarmMode(rawArgs) {
520
533
  finally {
521
534
  spinner.stop();
522
535
  dashboard.clear();
536
+ streamingFeed.clear();
523
537
  process.removeListener("SIGINT", abortAndExit);
524
538
  process.removeListener("SIGTERM", abortAndExit);
525
539
  repl.shutdown();
@@ -29,6 +29,7 @@ export declare class AsyncSemaphore {
29
29
  get waitingCount(): number;
30
30
  }
31
31
  export type ThreadProgressCallback = (threadId: string, phase: ThreadProgressPhase, detail?: string) => void;
32
+ export type ThreadOutputCallback = (threadId: string, chunk: string) => void;
32
33
  export declare class ThreadManager {
33
34
  private threads;
34
35
  private totalSpawned;
@@ -39,9 +40,12 @@ export declare class ThreadManager {
39
40
  private threadCache;
40
41
  private episodicMemory?;
41
42
  private onThreadProgress?;
43
+ private onThreadOutput?;
42
44
  private sessionAbort?;
43
45
  private threadAbortControllers;
44
46
  constructor(repoRoot: string, config: SwarmConfig, onThreadProgress?: ThreadProgressCallback, sessionAbort?: AbortSignal);
47
+ /** Set the output streaming callback for live agent output. */
48
+ setThreadOutputCallback(cb: ThreadOutputCallback): void;
45
49
  /** Set the episodic memory store for recording thread outcomes. */
46
50
  setEpisodicMemory(memory: EpisodicMemory): void;
47
51
  init(): Promise<void>;
@@ -260,6 +260,7 @@ export class ThreadManager {
260
260
  threadCache;
261
261
  episodicMemory;
262
262
  onThreadProgress;
263
+ onThreadOutput;
263
264
  sessionAbort;
264
265
  threadAbortControllers = new Map();
265
266
  constructor(repoRoot, config, onThreadProgress, sessionAbort) {
@@ -271,6 +272,10 @@ export class ThreadManager {
271
272
  this.onThreadProgress = onThreadProgress;
272
273
  this.sessionAbort = sessionAbort;
273
274
  }
275
+ /** Set the output streaming callback for live agent output. */
276
+ setThreadOutputCallback(cb) {
277
+ this.onThreadOutput = cb;
278
+ }
274
279
  /** Set the episodic memory store for recording thread outcomes. */
275
280
  setEpisodicMemory(memory) {
276
281
  this.episodicMemory = memory;
@@ -476,6 +481,7 @@ export class ThreadManager {
476
481
  model: threadConfig.agent.model || this.config.default_model,
477
482
  files: threadConfig.files,
478
483
  signal: combinedAc.signal,
484
+ onOutput: this.onThreadOutput ? (chunk) => this.onThreadOutput(threadId, chunk) : undefined,
479
485
  });
480
486
  }
481
487
  finally {
@@ -6,5 +6,6 @@ export * from "./dashboard.js";
6
6
  export * from "./log.js";
7
7
  export * from "./onboarding.js";
8
8
  export * from "./spinner.js";
9
+ export * from "./streaming-feed.js";
9
10
  export * from "./summary.js";
10
11
  export * from "./theme.js";
package/dist/ui/index.js CHANGED
@@ -6,6 +6,7 @@ export * from "./dashboard.js";
6
6
  export * from "./log.js";
7
7
  export * from "./onboarding.js";
8
8
  export * from "./spinner.js";
9
+ export * from "./streaming-feed.js";
9
10
  export * from "./summary.js";
10
11
  export * from "./theme.js";
11
12
  //# sourceMappingURL=index.js.map
@@ -4,10 +4,12 @@
4
4
  * Runs on an isolated 80ms animation loop separated from other output.
5
5
  * Displays a coral-colored spinner glyph + a rotating verb + optional detail.
6
6
  *
7
- * Coordinates with ThreadDashboard — the spinner owns the render cycle:
8
- * spinner line first, then dashboard lines below. This prevents interleaving.
7
+ * Coordinates with ThreadDashboard or StreamingFeed — the spinner owns the
8
+ * render cycle: spinner line first, then extra lines below. This prevents
9
+ * interleaving.
9
10
  */
10
11
  import type { ThreadDashboard } from "./dashboard.js";
12
+ import type { StreamingFeed } from "./streaming-feed.js";
11
13
  export declare class Spinner {
12
14
  private static _exitHandlerRegistered;
13
15
  private intervalId;
@@ -18,10 +20,13 @@ export declare class Spinner {
18
20
  private startTime;
19
21
  private running;
20
22
  private dashboard;
23
+ private streamingFeed;
21
24
  /** How many lines we wrote below the spinner (dashboard) */
22
25
  private extraLines;
23
26
  /** Link a dashboard so the spinner owns its render cycle. */
24
27
  setDashboard(d: ThreadDashboard): void;
28
+ /** Link a streaming feed so the spinner owns its render cycle. */
29
+ setStreamingFeed(f: StreamingFeed): void;
25
30
  /** Start the spinner. */
26
31
  start(detail?: string): void;
27
32
  /** Update the detail text without restarting. */
@@ -4,8 +4,9 @@
4
4
  * Runs on an isolated 80ms animation loop separated from other output.
5
5
  * Displays a coral-colored spinner glyph + a rotating verb + optional detail.
6
6
  *
7
- * Coordinates with ThreadDashboard — the spinner owns the render cycle:
8
- * spinner line first, then dashboard lines below. This prevents interleaving.
7
+ * Coordinates with ThreadDashboard or StreamingFeed — the spinner owns the
8
+ * render cycle: spinner line first, then extra lines below. This prevents
9
+ * interleaving.
9
10
  */
10
11
  import { coral, dim, isTTY, termWidth } from "./theme.js";
11
12
  /** Playful verbs shown while the spinner is active. */
@@ -50,12 +51,17 @@ export class Spinner {
50
51
  startTime = 0;
51
52
  running = false;
52
53
  dashboard = null;
54
+ streamingFeed = null;
53
55
  /** How many lines we wrote below the spinner (dashboard) */
54
56
  extraLines = 0;
55
57
  /** Link a dashboard so the spinner owns its render cycle. */
56
58
  setDashboard(d) {
57
59
  this.dashboard = d;
58
60
  }
61
+ /** Link a streaming feed so the spinner owns its render cycle. */
62
+ setStreamingFeed(f) {
63
+ this.streamingFeed = f;
64
+ }
59
65
  /** Start the spinner. */
60
66
  start(detail) {
61
67
  if (!isTTY || this.running)
@@ -119,13 +125,15 @@ export class Spinner {
119
125
  const verb = VERBS[this.verbIdx];
120
126
  const time = dim(`${elapsed}s`);
121
127
  const maxW = termWidth();
128
+ // When streaming feed is active, use its status detail automatically
129
+ const activeDetail = this.streamingFeed ? this.streamingFeed.getStatusDetail() || this.detail : this.detail;
122
130
  // Build detail string, truncating if needed (ANSI-safe)
123
131
  let detailStr = "";
124
- if (this.detail) {
132
+ if (activeDetail) {
125
133
  const prefix = ` X ${verb}... ${elapsed}s`;
126
134
  const available = maxW - prefix.length - 1;
127
135
  if (available > 5) {
128
- const raw = this.detail;
136
+ const raw = activeDetail;
129
137
  detailStr = raw.length > available ? dim(` ${raw.slice(0, available - 2)}\u2026`) : dim(` ${raw}`);
130
138
  }
131
139
  }
@@ -141,13 +149,14 @@ export class Spinner {
141
149
  }
142
150
  // Write spinner line (we're on the spinner line now)
143
151
  process.stderr.write(`\r\x1b[2K${line}`);
144
- // Write dashboard lines below if we have a linked dashboard
145
- if (this.dashboard) {
146
- const dashLines = this.dashboard.getLines();
147
- if (dashLines.length > 0) {
152
+ // Write extra lines below streaming feed takes priority over dashboard
153
+ const provider = this.streamingFeed || this.dashboard;
154
+ if (provider) {
155
+ const extraLines = provider.getLines();
156
+ if (extraLines.length > 0) {
148
157
  process.stderr.write("\n");
149
- process.stderr.write(dashLines.join("\n"));
150
- this.extraLines = dashLines.length;
158
+ process.stderr.write(extraLines.join("\n"));
159
+ this.extraLines = extraLines.length;
151
160
  // Move cursor back up to the spinner line
152
161
  if (this.extraLines > 0) {
153
162
  process.stderr.write(`\x1b[${this.extraLines}A`);
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Streaming feed — live grouped-waves view of swarm thread activity.
3
+ *
4
+ * Layout (top to bottom):
5
+ * 1. Completed summary (single line, hidden when 0)
6
+ * 2. Failed summary (single line, hidden when 0)
7
+ * 3. Retried summary (single line, hidden when 0)
8
+ * 4. Running section (interleaved feed, capped at MAX_FEED_LINES)
9
+ * 5. Queued summary (single line, hidden when 0)
10
+ * 6. Wave marker (single line, hidden when no waves)
11
+ *
12
+ * Rendering is owned by the Spinner — this component builds lines but does
13
+ * NOT write to stderr directly.
14
+ */
15
+ export type ThreadOutputCallback = (threadId: string, chunk: string) => void;
16
+ export declare class StreamingFeed {
17
+ private threads;
18
+ private feedLines;
19
+ private waves;
20
+ private labelIndex;
21
+ private enabled;
22
+ /** Tracks the highest completed count at each wave boundary. */
23
+ private completedAtLastSpawn;
24
+ private currentWave;
25
+ /** Cumulative stats for the session summary. */
26
+ private totalCompleted;
27
+ private totalFailed;
28
+ private totalFilesChanged;
29
+ private totalCostUsd;
30
+ constructor();
31
+ /** Called when a thread's phase changes. */
32
+ updateThread(id: string, phase: string, detail?: string, extra?: {
33
+ task?: string;
34
+ agent?: string;
35
+ model?: string;
36
+ filesChanged?: number;
37
+ costUsd?: number;
38
+ }): void;
39
+ /** Called when a thread completes, fails, or is cancelled. */
40
+ completeThread(id: string, phase: string, detail?: string): void;
41
+ /** Append a chunk of agent output for a thread. */
42
+ appendOutput(threadId: string, chunk: string): void;
43
+ private detectWave;
44
+ /** Get the status bar text (used by Spinner as its detail). */
45
+ getStatusDetail(): string;
46
+ /** Build all display lines (called by Spinner on each render tick). */
47
+ getLines(): string[];
48
+ /** Clear all state. */
49
+ clear(): void;
50
+ /** Get session summary stats. */
51
+ getSessionStats(): {
52
+ totalThreads: number;
53
+ completed: number;
54
+ failed: number;
55
+ totalFiles: number;
56
+ totalCost: number;
57
+ waves: number;
58
+ };
59
+ private buildCompletedLine;
60
+ private buildFailedLine;
61
+ private buildRunningSection;
62
+ private buildQueuedLine;
63
+ private buildWaveLine;
64
+ private assignLabel;
65
+ private subscript;
66
+ private shortTask;
67
+ private countByPhase;
68
+ private getThreadsByPhase;
69
+ private avgDuration;
70
+ private formatPhaseShort;
71
+ private truncateLine;
72
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Streaming feed — live grouped-waves view of swarm thread activity.
3
+ *
4
+ * Layout (top to bottom):
5
+ * 1. Completed summary (single line, hidden when 0)
6
+ * 2. Failed summary (single line, hidden when 0)
7
+ * 3. Retried summary (single line, hidden when 0)
8
+ * 4. Running section (interleaved feed, capped at MAX_FEED_LINES)
9
+ * 5. Queued summary (single line, hidden when 0)
10
+ * 6. Wave marker (single line, hidden when no waves)
11
+ *
12
+ * Rendering is owned by the Spinner — this component builds lines but does
13
+ * NOT write to stderr directly.
14
+ */
15
+ import { getLogLevel, isJsonMode } from "./log.js";
16
+ import { coral, cyan, dim, green, isTTY, red, stripAnsi, symbols, termWidth, truncate } from "./theme.js";
17
+ // ── Constants ────────────────────────────────────────────────────────────────
18
+ /** Max visible feed lines in the running section. */
19
+ const MAX_FEED_LINES = 8;
20
+ /** Max completed/failed thread names shown individually before collapsing. */
21
+ const COLLAPSE_THRESHOLD = 5;
22
+ /** Greek labels for low thread counts. */
23
+ const GREEK = [
24
+ "α",
25
+ "β",
26
+ "γ",
27
+ "δ",
28
+ "ε",
29
+ "ζ",
30
+ "η",
31
+ "θ",
32
+ "ι",
33
+ "κ",
34
+ "λ",
35
+ "μ",
36
+ "ν",
37
+ "ξ",
38
+ "ο",
39
+ "π",
40
+ "ρ",
41
+ "σ",
42
+ "τ",
43
+ "υ",
44
+ "φ",
45
+ "χ",
46
+ "ψ",
47
+ "ω",
48
+ ];
49
+ export class StreamingFeed {
50
+ threads = new Map();
51
+ feedLines = [];
52
+ waves = [];
53
+ labelIndex = 0;
54
+ enabled;
55
+ /** Tracks the highest completed count at each wave boundary. */
56
+ completedAtLastSpawn = 0;
57
+ currentWave = 1;
58
+ /** Cumulative stats for the session summary. */
59
+ totalCompleted = 0;
60
+ totalFailed = 0;
61
+ totalFilesChanged = 0;
62
+ totalCostUsd = 0;
63
+ constructor() {
64
+ this.enabled = isTTY && !isJsonMode() && getLogLevel() !== "quiet";
65
+ }
66
+ // ── Thread lifecycle ───────────────────────────────────────────────────
67
+ /** Called when a thread's phase changes. */
68
+ updateThread(id, phase, detail, extra) {
69
+ const existing = this.threads.get(id);
70
+ if (existing) {
71
+ existing.phase = phase;
72
+ if (detail !== undefined)
73
+ existing.detail = detail;
74
+ if (extra?.filesChanged !== undefined)
75
+ existing.filesChanged = extra.filesChanged;
76
+ if (extra?.costUsd !== undefined)
77
+ existing.costUsd = extra.costUsd;
78
+ }
79
+ else {
80
+ // New thread — assign label and detect wave
81
+ const label = this.assignLabel();
82
+ this.threads.set(id, {
83
+ id,
84
+ label,
85
+ task: extra?.task || "",
86
+ phase,
87
+ startedAt: Date.now(),
88
+ detail,
89
+ });
90
+ this.detectWave();
91
+ }
92
+ }
93
+ /** Called when a thread completes, fails, or is cancelled. */
94
+ completeThread(id, phase, detail) {
95
+ const existing = this.threads.get(id);
96
+ if (!existing)
97
+ return;
98
+ existing.phase = phase;
99
+ existing.completedAt = Date.now();
100
+ existing.durationSec = Math.round((existing.completedAt - existing.startedAt) / 1000);
101
+ if (detail)
102
+ existing.detail = detail;
103
+ // Parse detail for file count and cost (format: "3 files, $0.04 (1500+800 tokens)")
104
+ if (detail && phase === "completed") {
105
+ const filesMatch = detail.match(/(\d+)\s+files?/);
106
+ if (filesMatch) {
107
+ existing.filesChanged = Number.parseInt(filesMatch[1], 10);
108
+ this.totalFilesChanged += existing.filesChanged;
109
+ }
110
+ const costMatch = detail.match(/\$([0-9.]+)/);
111
+ if (costMatch) {
112
+ existing.costUsd = Number.parseFloat(costMatch[1]);
113
+ this.totalCostUsd += existing.costUsd;
114
+ }
115
+ this.totalCompleted++;
116
+ }
117
+ else if (phase === "failed") {
118
+ existing.error = detail;
119
+ this.totalFailed++;
120
+ }
121
+ }
122
+ // ── Agent output streaming ─────────────────────────────────────────────
123
+ /** Append a chunk of agent output for a thread. */
124
+ appendOutput(threadId, chunk) {
125
+ const thread = this.threads.get(threadId);
126
+ if (!thread)
127
+ return;
128
+ // Split into lines, ignore empty
129
+ const lines = chunk.split("\n").filter((l) => l.trim().length > 0);
130
+ for (const line of lines) {
131
+ this.feedLines.push({
132
+ threadId,
133
+ label: thread.label,
134
+ text: line.trim(),
135
+ timestamp: Date.now(),
136
+ });
137
+ }
138
+ // Cap total feed lines to prevent unbounded memory growth
139
+ if (this.feedLines.length > 500) {
140
+ this.feedLines = this.feedLines.slice(-300);
141
+ }
142
+ }
143
+ // ── Wave tracking ──────────────────────────────────────────────────────
144
+ detectWave() {
145
+ const completed = this.countByPhase("completed") + this.countByPhase("failed");
146
+ if (completed > this.completedAtLastSpawn && this.threads.size > 1) {
147
+ this.currentWave++;
148
+ const runningCount = this.countByPhase("agent_running") + this.countByPhase("creating_worktree") + this.countByPhase("queued");
149
+ this.waves.push({
150
+ waveNumber: this.currentWave,
151
+ threadCount: runningCount,
152
+ timestamp: Date.now(),
153
+ });
154
+ this.completedAtLastSpawn = completed;
155
+ }
156
+ }
157
+ // ── Rendering ──────────────────────────────────────────────────────────
158
+ /** Get the status bar text (used by Spinner as its detail). */
159
+ getStatusDetail() {
160
+ const running = this.countByPhase("agent_running") + this.countByPhase("creating_worktree");
161
+ const queued = this.countByPhase("queued");
162
+ const completed = this.totalCompleted;
163
+ const failed = this.totalFailed;
164
+ const total = this.threads.size;
165
+ const parts = [];
166
+ if (total > 0)
167
+ parts.push(`${total} threads`);
168
+ if (running > 0)
169
+ parts.push(`${running} running`);
170
+ if (completed > 0)
171
+ parts.push(`${completed} done`);
172
+ if (queued > 0)
173
+ parts.push(`${queued} queued`);
174
+ if (failed > 0)
175
+ parts.push(`${failed} failed`);
176
+ return parts.join(" · ");
177
+ }
178
+ /** Build all display lines (called by Spinner on each render tick). */
179
+ getLines() {
180
+ if (!this.enabled || this.threads.size === 0)
181
+ return [];
182
+ const w = Math.min(termWidth(), 100);
183
+ const lines = [];
184
+ // Completed summary
185
+ const completedLines = this.buildCompletedLine(w);
186
+ if (completedLines)
187
+ lines.push(completedLines);
188
+ // Failed summary
189
+ const failedLine = this.buildFailedLine(w);
190
+ if (failedLine)
191
+ lines.push(failedLine);
192
+ // Running section
193
+ const runningLines = this.buildRunningSection(w);
194
+ lines.push(...runningLines);
195
+ // Queued summary
196
+ const queuedLine = this.buildQueuedLine(w);
197
+ if (queuedLine)
198
+ lines.push(queuedLine);
199
+ // Wave marker
200
+ const waveLine = this.buildWaveLine();
201
+ if (waveLine)
202
+ lines.push(waveLine);
203
+ return lines;
204
+ }
205
+ /** Clear all state. */
206
+ clear() {
207
+ this.threads.clear();
208
+ this.feedLines = [];
209
+ this.waves = [];
210
+ this.labelIndex = 0;
211
+ }
212
+ /** Get session summary stats. */
213
+ getSessionStats() {
214
+ return {
215
+ totalThreads: this.threads.size,
216
+ completed: this.totalCompleted,
217
+ failed: this.totalFailed,
218
+ totalFiles: this.totalFilesChanged,
219
+ totalCost: this.totalCostUsd,
220
+ waves: this.currentWave,
221
+ };
222
+ }
223
+ // ── Private rendering helpers ──────────────────────────────────────────
224
+ buildCompletedLine(w) {
225
+ const completed = this.getThreadsByPhase("completed");
226
+ if (completed.length === 0)
227
+ return null;
228
+ if (completed.length <= COLLAPSE_THRESHOLD) {
229
+ // Show individual thread names
230
+ const names = completed
231
+ .map((t) => {
232
+ const dur = t.durationSec ? `${t.durationSec}s` : "";
233
+ return `${t.label} ${truncate(this.shortTask(t.task), 12)} ${dur}`;
234
+ })
235
+ .join(dim(" · "));
236
+ const files = this.totalFilesChanged > 0 ? dim(` · ${this.totalFilesChanged} files`) : "";
237
+ return this.truncateLine(` ${green(symbols.check)} ${green("completed")} ${dim(`(${completed.length})`)} ${dim(names)}${files}`, w);
238
+ }
239
+ // Collapsed — aggregate stats
240
+ const avgDur = this.avgDuration(completed);
241
+ const avgCost = this.totalCostUsd > 0 ? ` · $${(this.totalCostUsd / completed.length).toFixed(2)} each` : "";
242
+ return this.truncateLine(` ${green(symbols.check)} ${green("completed")} ${dim(`(${completed.length})`)} ${dim(`avg ${avgDur}s${avgCost} · ${this.totalFilesChanged} files`)}`, w);
243
+ }
244
+ buildFailedLine(w) {
245
+ const failed = this.getThreadsByPhase("failed");
246
+ if (failed.length === 0)
247
+ return null;
248
+ if (failed.length <= COLLAPSE_THRESHOLD) {
249
+ const names = failed
250
+ .map((t) => {
251
+ const err = t.error || t.detail || "error";
252
+ return `${t.label} ${truncate(err, 20)}`;
253
+ })
254
+ .join(dim(" · "));
255
+ return this.truncateLine(` ${red(symbols.cross)} ${red("failed")} ${dim(`(${failed.length})`)} ${dim(names)}`, w);
256
+ }
257
+ return this.truncateLine(` ${red(symbols.cross)} ${red("failed")} ${dim(`(${failed.length})`)}`, w);
258
+ }
259
+ buildRunningSection(w) {
260
+ const running = this.getThreadsByPhase("agent_running", "creating_worktree", "capturing_diff", "compressing");
261
+ if (running.length === 0)
262
+ return [];
263
+ const lines = [];
264
+ // Section header
265
+ const showingNote = running.length > MAX_FEED_LINES ? dim(` showing ${Math.min(running.length, 4)} most active`) : "";
266
+ lines.push(` ${coral("▸")} ${coral("running")} ${dim(`(${running.length})`)}${showingNote}`);
267
+ // Feed lines — show recent output from active threads
268
+ const activeIds = new Set(running.map((t) => t.id));
269
+ const relevantLines = this.feedLines.filter((fl) => activeIds.has(fl.threadId));
270
+ const recentLines = relevantLines.slice(-MAX_FEED_LINES);
271
+ if (recentLines.length > 0) {
272
+ for (const fl of recentLines) {
273
+ const label = coral(fl.label);
274
+ const text = truncate(fl.text, w - 10);
275
+ lines.push(` ${label} ${dim(symbols.vertLine)} ${text}`);
276
+ }
277
+ // Add cursor on last line of most active thread
278
+ const lastLine = recentLines[recentLines.length - 1];
279
+ if (lastLine) {
280
+ const thread = this.threads.get(lastLine.threadId);
281
+ if (thread && (thread.phase === "agent_running" || thread.phase === "creating_worktree")) {
282
+ // Replace the last line to add cursor
283
+ const idx = lines.length - 1;
284
+ lines[idx] = `${lines[idx]} ${dim("▌")}`;
285
+ }
286
+ }
287
+ }
288
+ else {
289
+ // No output yet — show phase info for each running thread
290
+ for (const t of running.slice(0, MAX_FEED_LINES)) {
291
+ const label = coral(t.label);
292
+ const phase = this.formatPhaseShort(t.phase);
293
+ const task = t.task ? dim(truncate(this.shortTask(t.task), w - 20)) : "";
294
+ lines.push(` ${label} ${dim(symbols.vertLine)} ${phase} ${task}`);
295
+ }
296
+ }
297
+ return lines;
298
+ }
299
+ buildQueuedLine(w) {
300
+ const queued = this.getThreadsByPhase("queued");
301
+ if (queued.length === 0)
302
+ return null;
303
+ if (queued.length <= COLLAPSE_THRESHOLD) {
304
+ const names = queued.map((t) => t.label).join(" ");
305
+ return this.truncateLine(` ${dim("·")} ${dim("queued")} ${dim(`(${queued.length})`)} ${dim(names)}`, w);
306
+ }
307
+ return this.truncateLine(` ${dim("·")} ${dim("queued")} ${dim(`(${queued.length})`)}`, w);
308
+ }
309
+ buildWaveLine() {
310
+ if (this.waves.length === 0)
311
+ return null;
312
+ const latest = this.waves[this.waves.length - 1];
313
+ return ` ${dim("↳")} ${dim(`wave ${latest.waveNumber} — ${latest.threadCount} threads spawned from previous results`)}`;
314
+ }
315
+ // ── Utility ────────────────────────────────────────────────────────────
316
+ assignLabel() {
317
+ const idx = this.labelIndex++;
318
+ if (idx < GREEK.length)
319
+ return GREEK[idx];
320
+ // Beyond 24 threads: use indexed labels like τ₂₅
321
+ const base = GREEK[idx % GREEK.length];
322
+ const num = idx + 1;
323
+ return `${base}${this.subscript(num)}`;
324
+ }
325
+ subscript(n) {
326
+ const SUBSCRIPT_DIGITS = "₀₁₂₃₄₅₆₇₈₉";
327
+ return String(n)
328
+ .split("")
329
+ .map((d) => SUBSCRIPT_DIGITS[Number.parseInt(d, 10)])
330
+ .join("");
331
+ }
332
+ shortTask(task) {
333
+ // Extract a short label from the task description
334
+ const firstLine = task.split("\n")[0];
335
+ // Remove common prefixes
336
+ return firstLine.replace(/^(fix|add|update|create|implement|refactor|write|remove|delete)\s+/i, "").trim();
337
+ }
338
+ countByPhase(...phases) {
339
+ let count = 0;
340
+ for (const [, t] of this.threads) {
341
+ if (phases.includes(t.phase))
342
+ count++;
343
+ }
344
+ return count;
345
+ }
346
+ getThreadsByPhase(...phases) {
347
+ const result = [];
348
+ for (const [, t] of this.threads) {
349
+ if (phases.includes(t.phase))
350
+ result.push(t);
351
+ }
352
+ return result;
353
+ }
354
+ avgDuration(threads) {
355
+ if (threads.length === 0)
356
+ return 0;
357
+ const total = threads.reduce((sum, t) => sum + (t.durationSec || 0), 0);
358
+ return Math.round(total / threads.length);
359
+ }
360
+ formatPhaseShort(phase) {
361
+ switch (phase) {
362
+ case "agent_running":
363
+ return coral("running");
364
+ case "creating_worktree":
365
+ return cyan("creating worktree");
366
+ case "capturing_diff":
367
+ return cyan("capturing diff");
368
+ case "compressing":
369
+ return dim("compressing");
370
+ default:
371
+ return dim(phase);
372
+ }
373
+ }
374
+ truncateLine(line, maxWidth) {
375
+ const visible = stripAnsi(line);
376
+ if (visible.length <= maxWidth)
377
+ return line;
378
+ const ansiOverhead = line.length - visible.length;
379
+ return `${line.slice(0, maxWidth - 1 + ansiOverhead)}\x1b[0m`;
380
+ }
381
+ }
382
+ //# sourceMappingURL=streaming-feed.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swarm-code",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
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": {