agent-horde 0.1.5 → 0.2.0

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.
@@ -3,15 +3,15 @@ import * as path from "node:path";
3
3
  import { listFindingsForRun } from "../ledger/findings.js";
4
4
  import { requireParam, requireText } from "../ledger/files.js";
5
5
  import { collaborationState, openMessages, projectCollaboration } from "../ledger/collaboration.js";
6
- import type { CollaborationState, PeerMessage } from "../ledger/collaboration.js";
7
- import { transactEvent } from "../ledger/events.js";
6
+ import type { ArtifactRecord, CollaborationState, PeerMessage } from "../ledger/collaboration.js";
7
+ import { readEvents, transactEvent } from "../ledger/events.js";
8
8
  import type { EventContext, EventDraft } from "../ledger/events.js";
9
9
  import { currentTarget, targetInfo, retainArtifact, hashText } from "./collaboration-target.js";
10
10
  import { collaborationView, inspectRecord } from "./collaboration-view.js";
11
11
  import { resourceTransition } from "./resource-contract.js";
12
12
  import type { HordeToolParams } from "./params.js";
13
13
 
14
- export const COLLABORATION_ACTIONS = new Set(["inbox", "message", "ack", "respond", "resolve", "defer", "attach", "review", "artifact", "resource-state", "collaboration", "inspect"]);
14
+ export const COLLABORATION_ACTIONS = new Set(["inbox", "message", "ack", "respond", "resolve", "defer", "attach", "review", "reattest", "artifact", "resource-state", "collaboration", "inspect"]);
15
15
  interface CollaborationCommand {
16
16
  ctx: EventContext;
17
17
  state: CollaborationState;
@@ -144,9 +144,13 @@ function attachEvidence(c: CollaborationCommand): EventDraft {
144
144
  if (p.revision && p.revision !== info.revision) throw new Error("horde: target revision changed");
145
145
  const file = p.artifactPath ? retainArtifact(ctx, p.artifactPath) : {};
146
146
  const text = requireText(p.text, "attach");
147
+ // Contradicting evidence on an idea is how a lead dies; the record that outlives the run is a rule-out.
148
+ const ruleOutHint = p.relation === "contradicts" && /^I\d+$/.test(target)
149
+ ? { note: `${target} has contradicting evidence: a rule-out candidate. If this closes the lead, its owner (${info.owner}) should 'horde rule-out' it with this evidence as the reason so later runs do not retry it.` }
150
+ : {};
147
151
  return { payload: { evidenceRecord: { id: `E${seq}`, actor: ctx.actor, target, revision: info.revision,
148
152
  relation: p.relation ?? "extends", text, ...file },
149
- ...(p.relation === "contradicts" ? automaticChallenge(c, target, info, text, [`E${seq}`]) : {}) } };
153
+ ...(p.relation === "contradicts" ? automaticChallenge(c, target, info, text, [`E${seq}`]) : {}), ...ruleOutHint } };
150
154
  }
151
155
 
152
156
  function publishArtifact(c: CollaborationCommand): EventDraft {
@@ -167,8 +171,15 @@ function publishArtifact(c: CollaborationCommand): EventDraft {
167
171
  if (existing && existing.hash === retained.hash && (existing.criteria !== criteria || JSON.stringify(existing.dependencies) !== JSON.stringify(dependencies))) {
168
172
  throw new Error("horde: changing criteria or dependencies requires a new file revision so prior reviews cannot transfer to a different contract");
169
173
  }
174
+ // A revision other artifacts pin is a frozen contract: re-hashing it goes stale for every
175
+ // dependent, so the change is announced, and reviewers can reattest when behaviour is unchanged.
176
+ const dependents = existing ? state.artifacts.filter((a) => a.id !== existing.id && a.dependencies.some((d) => d.target === existing.id)).map((a) => a.id) : [];
177
+ if (existing && existing.hash !== retained.hash && dependents.length && !p.text?.trim()) {
178
+ throw new Error(`horde: ${existing.id}@${existing.hash.slice(0, 12)} is pinned by ${dependents.join(", ")}; prefer a follow-up finding or artifact, or republish with text= naming the change and whether behaviour is unchanged so dependents can reattest`);
179
+ }
180
+ const changeNote = existing && existing.hash !== retained.hash && p.text?.trim() ? { changeNote: p.text.trim() } : {};
170
181
  return { payload: { artifact: { id: existing?.id ?? `A${seq}`, kind, owner: ctx.actor, ...retained,
171
- criteria, dependencies } }, parents: evidenceRefs(state, p.refs) };
182
+ criteria, dependencies, ...changeNote }, ...(dependents.length ? { dependents } : {}) }, parents: evidenceRefs(state, p.refs) };
172
183
  }
