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.
- package/dist/agents/opencode.js +43 -3
- package/dist/interactive-swarm.js +9 -0
- package/dist/swarm.js +16 -2
- package/dist/threads/manager.d.ts +4 -0
- package/dist/threads/manager.js +6 -0
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.js +1 -0
- package/dist/ui/spinner.d.ts +7 -2
- package/dist/ui/spinner.js +19 -10
- package/dist/ui/streaming-feed.d.ts +72 -0
- package/dist/ui/streaming-feed.js +382 -0
- package/package.json +1 -1
package/dist/agents/opencode.js
CHANGED
|
@@ -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>;
|
package/dist/threads/manager.js
CHANGED
|
@@ -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 {
|
package/dist/ui/index.d.ts
CHANGED
package/dist/ui/index.js
CHANGED
package/dist/ui/spinner.d.ts
CHANGED
|
@@ -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
|
|
8
|
-
* spinner line first, then
|
|
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. */
|
package/dist/ui/spinner.js
CHANGED
|
@@ -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
|
|
8
|
-
* spinner line first, then
|
|
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 (
|
|
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 =
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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(
|
|
150
|
-
this.extraLines =
|
|
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