swarm-code 0.1.16 → 0.1.17

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.
@@ -40,6 +40,7 @@ import { ThreadManager } from "./threads/manager.js";
40
40
  import { ThreadDashboard } from "./ui/dashboard.js";
41
41
  import { logError, logRouter, logSuccess, logVerbose, logWarn, setLogLevel } from "./ui/log.js";
42
42
  import { runOnboarding } from "./ui/onboarding.js";
43
+ import { RunLogger } from "./ui/run-log.js";
43
44
  // UI system
44
45
  import { Spinner } from "./ui/spinner.js";
45
46
  import { bold, coral, cyan, dim, green, isTTY, red, symbols, termWidth, truncate, yellow } from "./ui/theme.js";
@@ -784,6 +785,7 @@ export async function runInteractiveSwarm(rawArgs) {
784
785
  const repl = new PythonRepl();
785
786
  const sessionAc = new AbortController();
786
787
  const dashboard = new ThreadDashboard();
788
+ spinner.setDashboard(dashboard);
787
789
  const threadProgress = (threadId, phase, detail) => {
788
790
  if (phase === "completed" || phase === "failed" || phase === "cancelled") {
789
791
  dashboard.complete(threadId, phase, detail);
@@ -943,6 +945,7 @@ export async function runInteractiveSwarm(rawArgs) {
943
945
  sessionAc.signal.addEventListener("abort", onSessionAbort, { once: true });
944
946
  spinner.start();
945
947
  const startTime = Date.now();
948
+ const runLog = new RunLogger(query, resolved.model.id, config.default_agent, args.dir, config.max_iterations || 20);
946
949
  try {
947
950
  // Update episodic memory hints per-task
948
951
  let taskSystemPrompt = systemPrompt;
@@ -970,7 +973,7 @@ export async function runInteractiveSwarm(rawArgs) {
970
973
  mergeHandler,
971
974
  onProgress: (info) => {
972
975
  spinner.update(`iteration ${info.iteration}/${info.maxIterations}` +
973
- (info.subQueries > 0 ? ` · ${info.subQueries} queries` : ""));
976
+ (info.subQueries > 0 ? ` \u00B7 ${info.subQueries} queries` : ""));
974
977
  logVerbose(`Iteration ${info.iteration}/${info.maxIterations} | ` +
975
978
  `Sub-queries: ${info.subQueries} | Phase: ${info.phase}`);
976
979
  },
@@ -978,6 +981,9 @@ export async function runInteractiveSwarm(rawArgs) {
978
981
  spinner.stop();
979
982
  dashboard.clear();
980
983
  const elapsed = (Date.now() - startTime) / 1000;
984
+ // Log the run
985
+ runLog.complete({ completed: result.completed, iterations: result.iterations, answer: result.answer }, Date.now() - startTime);
986
+ const logPath = runLog.save();
981
987
  // Show concise result
982
988
  process.stderr.write("\n");
983
989
  const status = result.completed ? green("completed") : yellow("incomplete");
@@ -985,22 +991,27 @@ export async function runInteractiveSwarm(rawArgs) {
985
991
  // Show answer
986
992
  if (result.answer) {
987
993
  process.stderr.write("\n");
988
- const lines = result.answer.split("\n");
989
- for (const line of lines) {
994
+ const answerLines = result.answer.split("\n");
995
+ for (const line of answerLines) {
990
996
  process.stderr.write(` ${line}\n`);
991
997
  }
992
998
  }
999
+ if (logPath) {
1000
+ process.stderr.write(` ${dim(`log: ${logPath}`)}\n`);
1001
+ }
993
1002
  process.stderr.write("\n");
994
1003
  }
995
1004
  catch (err) {
996
1005
  spinner.stop();
997
1006
  dashboard.clear();
1007
+ const errMsg = err instanceof Error ? err.message : String(err);
1008
+ runLog.complete({ completed: false, iterations: 0, error: errMsg }, Date.now() - startTime);
1009
+ runLog.save();
998
1010
  if (currentTaskAc?.signal.aborted) {
999
1011
  logWarn("Task cancelled");
1000
1012
  }
1001
1013
  else {
1002
- const msg = err instanceof Error ? err.message : String(err);
1003
- logError(`Task failed: ${msg}`);
1014
+ logError(`Task failed: ${errMsg}`);
1004
1015
  }
1005
1016
  }
1006
1017
  finally {
package/dist/swarm.js CHANGED
@@ -288,6 +288,7 @@ export async function runSwarmMode(rawArgs) {
288
288
  const ac = new AbortController();
289
289
  // Thread dashboard for live status
290
290
  const dashboard = new ThreadDashboard();
291
+ spinner.setDashboard(dashboard);
291
292
  // Progress callback for thread events
292
293
  const threadProgress = (threadId, phase, detail) => {
293
294
  if (phase === "completed" || phase === "failed" || phase === "cancelled") {
@@ -1,18 +1,18 @@
1
1
  /**
2
2
  * Live thread status dashboard — shows running/queued/completed threads.
3
3
  *
4
- * Inspired by Claude Code's collapsed tool results: shows concise status
5
- * per thread, color-coded by phase, with elapsed time and file counts.
6
- * Uses in-place terminal updates for a live-updating display.
4
+ * Rendering is owned by the Spinner the dashboard builds lines but does NOT
5
+ * write directly to stderr. This prevents interleaving between the spinner's
6
+ * 80ms animation loop and async thread progress callbacks.
7
7
  */
8
8
  /**
9
- * Thread dashboard — manages a live-updating display of thread states.
10
- * Renders below the spinner line using ANSI cursor movement.
9
+ * Thread dashboard — manages thread state and builds status lines.
10
+ * Does NOT write to stderr directly the Spinner calls getLines()
11
+ * on each render tick to include dashboard output atomically.
11
12
  */
12
13
  export declare class ThreadDashboard {
13
14
  private threads;
14
15
  private pendingTimers;
15
- private lastLineCount;
16
16
  private enabled;
17
17
  constructor();
18
18
  /** Update a thread's status. */
@@ -22,12 +22,12 @@ export declare class ThreadDashboard {
22
22
  model?: string;
23
23
  filesChanged?: number;
24
24
  }): void;
25
- /** Remove a thread from the dashboard (when done). */
25
+ /** Mark a thread as done. Auto-removes after 1.5s. */
26
26
  complete(id: string, phase: string, detail?: string): void;
27
- /** Clear all dashboard output and cancel pending timers. */
27
+ /** Clear all state and cancel pending timers. */
28
28
  clear(): void;
29
- private render;
30
- private clearLines;
29
+ /** Get the current dashboard lines (called by Spinner.render). */
30
+ getLines(): string[];
31
31
  private buildLines;
32
32
  private formatPhase;
33
33
  }
@@ -1,20 +1,20 @@
1
1
  /**
2
2
  * Live thread status dashboard — shows running/queued/completed threads.
3
3
  *
4
- * Inspired by Claude Code's collapsed tool results: shows concise status
5
- * per thread, color-coded by phase, with elapsed time and file counts.
6
- * Uses in-place terminal updates for a live-updating display.
4
+ * Rendering is owned by the Spinner the dashboard builds lines but does NOT
5
+ * write directly to stderr. This prevents interleaving between the spinner's
6
+ * 80ms animation loop and async thread progress callbacks.
7
7
  */
8
8
  import { getLogLevel, isJsonMode } from "./log.js";
9
9
  import { coral, cyan, dim, green, isTTY, red, stripAnsi, symbols, termWidth, truncate, yellow } from "./theme.js";
10
10
  /**
11
- * Thread dashboard — manages a live-updating display of thread states.
12
- * Renders below the spinner line using ANSI cursor movement.
11
+ * Thread dashboard — manages thread state and builds status lines.
12
+ * Does NOT write to stderr directly the Spinner calls getLines()
13
+ * on each render tick to include dashboard output atomically.
13
14
  */
14
15
  export class ThreadDashboard {
15
16
  threads = new Map();
16
17
  pendingTimers = new Set();
17
- lastLineCount = 0;
18
18
  enabled;
19
19
  constructor() {
20
20
  this.enabled = isTTY && !isJsonMode() && getLogLevel() !== "quiet";
@@ -41,9 +41,9 @@ export class ThreadDashboard {
41
41
  detail,
42
42
  });
43
43
  }
44
- this.render();
44
+ // No direct render — spinner picks up changes on next tick
45
45
  }
46
- /** Remove a thread from the dashboard (when done). */
46
+ /** Mark a thread as done. Auto-removes after 1.5s. */
47
47
  complete(id, phase, detail) {
48
48
  const existing = this.threads.get(id);
49
49
  if (existing) {
@@ -51,47 +51,24 @@ export class ThreadDashboard {
51
51
  if (detail)
52
52
  existing.detail = detail;
53
53
  }
54
- this.render();
55
- // Remove completed/failed threads after a brief display
56
54
  const timer = setTimeout(() => {
57
55
  this.pendingTimers.delete(timer);
58
56
  this.threads.delete(id);
59
- this.render();
60
57
  }, 1500);
61
58
  this.pendingTimers.add(timer);
62
59
  }
63
- /** Clear all dashboard output and cancel pending timers. */
60
+ /** Clear all state and cancel pending timers. */
64
61
  clear() {
65
62
  for (const timer of this.pendingTimers)
66
63
  clearTimeout(timer);
67
64
  this.pendingTimers.clear();
68
- if (!this.enabled)
69
- return;
70
- this.clearLines();
71
65
  this.threads.clear();
72
- this.lastLineCount = 0;
73
66
  }
74
- render() {
75
- if (!this.enabled)
76
- return;
77
- // Clear previous output
78
- this.clearLines();
79
- const lines = this.buildLines();
80
- if (lines.length === 0) {
81
- this.lastLineCount = 0;
82
- return;
83
- }
84
- // Write new lines
85
- process.stderr.write(`${lines.join("\n")}\n`);
86
- this.lastLineCount = lines.length;
87
- }
88
- clearLines() {
89
- if (this.lastLineCount <= 0)
90
- return;
91
- // Move up and clear each line
92
- for (let i = 0; i < this.lastLineCount; i++) {
93
- process.stderr.write("\x1b[1A\x1b[K");
94
- }
67
+ /** Get the current dashboard lines (called by Spinner.render). */
68
+ getLines() {
69
+ if (!this.enabled || this.threads.size === 0)
70
+ return [];
71
+ return this.buildLines();
95
72
  }
96
73
  buildLines() {
97
74
  const w = Math.min(termWidth(), 80);
@@ -115,9 +92,12 @@ export class ThreadDashboard {
115
92
  else {
116
93
  line = ` ${tag} ${phase} ${time} ${dim(task)}`;
117
94
  }
118
- // Truncate to terminal width
119
- if (stripAnsi(line).length > w) {
120
- line = line.slice(0, w + (line.length - stripAnsi(line).length) - 1);
95
+ // ANSI-safe truncation count visible chars, not raw string length
96
+ const visible = stripAnsi(line);
97
+ if (visible.length > w) {
98
+ // Rebuild truncated: just trim the last visible portion
99
+ const ansiOverhead = line.length - visible.length;
100
+ line = `${line.slice(0, w - 1 + ansiOverhead)}\x1b[0m`;
121
101
  }
122
102
  lines.push(line);
123
103
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Run logger — writes structured logs for each swarm run to ~/.swarm/logs/.
3
+ *
4
+ * Each run creates a timestamped JSON log file with:
5
+ * - Task, model, agent, config
6
+ * - Iterations, sub-queries, thread spawns
7
+ * - Timing, token usage, costs
8
+ * - Thread details (task, agent, model, result, duration)
9
+ * - Final answer and completion status
10
+ *
11
+ * Logs are kept for dev iteration — view with `ls ~/.swarm/logs/` or parse as JSON.
12
+ */
13
+ export interface ThreadLogEntry {
14
+ id: string;
15
+ task: string;
16
+ agent: string;
17
+ model: string;
18
+ success: boolean;
19
+ durationMs: number;
20
+ filesChanged: string[];
21
+ error?: string;
22
+ }
23
+ export interface RunLogEntry {
24
+ timestamp: string;
25
+ task: string;
26
+ orchestratorModel: string;
27
+ agent: string;
28
+ dir: string;
29
+ completed: boolean;
30
+ iterations: number;
31
+ maxIterations: number;
32
+ durationMs: number;
33
+ threads: ThreadLogEntry[];
34
+ answer?: string;
35
+ error?: string;
36
+ }
37
+ export declare class RunLogger {
38
+ private entry;
39
+ private threads;
40
+ constructor(task: string, orchestratorModel: string, agent: string, dir: string, maxIterations: number);
41
+ addThread(thread: ThreadLogEntry): void;
42
+ complete(result: {
43
+ completed: boolean;
44
+ iterations: number;
45
+ answer?: string;
46
+ error?: string;
47
+ }, durationMs: number): void;
48
+ /** Write the log file. Call after complete(). */
49
+ save(): string | null;
50
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Run logger — writes structured logs for each swarm run to ~/.swarm/logs/.
3
+ *
4
+ * Each run creates a timestamped JSON log file with:
5
+ * - Task, model, agent, config
6
+ * - Iterations, sub-queries, thread spawns
7
+ * - Timing, token usage, costs
8
+ * - Thread details (task, agent, model, result, duration)
9
+ * - Final answer and completion status
10
+ *
11
+ * Logs are kept for dev iteration — view with `ls ~/.swarm/logs/` or parse as JSON.
12
+ */
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+ const LOG_DIR = path.join(os.homedir(), ".swarm", "logs");
17
+ export class RunLogger {
18
+ entry;
19
+ threads = [];
20
+ constructor(task, orchestratorModel, agent, dir, maxIterations) {
21
+ this.entry = {
22
+ timestamp: new Date().toISOString(),
23
+ task,
24
+ orchestratorModel,
25
+ agent,
26
+ dir,
27
+ completed: false,
28
+ iterations: 0,
29
+ maxIterations,
30
+ durationMs: 0,
31
+ threads: [],
32
+ };
33
+ }
34
+ addThread(thread) {
35
+ this.threads.push(thread);
36
+ }
37
+ complete(result, durationMs) {
38
+ this.entry.completed = result.completed;
39
+ this.entry.iterations = result.iterations;
40
+ this.entry.answer = result.answer;
41
+ this.entry.error = result.error;
42
+ this.entry.durationMs = durationMs;
43
+ this.entry.threads = this.threads;
44
+ }
45
+ /** Write the log file. Call after complete(). */
46
+ save() {
47
+ try {
48
+ fs.mkdirSync(LOG_DIR, { recursive: true });
49
+ const ts = this.entry.timestamp.replace(/[:.]/g, "-").slice(0, 19);
50
+ const filename = `run-${ts}.json`;
51
+ const filepath = path.join(LOG_DIR, filename);
52
+ fs.writeFileSync(filepath, JSON.stringify(this.entry, null, 2), "utf-8");
53
+ return filepath;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ }
60
+ //# sourceMappingURL=run-log.js.map
@@ -3,7 +3,11 @@
3
3
  *
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
+ *
7
+ * Coordinates with ThreadDashboard — the spinner owns the render cycle:
8
+ * spinner line first, then dashboard lines below. This prevents interleaving.
6
9
  */
10
+ import type { ThreadDashboard } from "./dashboard.js";
7
11
  export declare class Spinner {
8
12
  private static _exitHandlerRegistered;
9
13
  private intervalId;
@@ -13,13 +17,20 @@ export declare class Spinner {
13
17
  private detail;
14
18
  private startTime;
15
19
  private running;
20
+ private dashboard;
21
+ /** How many lines we wrote below the spinner (dashboard) */
22
+ private extraLines;
23
+ /** Link a dashboard so the spinner owns its render cycle. */
24
+ setDashboard(d: ThreadDashboard): void;
16
25
  /** Start the spinner. */
17
26
  start(detail?: string): void;
18
27
  /** Update the detail text without restarting. */
19
28
  update(detail: string): void;
20
- /** Stop the spinner and clear the line. */
29
+ /** Stop the spinner and clear everything (spinner + dashboard lines). */
21
30
  stop(): void;
22
31
  /** Whether the spinner is currently active. */
23
32
  get isActive(): boolean;
33
+ /** Called by the dashboard when it wants to re-render. */
34
+ requestDashboardRender(): void;
24
35
  private render;
25
36
  }
@@ -3,8 +3,11 @@
3
3
  *
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
+ *
7
+ * Coordinates with ThreadDashboard — the spinner owns the render cycle:
8
+ * spinner line first, then dashboard lines below. This prevents interleaving.
6
9
  */
7
- import { coral, dim, isTTY, stripAnsi, termWidth } from "./theme.js";
10
+ import { coral, dim, isTTY, termWidth } from "./theme.js";
8
11
  /** Playful verbs shown while the spinner is active. */
9
12
  const VERBS = [
10
13
  "Orchestrating",
@@ -46,6 +49,13 @@ export class Spinner {
46
49
  detail = "";
47
50
  startTime = 0;
48
51
  running = false;
52
+ dashboard = null;
53
+ /** How many lines we wrote below the spinner (dashboard) */
54
+ extraLines = 0;
55
+ /** Link a dashboard so the spinner owns its render cycle. */
56
+ setDashboard(d) {
57
+ this.dashboard = d;
58
+ }
49
59
  /** Start the spinner. */
50
60
  start(detail) {
51
61
  if (!isTTY || this.running)
@@ -55,6 +65,7 @@ export class Spinner {
55
65
  this.startTime = Date.now();
56
66
  this.frameIdx = 0;
57
67
  this.totalFrames = 0;
68
+ this.extraLines = 0;
58
69
  this.verbIdx = Math.floor(Math.random() * VERBS.length);
59
70
  // Hide cursor + register exit handler to restore it
60
71
  process.stderr.write("\x1b[?25l");
@@ -80,7 +91,7 @@ export class Spinner {
80
91
  update(detail) {
81
92
  this.detail = detail;
82
93
  }
83
- /** Stop the spinner and clear the line. */
94
+ /** Stop the spinner and clear everything (spinner + dashboard lines). */
84
95
  stop() {
85
96
  if (!this.running)
86
97
  return;
@@ -89,25 +100,64 @@ export class Spinner {
89
100
  clearInterval(this.intervalId);
90
101
  this.intervalId = null;
91
102
  }
92
- // Clear spinner line and show cursor
93
- process.stderr.write("\r\x1b[K\x1b[?25h");
103
+ // Clear spinner line + all dashboard lines below using "erase to end of screen"
104
+ process.stderr.write(`\r\x1b[K\x1b[J\x1b[?25h`);
105
+ this.extraLines = 0;
94
106
  }
95
107
  /** Whether the spinner is currently active. */
96
108
  get isActive() {
97
109
  return this.running;
98
110
  }
111
+ /** Called by the dashboard when it wants to re-render. */
112
+ requestDashboardRender() {
113
+ // Dashboard render happens on next spinner tick — no immediate write.
114
+ // This prevents interleaving.
115
+ }
99
116
  render() {
100
117
  const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
101
118
  const char = coral(SPINNER_CHARS[this.frameIdx]);
102
119
  const verb = VERBS[this.verbIdx];
103
120
  const time = dim(`${elapsed}s`);
104
- const detail = this.detail ? dim(` ${this.detail}`) : "";
105
- const line = ` ${char} ${verb}...${detail} ${time}`;
106
121
  const maxW = termWidth();
107
- const stripped = stripAnsi(line);
108
- // Truncate if wider than terminal
109
- const output = stripped.length > maxW ? line.slice(0, maxW - 1) : line;
110
- process.stderr.write(`\r\x1b[K${output}`);
122
+ // Build detail string, truncating if needed (ANSI-safe)
123
+ let detailStr = "";
124
+ if (this.detail) {
125
+ const prefix = ` X ${verb}... ${elapsed}s`;
126
+ const available = maxW - prefix.length - 1;
127
+ if (available > 5) {
128
+ const raw = this.detail;
129
+ detailStr = raw.length > available ? dim(` ${raw.slice(0, available - 2)}\u2026`) : dim(` ${raw}`);
130
+ }
131
+ }
132
+ const line = ` ${char} ${verb}...${detailStr} ${time}`;
133
+ // Clear previous extra lines (dashboard) — move up from current position
134
+ // Current cursor is on spinner line after last render
135
+ if (this.extraLines > 0) {
136
+ // Move down to the last dashboard line, then clear upward
137
+ process.stderr.write(`\x1b[${this.extraLines}B`); // move down past dashboard
138
+ for (let i = 0; i < this.extraLines; i++) {
139
+ process.stderr.write("\x1b[1A\x1b[2K"); // move up + clear line
140
+ }
141
+ }
142
+ // Write spinner line (we're on the spinner line now)
143
+ 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) {
148
+ process.stderr.write("\n");
149
+ process.stderr.write(dashLines.join("\n"));
150
+ this.extraLines = dashLines.length;
151
+ // Move cursor back up to the spinner line
152
+ if (this.extraLines > 0) {
153
+ process.stderr.write(`\x1b[${this.extraLines}A`);
154
+ }
155
+ process.stderr.write("\r");
156
+ }
157
+ else {
158
+ this.extraLines = 0;
159
+ }
160
+ }
111
161
  }
112
162
  }
113
163
  //# sourceMappingURL=spinner.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swarm-code",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
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": {