agent-horde 0.1.6 → 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.
package/src/run/ui.ts CHANGED
@@ -3,7 +3,10 @@ import { Text } from "@earendil-works/pi-tui";
3
3
  import { formatElapsed, formatTokens } from "../format.js";
4
4
  import { statusCounts } from "../prompt.js";
5
5
  import { HordeWidget } from "../widget.js";
6
- import type { HordeRun, ReportDetails } from "./types.js";
6
+ import { SWARM_REPORT_TYPE } from "./swarm-report.js";
7
+ import type { SwarmReportDetails } from "./swarm-report.js";
8
+ import { hordeLabel } from "./types.js";
9
+ import type { HordeRun, ReportDetails, Swarm } from "./types.js";
7
10
 
8
11
  const WIDGET_KEY = "agent-horde";
9
12
 
@@ -11,7 +14,26 @@ export const REPORT_TYPE = "agent-horde-report";
11
14
 
12
15
  const RENDER_INTERVAL_MS = 100;
13
16
 
17
+ function registerSwarmReportRenderer(pi: ExtensionAPI): void {
18
+ pi.registerMessageRenderer<SwarmReportDetails>(SWARM_REPORT_TYPE, (message, { outputPad }, theme) => {
19
+ const d = message.details;
20
+ if (!d) return undefined;
21
+ const title = `swarm ${d.swarmId} finished: ${d.hordes.length} hordes, ${formatElapsed(d.elapsedMs)}`;
22
+ const lines = [theme.bold(theme.fg("accent", title))];
23
+ for (const h of d.hordes) {
24
+ lines.push(theme.fg("muted", ` ${h.label.padEnd(3)} ${h.outcome.padEnd(18)} ${h.done} done ${h.failed} failed ` +
25
+ `${h.stopped} stopped t${h.turns} ${formatTokens(h.tokens)} tok $${h.cost.toFixed(2)} ${h.findings} findings`));
26
+ }
27
+ if (d.deliverable) {
28
+ lines.push("", theme.bold(theme.fg("accent", `swarm ${d.swarmId} deliverable`)), "");
29
+ lines.push(d.deliverable.trimEnd());
30
+ }
31
+ return new Text(lines.join("\n"), outputPad, 0);
32
+ });
33
+ }
34
+
14
35
  export function registerReportRenderer(pi: ExtensionAPI): void {
36
+ registerSwarmReportRenderer(pi);
15
37
  pi.registerMessageRenderer<ReportDetails>(REPORT_TYPE, (message, { outputPad }, theme) => {
16
38
  const d = message.details;
17
39
  if (!d) return undefined;
@@ -38,50 +60,73 @@ export function registerReportRenderer(pi: ExtensionAPI): void {
38
60
  });
39
61
  }
40
62
 
41
- /** Which run owns the widget on a shared UI context; a later run must not be clobbered. */
42
- const widgetOwners = new WeakMap<ExtensionUIContext, HordeRun>();
63
+ /** Which swarm owns the widget on a shared UI context; a later swarm must not be clobbered. */
64
+ const widgetOwners = new WeakMap<ExtensionUIContext, Swarm>();
43
65
 
44
- export function refreshRun(run: HordeRun): void {
45
- if (!run.ui || widgetOwners.get(run.ui) !== run) return;
46
- if (run.finalizing && run.finalizing.length > 0) {
47
- run.ui.setStatus(WIDGET_KEY, `horde ${run.id} synthesizing: ${run.finalizing.join(", ")}`);
48
- run.tui?.requestRender();
49
- return;
50
- }
66
+ /** The swarm each live run belongs to, so a per-run refresh redraws the whole widget. */
67
+ const swarmOf = new WeakMap<HordeRun, Swarm>();
68
+
69
+ /** One run's share of the status line: `h2 11/12 running, 1 paused, clean stop in 40s`. */
70
+ function runStatus(run: HordeRun, labelled: boolean): string {
51
71
  const c = statusCounts(run.members);
52
72
  const paused = run.members.filter((m) => m.paused).length;
53
- let text = `horde ${c.running + c.starting}/${run.count} running`;
73
+ let text = `${labelled ? `${hordeLabel(run.horde)} ` : ""}${c.running + c.starting}/${run.count} running`;
54
74
  if (paused > 0) text += `, ${paused} paused`;
55
75
  if (run.cleanStopUntil !== undefined) {
56
76
  const left = Math.max(0, Math.ceil((run.cleanStopUntil - Date.now()) / 1000));
57
77
  text += `, clean stop in ${left}s`;
58
78
  }
59
- run.ui.setStatus(WIDGET_KEY, text);
60
- run.tui?.requestRender();
79
+ if (run.finalizing && run.finalizing.length > 0) text += `, synthesizing ${run.finalizing.join(", ")}`;
80
+ return text;
81
+ }
82
+
83
+ export function refreshSwarm(swarm: Swarm): void {
84
+ if (!swarm.ui || widgetOwners.get(swarm.ui) !== swarm) return;
85
+ const single = swarm.runs.length === 1;
86
+ let text: string;
87
+ if (swarm.finalizing && swarm.finalizing.length > 0) {
88
+ text = `swarm ${swarm.id} synthesizing: ${swarm.finalizing.join(", ")}`;
89
+ } else if (single && swarm.runs[0]!.finalizing && swarm.runs[0]!.finalizing.length > 0) {
90
+ text = `horde ${swarm.id} synthesizing: ${swarm.runs[0]!.finalizing.join(", ")}`;
91
+ } else {
92
+ const parts = swarm.runs.map((run) => runStatus(run, !single));
93
+ text = single ? `horde ${parts[0]}` : `swarm ${parts.join("; ")}`;
94
+ }
95
+ swarm.ui.setStatus(WIDGET_KEY, text);
96
+ swarm.tui?.requestRender();
61
97
  }
62
98
 
63
- export function mountWidget(run: HordeRun, ui: ExtensionUIContext): void {
64
- run.ui = ui;
65
- widgetOwners.set(ui, run);
99
+ export function refreshRun(run: HordeRun): void {
100
+ const swarm = swarmOf.get(run);
101
+ if (swarm) refreshSwarm(swarm);
102
+ }
103
+
104
+ export function mountWidget(swarm: Swarm, ui: ExtensionUIContext): void {
105
+ swarm.ui = ui;
106
+ for (const run of swarm.runs) {
107
+ run.ui = ui;
108
+ swarmOf.set(run, swarm);
109
+ }
110
+ widgetOwners.set(ui, swarm);
66
111
  ui.setWidget(
67
112
  WIDGET_KEY,
68
113
  (tui, theme) => {
69
- run.tui = tui;
70
- return new HordeWidget(run, theme);
114
+ swarm.tui = tui;
115
+ return new HordeWidget(swarm, theme);
71
116
  },
72
117
  { placement: "aboveEditor" },
73
118
  );
74
- run.timer = setInterval(() => refreshRun(run), RENDER_INTERVAL_MS);
75
- run.timer.unref();
119
+ swarm.timer = setInterval(() => refreshSwarm(swarm), RENDER_INTERVAL_MS);
120
+ swarm.timer.unref();
76
121
  }
77
122
 
78
- export function unmountWidget(run: HordeRun): void {
79
- if (run.timer) clearInterval(run.timer);
80
- run.timer = undefined;
81
- if (!run.ui || widgetOwners.get(run.ui) !== run) return;
82
- widgetOwners.delete(run.ui);
83
- run.ui.setWidget(WIDGET_KEY, undefined);
84
- run.ui.setStatus(WIDGET_KEY, undefined);
123
+ export function unmountWidget(swarm: Swarm): void {
124
+ if (swarm.timer) clearInterval(swarm.timer);
125
+ swarm.timer = undefined;
126
+ if (!swarm.ui || widgetOwners.get(swarm.ui) !== swarm) return;
127
+ widgetOwners.delete(swarm.ui);
128
+ swarm.ui.setWidget(WIDGET_KEY, undefined);
129
+ swarm.ui.setStatus(WIDGET_KEY, undefined);
85
130
  }
86
131
 
87
132
  export function reportDetails(run: HordeRun, now: number): ReportDetails {
@@ -114,7 +159,7 @@ export function safeNotifyUi(
114
159
  }
115
160
  }
116
161
 
117
- export function safeNotify(run: HordeRun, message: string, level: "info" | "warning" | "error"): void {
162
+ export function safeNotify(run: Pick<HordeRun, "ui">, message: string, level: "info" | "warning" | "error"): void {
118
163
  try {
119
164
  run.ui?.notify(message, level);
120
165
  } catch {
package/src/run-graph.ts CHANGED
@@ -117,14 +117,14 @@ export function entityHref(runId: string, id: string): string | undefined {
117
117
  if (!match) return undefined;
118
118
  const letter = match[1] ?? "";
119
119
  const number = match[2] ?? "";
120
- const run = encodeURIComponent(runId);
120
+ const base = `/runs/${encodeURIComponent(runId)}`;
121
121
  switch (letter) {
122
- case "I": return `/ideas#idea-${number}`;
123
- case "F": return `/findings?run=${run}#F${number}`;
124
- case "T": return `/facts?run=${run}#T${number}`;
125
- case "C": return `/claims#C${number}`;
126
- case "R": return `/resources#R${number}`;
127
- case "A": case "V": case "Q": return `/runs/${run}/collaboration#${id}`;
122
+ case "I": return `${base}/ideas#idea-${number}`;
123
+ case "F": return `${base}/findings#F${number}`;
124
+ case "T": return `${base}/facts#T${number}`;
125
+ case "C": return `${base}/claims#C${number}`;
126
+ case "R": return `${base}/resources#R${number}`;
127
+ case "A": case "V": case "Q": return `${base}/collaboration#${id}`;
128
128
  case "E": return eventHref(runId, Number(number));
129
129
  default: return undefined;
130
130
  }
@@ -0,0 +1,127 @@
1
+ import { oneLine } from "./format.js";
2
+ import type { HordeEvent } from "./ledger/events.js";
3
+ import { LIFECYCLE_ATTEMPT, eventHref } from "./run-graph.js";
4
+ import type { GraphEdge, GraphLane, GraphNode, RunGraph } from "./run-graph.js";
5
+
6
+ /** One horde's events and where its run page is. */
7
+ export interface SwarmGraphHorde {
8
+ label: string;
9
+ runId: string;
10
+ events: HordeEvent[];
11
+ status?: string | undefined;
12
+ }
13
+
14
+ export interface SwarmGraphInput {
15
+ swarmId: string;
16
+ startedAt: number;
17
+ hordes: SwarmGraphHorde[];
18
+ /** The swarm's merge step, once it started. */
19
+ merge?: { startedAt: number; endedAt?: number | undefined; written?: boolean | undefined } | undefined;
20
+ /** Graph end for a live swarm (epoch ms); a finished swarm ends at its last event. */
21
+ now?: number | undefined;
22
+ }
23
+
24
+ const SWARM_LANE = "swarm";
25
+
26
+ /** The parent lifecycle actions that mark a horde's progress; member-level churn stays on the run graph. */
27
+ const PHASE_ACTIONS = new Set([
28
+ "run-start", "phase-start", "phase-end", "stop-clean", "stop-hard", "solved-validated", "solved-banked",
29
+ "solved-contested", "acceptance", "run-end",
30
+ ]);
31
+
32
+ function str(value: unknown): string | undefined {
33
+ return typeof value === "string" ? value : undefined;
34
+ }
35
+
36
+ function labelOf(event: HordeEvent): [string, string] {
37
+ const p = event.payload;
38
+ switch (event.action) {
39
+ case "run-start": return ["start", `${String(p.count ?? "?")} members${str(p.slice) ? `; workstream: ${oneLine(str(p.slice) ?? "", 160)}` : ""}`];
40
+ case "phase-start": return [`${str(p.phase) ?? "phase"} start`, `${str(p.phase) ?? "phase"} started`];
41
+ case "phase-end": return [`${str(p.phase) ?? "phase"} end`, `${str(p.phase) ?? "phase"} ended`];
42
+ case "stop-clean": return [`stop-clean (${str(p.label) ?? "stop-clean"})`, "clean stop requested"];
43
+ case "stop-hard": return [`stop-hard (${str(p.by) ?? "user"})`, "members terminated"];
44
+ case "solved-validated": return ["solved validated", str(p.validator) ? `by ${str(p.validator)}` : "without a separate validator"];
45
+ case "solved-banked": return [`solved banked (round ${String(p.round ?? "?")})`, "the run continues until stopped"];
46
+ case "solved-contested": return ["solved contested", `by ${str(p.member) ?? ""}`];
47
+ case "acceptance": return [`acceptance ${str(p.status) ?? ""}`.trim(), `${String(p.issues ?? 0)} issues`];
48
+ case "run-end": return ["end", `outcome ${str(p.outcome) ?? "unknown"}`];
49
+ default: return [event.action, ""];
50
+ }
51
+ }
52
+
53
+ function hordeNodes(horde: SwarmGraphHorde): GraphNode[] {
54
+ return horde.events
55
+ .filter((event) => event.attempt === LIFECYCLE_ATTEMPT && event.outcome === "applied" && PHASE_ACTIONS.has(event.action))
56
+ .map((event) => {
57
+ const [label, title] = labelOf(event);
58
+ const at = Date.parse(event.at);
59
+ return {
60
+ id: `${horde.label}:${event.eventId}`,
61
+ kind: "lifecycle" as const,
62
+ action: event.action,
63
+ label,
64
+ title: `${horde.label} ${event.eventId} ${title}`.trim(),
65
+ lane: horde.label,
66
+ at: Number.isFinite(at) ? at : 0,
67
+ eventId: event.eventId,
68
+ href: eventHref(horde.runId, event.seq),
69
+ outcome: "applied" as const,
70
+ marker: true,
71
+ placeholder: false,
72
+ };
73
+ })
74
+ .filter((node) => node.at > 0);
75
+ }
76
+
77
+ function mergeNodes(input: SwarmGraphInput): GraphNode[] {
78
+ const merge = input.merge;
79
+ if (!merge) return [];
80
+ const node = (id: string, label: string, title: string, at: number): GraphNode => ({
81
+ id, kind: "lifecycle", action: id, label, title, lane: SWARM_LANE, at, eventId: "",
82
+ href: `/swarms/${encodeURIComponent(input.swarmId)}`, outcome: "applied", marker: true, placeholder: false,
83
+ });
84
+ const nodes = [node("merge-start", "merge start", "merging the hordes' deliverables", merge.startedAt)];
85
+ if (merge.endedAt !== undefined) {
86
+ nodes.push(node("merge-end", `merge end${merge.written === false ? " (no deliverable)" : ""}`,
87
+ merge.written === false ? "the swarm deliverable was not written" : "DELIVERABLE.md written", merge.endedAt));
88
+ }
89
+ return nodes;
90
+ }
91
+
92
+ /** One lane per horde plus the swarm lane, so the hordes' phases line up against the merge step. */
93
+ export function buildSwarmGraph(input: SwarmGraphInput): RunGraph {
94
+ const perHorde = input.hordes.map((horde) => hordeNodes(horde));
95
+ const nodes = [...perHorde.flat(), ...mergeNodes(input)].sort((a, b) => a.at - b.at);
96
+ const edges: GraphEdge[] = [];
97
+ if (input.merge) {
98
+ for (const horde of perHorde) {
99
+ const end = horde.find((node) => node.action === "run-end");
100
+ if (end) edges.push({ from: end.id, to: "merge-start", label: "merged", tone: "lifecycle" });
101
+ }
102
+ }
103
+ const lanes: GraphLane[] = [
104
+ { id: SWARM_LANE, label: SWARM_LANE },
105
+ ...input.hordes.map((horde) => ({
106
+ id: horde.label,
107
+ label: `${horde.label} ${horde.runId}`,
108
+ href: `/runs/${encodeURIComponent(horde.runId)}`,
109
+ status: horde.status,
110
+ })),
111
+ ];
112
+ const start = input.startedAt;
113
+ const latest = Math.max(input.now ?? 0, input.merge?.endedAt ?? 0, ...nodes.map((node) => node.at));
114
+ const end = latest > start ? latest : start + 1_000;
115
+ const hordeTimes = perHorde.flat().map((node) => node.at);
116
+ const live = input.now !== undefined && !input.hordes.every((horde) => horde.events.some((event) => event.action === "run-end"));
117
+ return {
118
+ runId: input.swarmId,
119
+ start,
120
+ end,
121
+ membersEnd: live || hordeTimes.length === 0 ? end : Math.min(end, Math.max(start, ...hordeTimes)),
122
+ lanes,
123
+ phases: [],
124
+ nodes,
125
+ edges,
126
+ };
127
+ }
@@ -14,6 +14,7 @@ import type { Idea } from "../ledger/ideas.js";
14
14
  import { claimResource, listResources } from "../ledger/resources.js";
15
15
  import { cleanStopRequestedFor, confirmSolved, contestSolved, markSolved, readSolved } from "../ledger/run-signals.js";
16
16
  import { oneLine } from "../format.js";
17
+ import type { SiblingHorde } from "../member.js";
17
18
  import { validateFindingResourceCitations } from "../citations.js";
18
19
  import { idleHint } from "./hints.js";
19
20
  import type { HordeToolParams } from "./params.js";
@@ -54,6 +55,29 @@ interface ActionContext {
54
55
  cleanStop: boolean;
55
56
  /** The member's role instruction, when one was assigned. */
56
57
  role: string | undefined;
58
+ /** Other hordes of the swarm whose ledgers the read actions include, tagged by label. */
59
+ siblings: SiblingHorde[];
60
+ }
61
+
62
+ /**
63
+ * Shared read, local write: the read actions append each sibling horde's entries, tagged `[hN]`,
64
+ * so a fact, a rule-out or a resource another horde recorded is not re-derived here. A sibling
65
+ * whose ledger cannot be read is reported, not fatal.
66
+ */
67
+ function siblingSections(siblings: SiblingHorde[], what: string, lines: (hordeDir: string) => string[]): string {
68
+ return siblings
69
+ .map((sibling) => {
70
+ const tag = `[${sibling.label}]`;
71
+ let entries: string[];
72
+ try {
73
+ entries = lines(sibling.hordeDir).map((line) => `${tag} ${line}`);
74
+ } catch (error) {
75
+ entries = [`${tag} unreadable: ${oneLine(error instanceof Error ? error.message : String(error), 120)}`];
76
+ }
77
+ const head = `${tag} ${what} of horde ${sibling.label} (read-only; ${sibling.hordeDir}):`;
78
+ return `\n\n${head}\n${entries.length > 0 ? entries.join("\n") : `${tag} none`}`;
79
+ })
80
+ .join("");
57
81
  }
58
82
 
59
83
  export function isVerifierRole(role: string | undefined): boolean {
@@ -108,18 +132,19 @@ function closedIdea(ctx: ActionContext, idea: Idea, text: string): ActionResult
108
132
  return { text: `${text}${hint}`, id: idea.id };
109
133
  }
110
134
 
111
- function listAction({ hordeDir }: ActionContext): ActionResult {
135
+ function ideaLine(idea: Idea): string {
136
+ const owner = idea.owner ? ` @${idea.owner}` : "";
137
+ const note = idea.note ? `: ${idea.note}` : "";
138
+ return `#${idea.id} [${idea.mark}]${owner} ${idea.text}${note}`;
139
+ }
140
+
141
+ function listAction({ hordeDir, siblings }: ActionContext): ActionResult {
112
142
  const ideas = listIdeas(hordeDir);
113
- if (ideas.length === 0) return { text: "IDEAS.md has no ideas yet" };
114
- return {
115
- text: ideas
116
- .map((idea) => {
117
- const owner = idea.owner ? ` @${idea.owner}` : "";
118
- const note = idea.note ? `: ${idea.note}` : "";
119
- return `#${idea.id} [${idea.mark}]${owner} ${idea.text}${note}`;
120
- })
121
- .join("\n"),
122
- };
143
+ const own = ideas.length === 0 ? "IDEAS.md has no ideas yet" : ideas.map(ideaLine).join("\n");
144
+ // Only closed ideas cross hordes: results prevent re-derivation, open ones would invite claims.
145
+ const shared = siblingSections(siblings, "done and ruled-out ideas", (dir) =>
146
+ listIdeas(dir).filter((idea) => idea.mark === "x" || idea.mark === "!").map(ideaLine));
147
+ return { text: `${own}${shared}` };
123
148
  }
124
149
 
125
150
  async function postAction({ hordeDir, member, params }: ActionContext): Promise<ActionResult> {
@@ -282,14 +307,18 @@ async function factAction({ hordeDir, member, params }: ActionContext): Promise<
282
307
  return { text: `Recorded fact T${fact.id}: ${fact.text}${provisional}`, id: fact.id };
283
308
  }
284
309
 
285
- function factsAction({ hordeDir }: ActionContext): ActionResult {
286
- const facts = listFacts(hordeDir);
287
- if (facts.length === 0) return { text: "FACTS.md has no facts yet" };
310
+ function factLines(hordeDir: string): string[] {
288
311
  const status = (fact: FactEntry): string => {
289
312
  if (!fact.provisionalOn) return "";
290
313
  return factUnsettled(hordeDir, fact) ? ` [provisional: ${fact.provisionalOn} open]` : ` [was provisional on ${fact.provisionalOn}, now closed]`;
291
314
  };
292
- return { text: facts.map((fact) => `T${fact.id} ${fact.text} @${fact.author}${status(fact)}`).join("\n") };
315
+ return listFacts(hordeDir).map((fact) => `T${fact.id} ${fact.text} @${fact.author}${status(fact)}`);
316
+ }
317
+
318
+ function factsAction({ hordeDir, siblings }: ActionContext): ActionResult {
319
+ const own = factLines(hordeDir);
320
+ const shared = siblingSections(siblings, "facts", factLines);
321
+ return { text: `${own.length === 0 ? "FACTS.md has no facts yet" : own.join("\n")}${shared}` };
293
322
  }
294
323
 
295
324
  /** An answer that quotes a provisional fact as settled asserts what an open idea is still auditing. */
@@ -421,19 +450,19 @@ async function resourceAction(ctx: ActionContext): Promise<ActionResult> {
421
450
  };
422
451
  }
423
452
 
424
- function resourcesAction({ hordeDir }: ActionContext): ActionResult {
425
- const resources = listResources(hordeDir);
426
- if (resources.length === 0) return { text: "RESOURCES.md has no resources yet" };
427
- return {
428
- text: resources
429
- .map(
430
- (resource) =>
431
- `R${resource.id} ${resource.name} @${resource.owner}` +
432
- `${resource.note ? `: ${resource.note}` : ""}` +
433
- `${resource.reusers.length > 0 ? ` reuse=${resource.reusers.join(",")}` : ""}`,
434
- )
435
- .join("\n"),
436
- };
453
+ function resourceLines(hordeDir: string): string[] {
454
+ return listResources(hordeDir).map(
455
+ (resource) =>
456
+ `R${resource.id} ${resource.name} @${resource.owner}` +
457
+ `${resource.note ? `: ${resource.note}` : ""}` +
458
+ `${resource.reusers.length > 0 ? ` reuse=${resource.reusers.join(",")}` : ""}`,
459
+ );
460
+ }
461
+
462
+ function resourcesAction({ hordeDir, siblings }: ActionContext): ActionResult {
463
+ const own = resourceLines(hordeDir);
464
+ const shared = siblingSections(siblings, "resources (reuse by path; cite the path, not the R#)", resourceLines);
465
+ return { text: `${own.length === 0 ? "RESOURCES.md has no resources yet" : own.join("\n")}${shared}` };
437
466
  }
438
467
 
439
468
  /**
@@ -564,11 +593,11 @@ export async function runHordeAction(
564
593
  member: string,
565
594
  params: HordeToolParams,
566
595
  runId: string | undefined = process.env.PI_HORDE_RUN,
567
- tracking: { attempt?: string; requestId?: string; role?: string | undefined } = {},
596
+ tracking: { attempt?: string; requestId?: string; role?: string | undefined; siblings?: SiblingHorde[] | undefined } = {},
568
597
  ): Promise<ActionResult> {
569
598
  const cleanStop = Boolean(runId && cleanStopRequestedFor(hordeDir, runId, member));
570
- const role = tracking.role;
571
- const ctx: EventContext = { hordeDir, runId: runId ?? "", actor: member, ...tracking, requestId: params.requestId ?? tracking.requestId };
599
+ const { role, siblings = [], ...eventTracking } = tracking;
600
+ const ctx: EventContext = { hordeDir, runId: runId ?? "", actor: member, ...eventTracking, requestId: params.requestId ?? tracking.requestId };
572
601
  if (COLLABORATION_ACTIONS.has(params.action)) {
573
602
  if (!runId) throw new Error("horde: collaboration needs an active run");
574
603
  return runCollaborationAction(ctx, params);
@@ -580,7 +609,7 @@ export async function runHordeAction(
580
609
  const intent = audit ? await recordEvent({ ...ctx, requestId: ctx.requestId ? `${ctx.requestId}:intent` : undefined }, params.action, { outcome: "intent", payload: { input: params } }) : undefined;
581
610
  try {
582
611
  const refused = cleanStop && NEW_WORK_ACTIONS.has(params.action);
583
- const result = refused ? { text: CLEAN_STOP_NOTICE } : await handler({ hordeDir, member, params, runId, cleanStop, role });
612
+ const result = refused ? { text: CLEAN_STOP_NOTICE } : await handler({ hordeDir, member, params, runId, cleanStop, role, siblings });
584
613
  // A refusal and a no-op both record `noop`; the no-op keeps `result.noop` so the graph and the
585
614
  // report can tell an absorbed duplicate from refused work.
586
615
  if (audit) await recordEvent(ctx, params.action, { outcome: refused || result.noop ? "noop" : "applied", payload: { input: params, result }, parents: intent ? [intent.eventId] : [] });
@@ -2,6 +2,7 @@ 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
7
  import { isVerifierRole, runHordeAction } from "./actions.js";
7
8
  import { pendingVerificationHint, solvedHint } from "./hints.js";
@@ -31,6 +32,7 @@ export function registerMemberTool(pi: ExtensionAPI): void {
31
32
  throw new Error("agent-horde: PI_HORDE_DIR and PI_HORDE_NAME must be set for a horde member");
32
33
  }
33
34
  const attempt = process.env.PI_HORDE_ATTEMPT ?? `pid-${process.pid}`;
35
+ const siblings = parseSiblings(process.env.PI_HORDE_SIBLINGS);
34
36
  const activeRun = process.env.PI_HORDE_RUN;
35
37
  if (activeRun) registerInbox(pi, { hordeDir, runId: activeRun, actor: member, attempt });
36
38
  // One member per process, so one gate is the per-member reminder counter.
@@ -64,7 +66,7 @@ export function registerMemberTool(pi: ExtensionAPI): void {
64
66
  async execute(toolCallId, params) {
65
67
  const runId = process.env.PI_HORDE_RUN;
66
68
  const role = process.env.PI_HORDE_ROLE;
67
- const result = await runHordeAction(hordeDir, member, params, runId, { attempt, requestId: toolCallId, role });
69
+ const result = await runHordeAction(hordeDir, member, params, runId, { attempt, requestId: toolCallId, role, siblings });
68
70
  const runDir = runId ? path.join(hordeDir, "runs", runId) : undefined;
69
71
  if (runDir && MUTATING_ACTIONS.has(params.action)) touchHeartbeat(runDir, member);
70
72
  let text = compactReceipt(result.text, params) + solvedHint(hordeDir, runId, member, params.action);
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 {}