Skip to content

Commit f13edc4

Browse files
that-github-userunknownclaude
authored
Show live per-agent progress during parallel runs (#128)
Display live status updates as agents complete. TTY mode: per-line updates with carriage returns. Non-TTY: simple "N/M complete" counter. Created progress.ts utility module. 11 new tests. Generated by thinktank Opus (5 agents, ALL pass, perfect tie). Closes #57 Co-authored-by: unknown <that-github-user@github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c6564ab commit f13edc4

File tree

3 files changed

+295
-27
lines changed

3 files changed

+295
-27
lines changed

src/commands/run.ts

Lines changed: 66 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
getRepoRoot,
1616
removeWorktree,
1717
} from "../utils/git.js";
18+
import { createProgressTracker } from "../utils/progress.js";
1819

1920
const execFileAsync = promisify(execFile);
2021

@@ -213,24 +214,43 @@ export async function retry(opts: RunOptions): Promise<void> {
213214
console.log(` Re-running ${failed.length} agent(s) in parallel (${runner.name})...`);
214215
console.log();
215216

217+
const showProgress = opts.outputFormat === "text";
218+
const tracker = showProgress
219+
? createProgressTracker(
220+
worktrees.map((w) => w.id),
221+
Boolean(process.stdout.isTTY),
222+
)
223+
: null;
224+
225+
tracker?.start();
226+
216227
const agentPromises = worktrees.map(({ id, path }) =>
217-
runner.run(id, {
218-
prompt: previous.prompt,
219-
worktreePath: path,
220-
model: previous.model,
221-
timeout: opts.timeout,
222-
verbose: opts.verbose,
223-
}),
228+
runner
229+
.run(id, {
230+
prompt: previous.prompt,
231+
worktreePath: path,
232+
model: previous.model,
233+
timeout: opts.timeout,
234+
verbose: opts.verbose,
235+
})
236+
.then((result) => {
237+
tracker?.onAgentComplete(result);
238+
return result;
239+
}),
224240
);
225241

226242
const retriedAgents: AgentResult[] = await Promise.all(agentPromises);
227243

228-
for (const agent of retriedAgents) {
229-
const icon = agent.status === "success" ? "✓" : agent.status === "timeout" ? "⏱" : "✗";
230-
const files = agent.filesChanged.length;
231-
console.log(
232-
` Agent #${agent.id}: ${icon} ${agent.status}${files} files changed in ${Math.round(agent.duration / 1000)}s`,
233-
);
244+
tracker?.finish();
245+
246+
if (!showProgress || !process.stdout.isTTY) {
247+
for (const agent of retriedAgents) {
248+
const icon = agent.status === "success" ? "✓" : agent.status === "timeout" ? "⏱" : "✗";
249+
const files = agent.filesChanged.length;
250+
console.log(
251+
` Agent #${agent.id}: ${icon} ${agent.status}${files} files changed in ${Math.round(agent.duration / 1000)}s`,
252+
);
253+
}
234254
}
235255
console.log();
236256

@@ -400,25 +420,44 @@ export async function run(opts: RunOptions): Promise<void> {
400420
console.log(` Running ${opts.attempts} agents in parallel (${runner.name})...`);
401421
console.log();
402422

423+
const showProgress = opts.outputFormat === "text";
424+
const tracker = showProgress
425+
? createProgressTracker(
426+
worktrees.map((w) => w.id),
427+
Boolean(process.stdout.isTTY),
428+
)
429+
: null;
430+
431+
tracker?.start();
432+
403433
const agentPromises = worktrees.map(({ id, path }) =>
404-
runner.run(id, {
405-
prompt: opts.prompt,
406-
worktreePath: path,
407-
model: opts.model,
408-
timeout: opts.timeout,
409-
verbose: opts.verbose,
410-
}),
434+
runner
435+
.run(id, {
436+
prompt: opts.prompt,
437+
worktreePath: path,
438+
model: opts.model,
439+
timeout: opts.timeout,
440+
verbose: opts.verbose,
441+
})
442+
.then((result) => {
443+
tracker?.onAgentComplete(result);
444+
return result;
445+
}),
411446
);
412447

413448
const agents: AgentResult[] = await Promise.all(agentPromises);
414449

415-
// Report completion
416-
for (const agent of agents) {
417-
const icon = agent.status === "success" ? "✓" : agent.status === "timeout" ? "⏱" : "✗";
418-
const files = agent.filesChanged.length;
419-
console.log(
420-
` Agent #${agent.id}: ${icon} ${agent.status}${files} files changed in ${Math.round(agent.duration / 1000)}s`,
421-
);
450+
tracker?.finish();
451+
452+
// Report completion (skip in TTY mode — progress tracker already showed status)
453+
if (!showProgress || !process.stdout.isTTY) {
454+
for (const agent of agents) {
455+
const icon = agent.status === "success" ? "✓" : agent.status === "timeout" ? "⏱" : "✗";
456+
const files = agent.filesChanged.length;
457+
console.log(
458+
` Agent #${agent.id}: ${icon} ${agent.status}${files} files changed in ${Math.round(agent.duration / 1000)}s`,
459+
);
460+
}
422461
}
423462
console.log();
424463

src/utils/progress.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import assert from "node:assert/strict";
2+
import { describe, it } from "node:test";
3+
import type { AgentResult } from "../types.js";
4+
import { createProgressTracker } from "./progress.js";
5+
6+
function makeAgent(overrides: Partial<AgentResult> = {}): AgentResult {
7+
return {
8+
id: 1,
9+
worktree: "/tmp/thinktank-agent-1",
10+
status: "success",
11+
exitCode: 0,
12+
duration: 45000,
13+
output: "",
14+
diff: "diff --git a/file.ts b/file.ts\n+added line",
15+
filesChanged: ["file.ts", "other.ts"],
16+
linesAdded: 2,
17+
linesRemoved: 0,
18+
...overrides,
19+
};
20+
}
21+
22+
function createMockStream(): { write(s: string): boolean; output: string[] } {
23+
const output: string[] = [];
24+
return {
25+
write(s: string) {
26+
output.push(s);
27+
return true;
28+
},
29+
output,
30+
};
31+
}
32+
33+
describe("createProgressTracker — TTY mode", () => {
34+
it("writes a running line for each agent on start", () => {
35+
const stream = createMockStream();
36+
const tracker = createProgressTracker([1, 2, 3], true, stream);
37+
tracker.start();
38+
39+
assert.equal(stream.output.length, 3);
40+
assert.ok(stream.output[0].includes("Agent #1: running..."));
41+
assert.ok(stream.output[1].includes("Agent #2: running..."));
42+
assert.ok(stream.output[2].includes("Agent #3: running..."));
43+
});
44+
45+
it("updates the correct line when an agent completes", () => {
46+
const stream = createMockStream();
47+
const tracker = createProgressTracker([1, 2, 3], true, stream);
48+
tracker.start();
49+
50+
const agent = makeAgent({ id: 2, duration: 45000, filesChanged: ["a.ts", "b.ts"] });
51+
tracker.onAgentComplete(agent);
52+
53+
// Should have ANSI escape to move up and rewrite
54+
const updateOutput = stream.output.slice(3).join("");
55+
assert.ok(updateOutput.includes("Agent #2: done (45s, 2 files)"));
56+
});
57+
58+
it("shows singular 'file' for 1 file changed", () => {
59+
const stream = createMockStream();
60+
const tracker = createProgressTracker([1], true, stream);
61+
tracker.start();
62+
63+
const agent = makeAgent({ id: 1, duration: 10000, filesChanged: ["a.ts"] });
64+
tracker.onAgentComplete(agent);
65+
66+
const updateOutput = stream.output.slice(1).join("");
67+
assert.ok(updateOutput.includes("1 file)"), `Expected singular 'file' in: ${updateOutput}`);
68+
});
69+
70+
it("shows status for failed agents", () => {
71+
const stream = createMockStream();
72+
const tracker = createProgressTracker([1], true, stream);
73+
tracker.start();
74+
75+
const agent = makeAgent({ id: 1, status: "error", duration: 5000 });
76+
tracker.onAgentComplete(agent);
77+
78+
const updateOutput = stream.output.slice(1).join("");
79+
assert.ok(updateOutput.includes("error (5s)"));
80+
});
81+
82+
it("shows status for timed-out agents", () => {
83+
const stream = createMockStream();
84+
const tracker = createProgressTracker([1], true, stream);
85+
tracker.start();
86+
87+
const agent = makeAgent({ id: 1, status: "timeout", duration: 300000 });
88+
tracker.onAgentComplete(agent);
89+
90+
const updateOutput = stream.output.slice(1).join("");
91+
assert.ok(updateOutput.includes("timeout (300s)"));
92+
});
93+
94+
it("handles all agents completing", () => {
95+
const stream = createMockStream();
96+
const tracker = createProgressTracker([1, 2], true, stream);
97+
tracker.start();
98+
99+
tracker.onAgentComplete(makeAgent({ id: 1, duration: 30000, filesChanged: ["a.ts"] }));
100+
tracker.onAgentComplete(makeAgent({ id: 2, duration: 40000, filesChanged: ["b.ts", "c.ts"] }));
101+
tracker.finish();
102+
103+
const all = stream.output.join("");
104+
assert.ok(all.includes("Agent #1: done (30s, 1 file)"));
105+
assert.ok(all.includes("Agent #2: done (40s, 2 files)"));
106+
});
107+
});
108+
109+
describe("createProgressTracker — non-TTY mode", () => {
110+
it("does not write anything on start", () => {
111+
const stream = createMockStream();
112+
const tracker = createProgressTracker([1, 2, 3], false, stream);
113+
tracker.start();
114+
115+
assert.equal(stream.output.length, 0);
116+
});
117+
118+
it("writes progress count on each completion", () => {
119+
const stream = createMockStream();
120+
const tracker = createProgressTracker([1, 2, 3], false, stream);
121+
tracker.start();
122+
123+
tracker.onAgentComplete(makeAgent({ id: 1 }));
124+
assert.ok(stream.output[0].includes("1/3 agents complete..."));
125+
126+
tracker.onAgentComplete(makeAgent({ id: 2 }));
127+
assert.ok(stream.output[1].includes("2/3 agents complete..."));
128+
129+
tracker.onAgentComplete(makeAgent({ id: 3 }));
130+
assert.ok(stream.output[2].includes("3/3 agents complete..."));
131+
});
132+
133+
it("writes a trailing newline on finish", () => {
134+
const stream = createMockStream();
135+
const tracker = createProgressTracker([1, 2], false, stream);
136+
tracker.start();
137+
138+
tracker.onAgentComplete(makeAgent({ id: 1 }));
139+
tracker.onAgentComplete(makeAgent({ id: 2 }));
140+
tracker.finish();
141+
142+
const last = stream.output[stream.output.length - 1];
143+
assert.equal(last, "\n");
144+
});
145+
146+
it("does not write trailing newline if no agents completed", () => {
147+
const stream = createMockStream();
148+
const tracker = createProgressTracker([1, 2], false, stream);
149+
tracker.start();
150+
tracker.finish();
151+
152+
assert.equal(stream.output.length, 0);
153+
});
154+
155+
it("uses carriage return for in-place updates", () => {
156+
const stream = createMockStream();
157+
const tracker = createProgressTracker([1, 2], false, stream);
158+
tracker.start();
159+
160+
tracker.onAgentComplete(makeAgent({ id: 1 }));
161+
assert.ok(stream.output[0].startsWith("\r"));
162+
});
163+
});

src/utils/progress.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { AgentResult } from "../types.js";
2+
3+
export interface ProgressTracker {
4+
start(): void;
5+
onAgentComplete(agent: AgentResult): void;
6+
finish(): void;
7+
}
8+
9+
/**
10+
* Create a live progress tracker for parallel agent runs.
11+
*
12+
* TTY mode: writes one line per agent, updates in-place via ANSI cursor movement.
13+
* Non-TTY mode: writes a single "X/N agents complete..." line on each completion.
14+
*/
15+
export function createProgressTracker(
16+
agentIds: number[],
17+
isTTY: boolean,
18+
stream: { write(s: string): boolean } = process.stdout,
19+
): ProgressTracker {
20+
const total = agentIds.length;
21+
let completed = 0;
22+
23+
if (isTTY) {
24+
return {
25+
start() {
26+
for (const id of agentIds) {
27+
stream.write(` Agent #${id}: running...\n`);
28+
}
29+
},
30+
onAgentComplete(agent: AgentResult) {
31+
completed++;
32+
const index = agentIds.indexOf(agent.id);
33+
const linesUp = total - index;
34+
const secs = Math.round(agent.duration / 1000);
35+
const files = agent.filesChanged.length;
36+
const label =
37+
agent.status === "success"
38+
? `done (${secs}s, ${files} file${files !== 1 ? "s" : ""})`
39+
: `${agent.status} (${secs}s)`;
40+
41+
// Move cursor up to the agent's line, overwrite, move back down
42+
stream.write(`\x1b[${linesUp}A\r Agent #${agent.id}: ${label}\x1b[K\n`);
43+
if (linesUp > 1) {
44+
stream.write(`\x1b[${linesUp - 1}B`);
45+
}
46+
},
47+
finish() {
48+
// Cursor is already on the line after the last agent — nothing to do
49+
},
50+
};
51+
}
52+
53+
// Non-TTY: simple one-liner
54+
return {
55+
start() {},
56+
onAgentComplete() {
57+
completed++;
58+
stream.write(`\r ${completed}/${total} agents complete...`);
59+
},
60+
finish() {
61+
if (completed > 0) {
62+
stream.write("\n");
63+
}
64+
},
65+
};
66+
}

0 commit comments

Comments
 (0)