agent-horde 0.1.6 → 0.2.1
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/README.md +22 -3
- package/docs/REFERENCE.md +107 -17
- package/package.json +1 -1
- package/src/args.ts +107 -47
- package/src/citations.ts +16 -5
- package/src/dashboard-data.ts +480 -49
- package/src/dashboard-html.ts +379 -92
- package/src/dashboard.ts +205 -96
- package/src/ledger/files.ts +6 -1
- package/src/ledger/findings.ts +25 -6
- package/src/member.ts +47 -0
- package/src/prompt.ts +37 -6
- package/src/run/command.ts +68 -37
- package/src/run/completions.ts +26 -14
- package/src/run/controls.ts +74 -15
- package/src/run/finalize.ts +34 -25
- package/src/run/launch.ts +179 -34
- package/src/run/repair.ts +136 -24
- package/src/run/setup.ts +35 -25
- package/src/run/solved.ts +21 -8
- package/src/run/supervise.ts +3 -1
- package/src/run/swarm-finalize.ts +121 -0
- package/src/run/swarm-report.ts +152 -0
- package/src/run/swarm.ts +156 -0
- package/src/run/synthesis.ts +141 -20
- package/src/run/types.ts +66 -5
- package/src/run/ui.ts +73 -28
- package/src/run-graph.ts +7 -7
- package/src/swarm-graph.ts +127 -0
- package/src/tool/actions.ts +67 -38
- package/src/tool/collaboration-actions.ts +31 -6
- package/src/tool/register.ts +3 -1
- package/src/widget.ts +80 -8
package/src/tool/actions.ts
CHANGED
|
@@ -7,13 +7,14 @@ import type { FactEntry } from "../ledger/facts.js";
|
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { CLUSTER_THRESHOLD } from "../ledger/clusters.js";
|
|
9
9
|
import { collaborationState } from "../ledger/collaboration.js";
|
|
10
|
-
import { addFinding, amendFinding, answerCitations, findingReviewState, findingRevision, findingSummaries, listFindingsForRun, pendingReviewsForMember,
|
|
10
|
+
import { addFinding, amendFinding, answerCitations, findingReviewState, findingRevision, findingSummaries, listFindingsForRun, pendingReviewsForMember, staleAnswerFor, verifyFinding } from "../ledger/findings.js";
|
|
11
11
|
import { textOverlap } from "../ledger/text-match.js";
|
|
12
12
|
import { addIdea, claimIdea, dropIdea, finishIdea, listIdeas, ruleOutIdea } from "../ledger/ideas.js";
|
|
13
13
|
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
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
|
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,29 +450,29 @@ async function resourceAction(ctx: ActionContext): Promise<ActionResult> {
|
|
|
421
450
|
};
|
|
422
451
|
}
|
|
423
452
|
|
|
424
|
-
function
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
.
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
/**
|
|
440
|
-
* An answer that cites a superseded finding, or
|
|
441
|
-
* stale reading as the run's result. The proposer cites the
|
|
442
|
-
* refused until the proposer re-proposes.
|
|
469
|
+
* An answer that cites a superseded finding, or a finding amended or an artifact republished since
|
|
470
|
+
* the proposal pinned it, ships a stale reading as the run's result. The proposer cites the
|
|
471
|
+
* current text instead; a validator is refused until the proposer re-proposes.
|
|
443
472
|
*/
|
|
444
473
|
function staleAnswer(ctx: ActionContext, text: string, cites?: Record<string, string>): string | undefined {
|
|
445
474
|
const runId = requireRun(ctx);
|
|
446
|
-
const stale =
|
|
475
|
+
const stale = staleAnswerFor(ctx.hordeDir, runId, text, cites);
|
|
447
476
|
if (!stale.length) return undefined;
|
|
448
477
|
return `cites ${stale.join(", ")}`;
|
|
449
478
|
}
|
|
@@ -460,7 +489,7 @@ async function solvedAction(ctx: ActionContext): Promise<ActionResult> {
|
|
|
460
489
|
runId,
|
|
461
490
|
ctx.member,
|
|
462
491
|
ctx.params.text,
|
|
463
|
-
answerCitations(listFindingsForRun(ctx.hordeDir, runId), text),
|
|
492
|
+
answerCitations(listFindingsForRun(ctx.hordeDir, runId), text, collaborationState(ctx.hordeDir, runId).artifacts),
|
|
464
493
|
);
|
|
465
494
|
return {
|
|
466
495
|
text: alreadySolved
|
|
@@ -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
|
|
571
|
-
const ctx: EventContext = { hordeDir, runId: runId ?? "", actor: member, ...
|
|
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] : [] });
|
|
@@ -107,11 +107,12 @@ function responseSubject(c: CollaborationCommand, m: PeerMessage): string {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
|
-
* A blocking request closes on the recipient's response plus
|
|
111
|
-
* the
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
110
|
+
* A blocking request closes on the recipient's response plus a supporting check of the current
|
|
111
|
+
* revision: the sender's own review on a challenge, or the recipient's review when the sender owns
|
|
112
|
+
* the target and so may not review it. Before any response the sender may still withdraw its own
|
|
113
|
+
* challenge on an F# or A#: a challenger that verified the claim itself, or watched others verify
|
|
114
|
+
* it, records a supporting review of the current revision and cites evidence on it. Limits that
|
|
115
|
+
* review discloses stay acceptance debt on the review record; the withdrawal clears only the dispute.
|
|
115
116
|
*/
|
|
116
117
|
function validateResolution(c: CollaborationCommand, m: PeerMessage, refs: string[]): void {
|
|
117
118
|
if (m.sender === m.to) throw new Error("horde: a blocking request needs an independent resolver");
|
|
@@ -121,13 +122,19 @@ function validateResolution(c: CollaborationCommand, m: PeerMessage, refs: strin
|
|
|
121
122
|
if (refs.length === 0 || (withdrawal && !reviewable) || (!withdrawal && !["responded", "deferred"].includes(m.state))) {
|
|
122
123
|
throw new Error("horde: blocking resolution needs a response and evidence");
|
|
123
124
|
}
|
|
124
|
-
const revision = targetInfo(c.ctx, c.state, target)
|
|
125
|
+
const { owner, revision } = targetInfo(c.ctx, c.state, target);
|
|
125
126
|
if ((c.params.revision ?? m.responseRevision ?? revision) !== revision) throw new Error(`horde: response is stale; explicitly review and resolve ${target}@${revision}, or ask the owner to respond again`);
|
|
126
127
|
if (!refs.some((id) => c.state.evidence.some((e) => e.id === id && e.target === target && e.revision === revision))) {
|
|
127
128
|
const detail = staleEvidence(c.state, refs, target, revision) ?? `none of ${refs.join(", ")} targets ${target}@${revision.slice(0, 12)}`;
|
|
128
129
|
throw new Error(`horde: resolution evidence must address the current response target revision: ${detail}; attach evidence to that revision first`);
|
|
129
130
|
}
|
|
130
131
|
if (!reviewable) return;
|
|
132
|
+
if (owner === c.ctx.actor) requireRecipientReview(c, m, target, revision, withdrawal);
|
|
133
|
+
else requireOwnReview(c, target, revision, withdrawal);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A challenger settles its own dispute: its latest review of the current revision must support. */
|
|
137
|
+
function requireOwnReview(c: CollaborationCommand, target: string, revision: string, withdrawal: boolean): void {
|
|
131
138
|
const review = c.state.reviews.findLast((r) => r.target === target && r.revision === revision && r.actor === c.ctx.actor);
|
|
132
139
|
if (!review || review.verdict !== "supports") {
|
|
133
140
|
throw new Error(withdrawal
|
|
@@ -137,6 +144,24 @@ function validateResolution(c: CollaborationCommand, m: PeerMessage, refs: strin
|
|
|
137
144
|
if (!withdrawal && target.startsWith("A") && review.unchecked) throw new Error("horde: resolve requires your supporting review of the current revision; finding limitations remain acceptance debt");
|
|
138
145
|
}
|
|
139
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The owner of the target cannot review it, so a request it sent about its own work (a re-review
|
|
149
|
+
* after a correction) resolves on the recipient's supporting review of the current revision. The
|
|
150
|
+
* refusal names what is missing, since telling the owner to review its own artifact leads nowhere.
|
|
151
|
+
*/
|
|
152
|
+
function requireRecipientReview(c: CollaborationCommand, m: PeerMessage, target: string, revision: string, withdrawal: boolean): void {
|
|
153
|
+
const review = c.state.reviews.findLast((r) => r.target === target && r.revision === revision && r.actor === m.to);
|
|
154
|
+
const short = `${target}@${revision.slice(0, 12)}`;
|
|
155
|
+
const prefix = `horde: ${target} is yours, so ${m.id} resolves on ${m.to}'s review`;
|
|
156
|
+
if (!review || review.verdict !== "supports") {
|
|
157
|
+
const missing = review ? `${review.id} is ${review.verdict}, not supports` : `no supporting review of ${short} by ${m.to} yet`;
|
|
158
|
+
throw new Error(`${prefix}: ${missing}; ask ${m.to} to review the current revision`);
|
|
159
|
+
}
|
|
160
|
+
if (!withdrawal && target.startsWith("A") && review.unchecked) {
|
|
161
|
+
throw new Error(`${prefix}: ${review.id} left unchecked: ${review.unchecked}; ask ${m.to} for a full review of ${short}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
140
165
|
function attachEvidence(c: CollaborationCommand): EventDraft {
|
|
141
166
|
const { ctx, state, params: p, seq } = c;
|
|
142
167
|
const target = requireParam(p.target, "target", p.action);
|
package/src/tool/register.ts
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
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
|
|
156
|
+
private readonly swarm: WidgetSwarm;
|
|
85
157
|
private readonly theme: Theme;
|
|
86
158
|
private readonly now: () => number;
|
|
87
159
|
|
|
88
|
-
constructor(
|
|
89
|
-
this.
|
|
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.
|
|
167
|
+
return widgetLines(this.swarm, this.now(), width, this.theme);
|
|
96
168
|
}
|
|
97
169
|
|
|
98
170
|
invalidate(): void {}
|