173
184
 
174
185
  function recordReview(c: CollaborationCommand): EventDraft {
@@ -192,33 +203,71 @@ function recordReview(c: CollaborationCommand): EventDraft {
192
203
  text: requireParam(p.evidenceText, "evidenceText", p.action), ...(p.artifactPath ? retainArtifact(ctx, p.artifactPath) : {}),
193
204
  };
194
205
  if (evidenceRecord) refs.push(evidenceRecord.id);
206
+ const limits = p.limits?.trim();
207
+ // A partial verdict refutes nothing: its unchecked scope is coverage debt that the acceptance
208
+ // names and a full review settles. Only a refutation is an objection the owner must answer.
195
209
  return { payload: { ...(evidenceRecord ? { evidenceRecord } : {}), review: { id: `V${seq}`, actor: ctx.actor, target, revision: info.revision,
196
- verdict: p.verdict, support: p.support, checked, unchecked: p.unchecked,
210
+ verdict: p.verdict, support: p.support, checked, unchecked: p.unchecked, ...(limits ? { limits } : {}),
197
211
  text, evidence: refs },
198
212
  ...completeAssignedReview(c, target, `V${seq}`, text, refs),
199
- ...(["refutes", "partially-supports"].includes(p.verdict) ? automaticChallenge(c, target, info, text, refs) : {}),
200
- ...(p.verdict === "supports" && !p.unchecked.trim() ? coverageClearance(c, target, info, `V${seq}`, refs) : {}) }, parents: refs.filter((id) => id !== evidenceRecord?.id) };
201
- }
202
-
203
- /** The challenge a partially-supports review raised in its own event, as opposed to an objection a member wrote. */
204
- function coverageChallenge(state: CollaborationState, message: PeerMessage): boolean {
205
- return state.reviews.some((r) => r.id === `V${message.id.slice(1)}` && r.actor === message.sender && r.verdict === "partially-supports");
213
+ ...(p.verdict === "refutes" ? automaticChallenge(c, target, info, text, refs) : {}) }, parents: refs.filter((id) => id !== evidenceRecord?.id) };
206
214
  }
207
215
 
208
216
  /**
209
- * A partial review opens a blocking challenge that only its sender may resolve. When that review
210
- * contradicted nothing and another member later supports the whole revision, the unchecked scope
211
- * is covered: the full review clears the challenge, and the resolution names both reviews.
217
+ * A behaviour-neutral re-hash (a republish against a corrected dependency, an amended finding
218
+ * body, a spec clarification) would otherwise drop every review of the earlier revision. The
219
+ * reviewer carries its own latest review forward with evidence that what it checked is unchanged.
220
+ */
221
+ /**
222
+ * What changed between the reviewed revision and the current one, as the ledger recorded it. A
223
+ * review carries forward only across prose: a new acceptance contract or a new claim line is a
224
+ * different subject and needs a fresh review.
212
225
  */
