swarm-code 0.1.22 → 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/core/rlm.js +7 -3
- package/dist/interactive-swarm.js +29 -1
- package/dist/swarm.js +16 -2
- package/dist/threads/manager.d.ts +6 -1
- package/dist/threads/manager.js +10 -3
- 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
|
}
|
package/dist/core/rlm.js
CHANGED
|
@@ -171,6 +171,7 @@ export async function runRlmLoop(options) {
|
|
|
171
171
|
const { context, query, model, repl, signal, onProgress, onSubQueryStart, onSubQuery, systemPrompt, threadHandler, mergeHandler, } = options;
|
|
172
172
|
let totalSubQueries = 0;
|
|
173
173
|
let iterationSubQueries = 0;
|
|
174
|
+
let mergedAlready = false;
|
|
174
175
|
const llmQueryHandler = async (subContext, instruction) => {
|
|
175
176
|
if (signal?.aborted)
|
|
176
177
|
throw new Error("Aborted");
|
|
@@ -217,7 +218,10 @@ export async function runRlmLoop(options) {
|
|
|
217
218
|
repl.setThreadHandler(threadHandler);
|
|
218
219
|
}
|
|
219
220
|
if (mergeHandler) {
|
|
220
|
-
repl.setMergeHandler(
|
|
221
|
+
repl.setMergeHandler(async () => {
|
|
222
|
+
mergedAlready = true;
|
|
223
|
+
return mergeHandler();
|
|
224
|
+
});
|
|
221
225
|
}
|
|
222
226
|
}
|
|
223
227
|
await initRepl();
|
|
@@ -374,7 +378,7 @@ export async function runRlmLoop(options) {
|
|
|
374
378
|
});
|
|
375
379
|
if (execResult.hasFinal && execResult.finalValue !== null) {
|
|
376
380
|
// Auto-merge any unmerged thread branches before returning
|
|
377
|
-
if (mergeHandler) {
|
|
381
|
+
if (mergeHandler && !mergedAlready) {
|
|
378
382
|
try {
|
|
379
383
|
await mergeHandler();
|
|
380
384
|
}
|
|
@@ -422,7 +426,7 @@ export async function runRlmLoop(options) {
|
|
|
422
426
|
});
|
|
423
427
|
}
|
|
424
428
|
// Auto-merge any remaining thread branches even though FINAL was never called
|
|
425
|
-
if (mergeHandler) {
|
|
429
|
+
if (mergeHandler && !mergedAlready) {
|
|
426
430
|
try {
|
|
427
431
|
await mergeHandler();
|
|
428
432
|
}
|
|
@@ -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) {
|
|
@@ -757,8 +758,11 @@ export async function runInteractiveSwarm(rawArgs) {
|
|
|
757
758
|
logError(`Directory "${args.dir}" does not exist`);
|
|
758
759
|
process.exit(1);
|
|
759
760
|
}
|
|
760
|
-
// First-run onboarding
|
|
761
|
+
// First-run onboarding (may create ~/.swarm/config.yaml with user's chosen model)
|
|
761
762
|
await runOnboarding();
|
|
763
|
+
// Reload config to pick up any changes from onboarding
|
|
764
|
+
// (onboarding writes ~/.swarm/config.yaml but loadConfig() already ran before it)
|
|
765
|
+
Object.assign(config, loadConfig());
|
|
762
766
|
// Override config with CLI args
|
|
763
767
|
if (args.agent)
|
|
764
768
|
config.default_agent = args.agent;
|
|
@@ -805,13 +809,17 @@ export async function runInteractiveSwarm(rawArgs) {
|
|
|
805
809
|
const repl = new PythonRepl();
|
|
806
810
|
const sessionAc = new AbortController();
|
|
807
811
|
const dashboard = new ThreadDashboard();
|
|
812
|
+
const streamingFeed = new StreamingFeed();
|
|
808
813
|
spinner.setDashboard(dashboard);
|
|
814
|
+
spinner.setStreamingFeed(streamingFeed);
|
|
809
815
|
const threadProgress = (threadId, phase, detail) => {
|
|
810
816
|
if (phase === "completed" || phase === "failed" || phase === "cancelled") {
|
|
811
817
|
dashboard.complete(threadId, phase, detail);
|
|
818
|
+
streamingFeed.completeThread(threadId, phase, detail);
|
|
812
819
|
}
|
|
813
820
|
else {
|
|
814
821
|
dashboard.update(threadId, phase, detail);
|
|
822
|
+
streamingFeed.updateThread(threadId, phase, detail);
|
|
815
823
|
}
|
|
816
824
|
};
|
|
817
825
|
// Enable OpenCode server mode
|
|
@@ -821,6 +829,9 @@ export async function runInteractiveSwarm(rawArgs) {
|
|
|
821
829
|
}
|
|
822
830
|
// Initialize thread manager
|
|
823
831
|
const threadManager = new ThreadManager(args.dir, config, threadProgress, sessionAc.signal);
|
|
832
|
+
threadManager.setThreadOutputCallback((threadId, chunk) => {
|
|
833
|
+
streamingFeed.appendOutput(threadId, chunk);
|
|
834
|
+
});
|
|
824
835
|
await threadManager.init();
|
|
825
836
|
if (episodicMemory) {
|
|
826
837
|
threadManager.setEpisodicMemory(episodicMemory);
|
|
@@ -870,6 +881,7 @@ export async function runInteractiveSwarm(rawArgs) {
|
|
|
870
881
|
cleanupCalled = true;
|
|
871
882
|
spinner.stop();
|
|
872
883
|
dashboard.clear();
|
|
884
|
+
streamingFeed.clear();
|
|
873
885
|
sessionAc.abort();
|
|
874
886
|
repl.shutdown();
|
|
875
887
|
await threadManager.cleanup();
|
|
@@ -960,6 +972,22 @@ export async function runInteractiveSwarm(rawArgs) {
|
|
|
960
972
|
else if (merged > 0) {
|
|
961
973
|
logSuccess(`Merged ${merged} branches`);
|
|
962
974
|
}
|
|
975
|
+
// Clean up merged worktrees
|
|
976
|
+
if (config.auto_cleanup_worktrees) {
|
|
977
|
+
for (const r of results) {
|
|
978
|
+
if (r.success) {
|
|
979
|
+
const thread = threads.find((t) => t.branchName === r.branch);
|
|
980
|
+
if (thread) {
|
|
981
|
+
try {
|
|
982
|
+
await threadManager.destroyWorktree(thread.id);
|
|
983
|
+
}
|
|
984
|
+
catch {
|
|
985
|
+
// Non-fatal
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
963
991
|
const summary = results
|
|
964
992
|
.map((r) => (r.success ? `Merged ${r.branch}: ${r.message}` : `FAILED ${r.branch}: ${r.message}`))
|
|
965
993
|
.join("\n");
|
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>;
|
|
@@ -80,6 +84,7 @@ export declare class ThreadManager {
|
|
|
80
84
|
};
|
|
81
85
|
/** Cleanup all worktrees. */
|
|
82
86
|
cleanup(): Promise<void>;
|
|
83
|
-
|
|
87
|
+
/** Destroy a specific thread's worktree and branch. */
|
|
88
|
+
destroyWorktree(threadId: string): Promise<void>;
|
|
84
89
|
private failResult;
|
|
85
90
|
}
|
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 {
|
|
@@ -486,7 +492,7 @@ export class ThreadManager {
|
|
|
486
492
|
state.status = "cancelled";
|
|
487
493
|
state.phase = "cancelled";
|
|
488
494
|
state.completedAt = Date.now();
|
|
489
|
-
await this.
|
|
495
|
+
await this.destroyWorktree(threadId);
|
|
490
496
|
return this.failResult(state, "Thread cancelled during execution");
|
|
491
497
|
}
|
|
492
498
|
// Capture diff
|
|
@@ -570,7 +576,7 @@ export class ThreadManager {
|
|
|
570
576
|
const { cost } = this.budget.recordCost(threadId, errModel);
|
|
571
577
|
state.estimatedCostUsd = cost;
|
|
572
578
|
// Cleanup worktree on failure
|
|
573
|
-
await this.
|
|
579
|
+
await this.destroyWorktree(threadId);
|
|
574
580
|
return this.failResult(state, errorMsg);
|
|
575
581
|
}
|
|
576
582
|
finally {
|
|
@@ -637,7 +643,8 @@ export class ThreadManager {
|
|
|
637
643
|
await this.worktreeManager.destroyAll();
|
|
638
644
|
}
|
|
639
645
|
}
|
|
640
|
-
|
|
646
|
+
/** Destroy a specific thread's worktree and branch. */
|
|
647
|
+
async destroyWorktree(threadId) {
|
|
641
648
|
try {
|
|
642
649
|
await this.worktreeManager.destroy(threadId, true);
|
|
643
650
|
}
|
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