213
- function coverageClearance(c: CollaborationCommand, target: string, info: { revision: string }, reviewId: string, refs: string[]): Record<string, unknown> {
214
- const cleared = c.state.messages.filter((m) =>
215
- m.target === target && m.revision === info.revision && m.blocking && m.state !== "resolved" && m.sender !== c.ctx.actor && coverageChallenge(c.state, m));
216
- if (!cleared.length) return {};
217
- return { clearances: cleared.map((m) => ({
218
- id: m.id,
219
- text: `cleared by ${reviewId}: ${c.ctx.actor}'s full-scope supporting review of ${target}@${info.revision.slice(0, 12)} covers the scope V${m.id.slice(1)} left unchecked`,
220
- refs,
221
- })) };
226
+ function reattestBlocker(ctx: EventContext, state: CollaborationState, target: string, previousRevision: string): string | undefined {
227
+ const events = readEvents(ctx.hordeDir, ctx.runId).filter((e) => e.outcome === "applied");
228
+ if (target.startsWith("A")) {
229
+ const current = state.artifacts.find((a) => a.id === target);
230
+ const earlier = events.map((e) => e.payload.artifact as ArtifactRecord | undefined).find((a) => a?.id === target && a.hash === previousRevision);
231
+ if (!current || !earlier) return `the reviewed revision ${previousRevision.slice(0, 12)} of ${target} is not on record`;
232
+ if (earlier.criteria !== current.criteria) return `${target}'s criteria changed since the reviewed revision; review the new contract`;
233
+ if ((earlier.kind ?? "runnable") !== (current.kind ?? "runnable")) return `${target}'s kind changed since the reviewed revision`;
234
+ return undefined;
235
+ }
236
+ const amends = events.filter((e) => e.action === "amend").map((e) => e.payload.result as { id?: number; previousRevision?: string; titleChanged?: boolean } | undefined);
237
+ const start = amends.findIndex((a) => a?.id === Number(target.slice(1)) && a.previousRevision === previousRevision);
238
+ if (start === -1) return `the reviewed revision ${previousRevision.slice(0, 12)} of ${target} is not the revision an amend started from; review the current text`;
239
+ if (amends.slice(start).some((a) => a?.id === Number(target.slice(1)) && a.titleChanged)) return `${target}'s claim line changed since the reviewed revision; review the new claim`;
240
+ return undefined;
241
+ }
242
+
243
+ function reattestReview(c: CollaborationCommand): EventDraft {
244
+ const { ctx, state, params: p, seq } = c;
245
+ const target = requireParam(p.target, "target", p.action);
246
+ if (!/^[FA]\d+$/.test(target)) throw new Error("horde: reattest targets an F# or A#");
247
+ const info = targetInfo(ctx, state, target);
248
+ if (info.owner === ctx.actor) throw new Error("horde: an independent review needs another member");
249
+ if (p.revision !== info.revision) throw new Error("horde: reattest must name the current revision from inspect");
250
+ const previous = state.reviews.findLast((r) => r.target === target && r.actor === ctx.actor && r.revision !== info.revision);
251
+ if (!previous) throw new Error(`horde: no earlier review of ${target} by ${ctx.actor} to carry forward; record a review instead`);
252
+ const blocker = reattestBlocker(ctx, state, target, previous.revision);
253
+ if (blocker) throw new Error(`horde: cannot reattest: ${blocker}`);
254
+ if (state.reviews.some((r) => r.target === target && r.actor === ctx.actor && r.revision === info.revision)) {
255
+ throw new Error(`horde: ${ctx.actor} already reviewed ${target}@${info.revision.slice(0, 12)}`);
256
+ }
257
+ const refs = [...evidenceRefs(state, p.refs)];
258
+ if (!refs.length && !p.evidenceText) throw new Error("horde: reattest needs evidence that the checked behaviour is unchanged: refs or evidenceText");
259
+ const stale = staleEvidence(state, refs, target, info.revision);
260
+ if (stale) throw new Error(`horde: reattest evidence must address the current revision: ${stale}`);
261
+ const text = requireText(p.text, "reattest");
262
+ const evidenceRecord = p.evidenceText === undefined ? undefined : {
263
+ id: `E${seq}`, actor: ctx.actor, target, revision: info.revision, relation: "extends" as const,
264
+ text: p.evidenceText, ...(p.artifactPath ? retainArtifact(ctx, p.artifactPath) : {}),
265
+ };
266
+ if (evidenceRecord) refs.push(evidenceRecord.id);
267
+ const review = { ...previous, id: `V${seq}`, revision: info.revision, text, evidence: refs, reattests: previous.id };
268
+ return { payload: { ...(evidenceRecord ? { evidenceRecord } : {}), review,
269
+ ...completeAssignedReview(c, target, `V${seq}`, text, refs),
270
+ ...(review.verdict === "refutes" ? automaticChallenge(c, target, info, text, refs) : {}) }, parents: [previous.id, ...refs.filter((id) => id !== evidenceRecord?.id)] };
222
271
  }
223
272
 
224
273
  /** The parent's assignment closes with the review id and its evidence, so the thread shows what decided it. */
@@ -239,6 +288,7 @@ function decide(c: CollaborationCommand): EventDraft {
239
288
  case "message": return newMessage(c);
240
289
  case "attach": return attachEvidence(c);
241
290
  case "review": return recordReview(c);
291
+ case "reattest": return reattestReview(c);
242
292
  case "artifact": return publishArtifact(c);
243
293
  case "resource-state": return resourceTransition(c);
244
294
  default: return transition(c);
@@ -28,19 +28,20 @@ export const HordeParams = Type.Object({
28
28
  "confirm-solved",
29
29
  "contest-solved",
30
30
  "concerns",
31
- "verify", "inbox", "message", "ack", "respond", "resolve", "defer",
32
- "attach", "review", "artifact", "resource-state", "collaboration", "inspect",
31
+ "verify", "amend", "inbox", "message", "ack", "respond", "resolve", "defer",
32
+ "attach", "review", "reattest", "artifact", "resource-state", "collaboration", "inspect",
33
33
  ] as const,
34
34
  {
35
35
  description:
36
36
  "list/findings/facts/claims/resources/concerns: read shared state; post: BOARD.md; " +
37
37
  "idea/claim/done/drop/rule-out: checklist (rule-out is a terminal negative); " +
38
38
  "finding: FINDINGS.md with a claimed idea, a concern slug, and optional importance/" +
39
- "confidence/support; " +
39
+ "confidence/support; amend: rewrite the body prose of your own F#id at its current revision; " +
40
40
  "verify: record your independent reproduction of finding F#id with the support level you reached; " +
41
41
  "fact: FACTS.md; assert/confirm/contest/retract/supersede: change claim C#id state; " +
42
42
  "resource: claim a shared dataset, harness, corpus, or sandbox (with cleanup) before building it; " +
43
- "solved: propose the goal answered; confirm-solved/contest-solved: validate or refute it",
43
+ "solved: propose the goal answered; confirm-solved/contest-solved: validate or refute it; " +
44
+ "reattest: carry your earlier review of F#/A# forward to its current revision with evidence that the checked behaviour is unchanged",
44
45
  },
45
46
  ),
46
47
  detail: Type.Optional(StringEnum(["summary", "full"] as const, { description: "Collaboration reads default to summaries; full returns complete page bodies, inspected subjects or write receipts. inspect E#/V#/Q# always returns the full record." })),
@@ -57,7 +58,8 @@ export const HordeParams = Type.Object({
57
58
  relation: Type.Optional(StringEnum(["supports", "contradicts", "extends"] as const)),
58
59
  verdict: Type.Optional(StringEnum(["supports", "partially-supports", "refutes", "inconclusive"] as const)),
59
60
  checked: Type.Optional(Type.String({ description: "Exact assertions checked and method; required for review." })),
60
- unchecked: Type.Optional(Type.String({ description: "Assertions left unchecked or limitations; empty only for full coverage." })),
61
+ unchecked: Type.Optional(Type.String({ description: "Assertions inside the target's stated claim or criteria that you did not check; empty only for full coverage of that scope. Disclosures outside the criteria go in limits." })),
62
+ limits: Type.Optional(Type.String({ description: "For review: limits outside the target's declared claim or criteria (one engine, no hardened deployment). Recorded on the acceptance as limits, never as blockers." })),
61
63
  artifactKind: Type.Optional(StringEnum(["runnable", "document"] as const, { description: "Artifact classification, immutable after publication. Default runnable requires tested/reproduced review. document permits full-scope sourced/derived review of its stated criteria, not runtime acceptance." })),
62
64
  artifactPath: Type.Optional(Type.String({ description: "File path relative to this run, under artifacts/, retained by content hash." })),
63
65
  criteria: Type.Optional(Type.String({ description: "Artifact acceptance criteria, including positive/negative tests." })),
@@ -168,8 +170,8 @@ export type HordeToolParams = {
168
170
  | "confirm-solved"
169
171
  | "contest-solved"
170
172
  | "concerns"
171
- | "verify" | "inbox" | "message" | "ack" | "respond" | "resolve" | "defer"
172
- | "attach" | "review" | "artifact" | "resource-state" | "collaboration" | "inspect";
173
+ | "verify" | "amend" | "inbox" | "message" | "ack" | "respond" | "resolve" | "defer"
174
+ | "attach" | "review" | "reattest" | "artifact" | "resource-state" | "collaboration" | "inspect";
173
175
  detail?: "summary" | "full";
174
176
  offset?: number;
175
177
  limit?: number;
@@ -185,6 +187,7 @@ export type HordeToolParams = {
185
187
  verdict?: "supports" | "partially-supports" | "refutes" | "inconclusive";
186
188
  checked?: string;
187
189
  unchecked?: string;
190
+ limits?: string;
188
191
  artifactPath?: string;
189
192
  artifactKind?: "runnable" | "document";
190
193
  criteria?: string;
@@ -2,8 +2,9 @@ import * as path from "node:path";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { cleanStopRequestedFor, writeHeartbeat } from "../ledger/run-signals.js";
4
4
  import { envNumber } from "../format.js";
5
+ import { parseSiblings } from "../member.js";
5
6
  import { ReminderGate, goalReminder } from "../reminder.js";
6
- import { runHordeAction } from "./actions.js";
7
+ import { isVerifierRole, runHordeAction } from "./actions.js";
7
8
  import { pendingVerificationHint, solvedHint } from "./hints.js";
8
9
  import { HordeParams } from "./params.js";
9
10
  import { compactReceipt } from "./collaboration-view.js";
@@ -18,9 +19,12 @@ const REMIND_MS = envNumber("PI_HORDE_REMIND_MS", 600_000);
18
19
  const MUTATING_ACTIONS = new Set([
19
20
  "post", "idea", "claim", "done", "drop", "rule-out", "finding", "fact", "assert", "confirm",
20
21
  "contest", "retract", "supersede", "resource", "solved", "confirm-solved", "contest-solved",
21
- "verify", "message", "ack", "respond", "resolve", "defer", "attach", "review", "artifact", "resource-state",
22
+ "verify", "amend", "message", "ack", "respond", "resolve", "defer", "attach", "review", "reattest", "artifact", "resource-state",
22
23
  ]);
23
24
 
25
+ /** After these, a verifier is shown what it still has to review. */
26
+ const VERIFIER_QUEUE_ACTIONS = new Set(["verify", "review", "reattest", "post", "findings"]);
27
+
24
28
  export function registerMemberTool(pi: ExtensionAPI): void {
25
29
  const hordeDir = process.env.PI_HORDE_DIR;
26
30
  const member = process.env.PI_HORDE_NAME;
@@ -28,6 +32,7 @@ export function registerMemberTool(pi: ExtensionAPI): void {
28
32
  throw new Error("agent-horde: PI_HORDE_DIR and PI_HORDE_NAME must be set for a horde member");
29
33
  }
30
34
  const attempt = process.env.PI_HORDE_ATTEMPT ?? `pid-${process.pid}`;
35
+ const siblings = parseSiblings(process.env.PI_HORDE_SIBLINGS);
31
36
  const activeRun = process.env.PI_HORDE_RUN;
32
37
  if (activeRun) registerInbox(pi, { hordeDir, runId: activeRun, actor: member, attempt });
33
38
  // One member per process, so one gate is the per-member reminder counter.
@@ -60,13 +65,15 @@ export function registerMemberTool(pi: ExtensionAPI): void {
60
65
  parameters: HordeParams,
61
66
  async execute(toolCallId, params) {
62
67
  const runId = process.env.PI_HORDE_RUN;
63
- const result = await runHordeAction(hordeDir, member, params, runId, { attempt, requestId: toolCallId });
68
+ const role = process.env.PI_HORDE_ROLE;
69
+ const result = await runHordeAction(hordeDir, member, params, runId, { attempt, requestId: toolCallId, role, siblings });
64
70
  const runDir = runId ? path.join(hordeDir, "runs", runId) : undefined;
65
71
  if (runDir && MUTATING_ACTIONS.has(params.action)) touchHeartbeat(runDir, member);
66
72
  let text = compactReceipt(result.text, params) + solvedHint(hordeDir, runId, member, params.action);
67
- // A verifier's coverage claim is only a snapshot. Append the live pending queue
68
- // after every verify so a finding filed later cannot read as already checked.
69
- if (runId && params.action === "verify" && /verifier/i.test(process.env.PI_HORDE_ROLE ?? "")) {
73
+ // A verifier's coverage claim is only a snapshot. Append the live pending queue after every
74
+ // review and every board update, so the unreviewed F# are named before wind-down and a
75
+ // finding filed later cannot read as already checked.
76
+ if (runId && VERIFIER_QUEUE_ACTIONS.has(params.action) && isVerifierRole(role)) {
70
77
  text += pendingVerificationHint(hordeDir, runId, member);
71
78
  }
72
79
  const winding = Boolean(runId && cleanStopRequestedFor(hordeDir, runId, member));
package/src/widget.ts CHANGED
@@ -1,17 +1,30 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
- import { formatElapsed } from "./format.js";
3
+ import { formatElapsed, formatTokens } from "./format.js";
4
4
  import type { MemberState } from "./member.js";
5
- import { memberActivity, statusCounts } from "./prompt.js";
5
+ import { memberActivity, statusCounts, totalUsage } from "./prompt.js";
6
6
 
7
7
  interface WidgetRun {
8
8
  id: string;
9
+ horde: number;
9
10
  members: MemberState[];
10
11
  startedAt: number;
11
12
  cleanStopUntil?: number | undefined;
12
13
  dashboardUrl?: string | undefined;
13
14
  /** Steps still running after members finished, e.g. ["deliverable", "post-mortem"]. */
14
15
  finalizing?: string[] | undefined;
16
+ reported?: boolean | undefined;
17
+ finalized?: boolean | undefined;
18
+ outcome?: string | undefined;
19
+ }
20
+
21
+ interface WidgetSwarm {
22
+ id: string;
23
+ runs: WidgetRun[];
24
+ startedAt: number;
25
+ dashboardUrl?: string | undefined;
26
+ /** The swarm step still running after every horde finalized, e.g. ["merge"]. */
27
+ finalizing?: string[] | undefined;
15
28
  }
16
29
 
17
30
  const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -65,8 +78,8 @@ function memberLine(m: MemberState, now: number, frame: number, theme: Theme): s
65
78
  );
66
79
  }
67
80
 
68
- export function widgetLines(run: WidgetRun, now: number, width: number, theme: Theme): string[] {
69
- const frame = Math.floor(now / 100) % SPINNER.length;
81
+ /** The widget for one horde: a header, then a line per member (a glyph strip past eight). */
82
+ function runLines(run: WidgetRun, now: number, frame: number, theme: Theme): string[] {
70
83
  const lines = [headerLine(run, now, theme)];
71
84
  if (run.members.length > COLLAPSE_ABOVE) {
72
85
  lines.push(` ${run.members.map((m) => statusGlyph(m, frame, theme)).join(" ")}`);
@@ -77,22 +90,81 @@ export function widgetLines(run: WidgetRun, now: number, width: number, theme: T
77
90
  } else {
78
91
  for (const m of run.members) lines.push(memberLine(m, now, frame, theme));
79
92
  }
93
+ return lines;
94
+ }
95
+
96
+ /** What one horde of a swarm is doing, in a word. */
97
+ function hordeActivity(run: WidgetRun, c: ReturnType<typeof statusCounts>): string {
98
+ if (run.finalized) return run.outcome ?? "finished";
99
+ if (run.finalizing && run.finalizing.length > 0) return `synthesizing ${run.finalizing.join(", ")}`;
100
+ if (run.reported) return "finalizing";
101
+ if (run.cleanStopUntil !== undefined) return "stopping";
102
+ return c.running + c.starting > 0 ? "running" : "finishing";
103
+ }
104
+
105
+ /** One line per horde: `h2 11/12 running 1 done t120 45.2k tok $1.20 running`. */
106
+ export function hordeLine(run: WidgetRun, now: number, frame: number, theme: Theme): string {
107
+ const c = statusCounts(run.members);
108
+ const usage = totalUsage(run.members);
109
+ const live = run.members.find((m) => m.status === "running");
110
+ const glyph = run.finalized
111
+ ? theme.fg("success", "✓")
112
+ : live
113
+ ? theme.fg("accent", SPINNER[frame % SPINNER.length] ?? "⠋")
114
+ : theme.fg("dim", "·");
115
+ const parts = [
116
+ `${c.running + c.starting}/${run.members.length} running`,
117
+ `${c.done} done`,
118
+ ];
119
+ if (c.failed) parts.push(theme.fg("error", `${c.failed} failed`));
120
+ if (c.stopped) parts.push(theme.fg("warning", `${c.stopped} stopped`));
121
+ parts.push(theme.fg("dim", `t${usage.turns} ${formatTokens(usage.input + usage.output)} tok $${usage.cost.toFixed(2)}`));
122
+ parts.push(theme.fg("muted", hordeActivity(run, c)));
123
+ return ` ${glyph} ${`h${run.horde}`.padEnd(3)} ${parts.join(" ")}`;
124
+ }
125
+
126
+ function swarmHeader(swarm: WidgetSwarm, now: number, theme: Theme): string {
127
+ const members = swarm.runs.flatMap((run) => run.members);
128
+ const c = statusCounts(members);
129
+ const parts = [
130
+ theme.bold(theme.fg("accent", `swarm ${swarm.id}`)),
131
+ `${swarm.runs.length} hordes`,
132
+ `${c.running + c.starting} running`,
133
+ `${c.done} done`,
134
+ ];
135
+ if (c.failed) parts.push(theme.fg("error", `${c.failed} failed`));
136
+ if (c.stopped) parts.push(theme.fg("warning", `${c.stopped} stopped`));
137
+ parts.push(formatElapsed(now - swarm.startedAt));
138
+ if (swarm.finalizing && swarm.finalizing.length > 0) {
139
+ parts.push(theme.fg("warning", `synthesizing ${swarm.finalizing.join(", ")}`));
140
+ }
141
+ if (swarm.dashboardUrl) parts.push(`${swarm.dashboardUrl}swarms/${swarm.id}`);
142
+ return `${parts.join(" ")} ${theme.fg("dim", "/agent-horde status | stop-hard | stop-clean [hN]")}`;
143
+ }
144
+
145
+ /** A swarm of one shows its run as before; a larger swarm shows one line per horde. */
146
+ export function widgetLines(swarm: WidgetSwarm, now: number, width: number, theme: Theme): string[] {
147
+ const frame = Math.floor(now / 100) % SPINNER.length;
148
+ const single = swarm.runs.length === 1 ? swarm.runs[0] : undefined;
149
+ const lines = single
150
+ ? runLines(single, now, frame, theme)
151
+ : [swarmHeader(swarm, now, theme), ...swarm.runs.map((run) => hordeLine(run, now, frame, theme))];
80
152
  return lines.map((line) => truncateToWidth(line, width));
81
153
  }
82
154
 
83
155
  export class HordeWidget {
84
- private readonly run: WidgetRun;
156
+ private readonly swarm: WidgetSwarm;
85
157
  private readonly theme: Theme;
86
158
  private readonly now: () => number;
87
159
 
88
- constructor(run: WidgetRun, theme: Theme, now: () => number = Date.now) {
89
- this.run = run;
160
+ constructor(swarm: WidgetSwarm, theme: Theme, now: () => number = Date.now) {
161
+ this.swarm = swarm;
90
162
  this.theme = theme;
91
163
  this.now = now;
92
164
  }
93
165
 
94
166
  render(width: number): string[] {
95
- return widgetLines(this.run, this.now(), width, this.theme);
167
+ return widgetLines(this.swarm, this.now(), width, this.theme);
96
168
  }
97
169
 
98
170
  invalidate(): void {}