agent-horde 0.2.0 → 0.3.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/README.md +13 -2
- package/docs/REFERENCE.md +36 -9
- package/package.json +2 -2
- package/src/args.ts +30 -4
- package/src/citations.ts +16 -5
- package/src/dashboard-data.ts +31 -4
- package/src/dashboard-html.ts +4 -0
- package/src/format.ts +5 -0
- package/src/ledger/findings.ts +25 -6
- package/src/member.ts +22 -7
- package/src/prompt.ts +34 -7
- package/src/run/budget.ts +30 -0
- package/src/run/completions.ts +6 -1
- package/src/run/controls.ts +2 -1
- package/src/run/finalize.ts +15 -13
- package/src/run/launch.ts +2 -1
- package/src/run/repair.ts +138 -26
- package/src/run/setup.ts +3 -1
- package/src/run/solved.ts +21 -8
- package/src/run/supervise.ts +3 -2
- package/src/run/swarm-finalize.ts +3 -0
- package/src/run/swarm-report.ts +2 -1
- package/src/run/swarm.ts +2 -1
- package/src/run/types.ts +8 -0
- package/src/tool/actions.ts +7 -7
- package/src/tool/collaboration-actions.ts +31 -6
- package/src/tool/params.ts +14 -2
- package/src/widget.ts +12 -2
package/src/run/supervise.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { reconcileOwnedIdeas } from "../ledger/ideas.js";
|
|
|
9
9
|
import { cleanStopRequestedFor, clearHeartbeat, readHeartbeat } from "../ledger/run-signals.js";
|
|
10
10
|
import { resetMemberState, spawnMember } from "../member.js";
|
|
11
11
|
import type { MemberState } from "../member.js";
|
|
12
|
+
import { checkBudget } from "./budget.js";
|
|
12
13
|
import { hardStopMembers } from "./controls.js";
|
|
13
14
|
import { recordLifecycle } from "./lifecycle.js";
|
|
14
15
|
import type { HordeRun, HordeState, Swarm } from "./types.js";
|
|
@@ -72,7 +73,7 @@ export async function spawnOne(
|
|
|
72
73
|
approve: ctx.isProjectTrusted(),
|
|
73
74
|
signal: controller.signal,
|
|
74
75
|
role: launch.roles[member.index - 1],
|
|
75
|
-
onChange: () => { onProgress?.(); refreshRun(run); },
|
|
76
|
+
onChange: () => { onProgress?.(); checkBudget(launch); refreshRun(run); },
|
|
76
77
|
onSpawn: (proc) => run.procs.set(member.name, proc),
|
|
77
78
|
onExit: () => {
|
|
78
79
|
run.controller.signal.removeEventListener("abort", onMasterAbort);
|
|
@@ -84,7 +85,7 @@ export async function spawnOne(
|
|
|
84
85
|
await recordLifecycle(run, "member-exit", {
|
|
85
86
|
member: member.name, attempt: member.attempt, status: member.status, endCause: member.endCause,
|
|
86
87
|
exitCode: member.exitCode, turns: member.turns, toolCalls: member.toolCalls,
|
|
87
|
-
tokens: member.usage.input + member.usage.output, partial: member.partial === true,
|
|
88
|
+
tokens: member.usage.input + member.usage.output, cost: member.usage.cost, partial: member.partial === true,
|
|
88
89
|
});
|
|
89
90
|
}
|
|
90
91
|
|
|
@@ -15,6 +15,8 @@ export interface SwarmRecord {
|
|
|
15
15
|
count: number;
|
|
16
16
|
startedAt: number;
|
|
17
17
|
untilStop: boolean;
|
|
18
|
+
/** The swarm's `--budget` in dollars, when one was given. */
|
|
19
|
+
budget?: number | undefined;
|
|
18
20
|
partition: string[];
|
|
19
21
|
runs: Array<{ label: string; runId: string; runDir: string; hordeDir: string; startedAt: number; endedAt?: number | undefined; outcome?: string | undefined }>;
|
|
20
22
|
/** The merge step: when the swarm deliverable helper ran and whether it wrote anything. */
|
|
@@ -30,6 +32,7 @@ export function swarmRecord(swarm: Swarm): SwarmRecord {
|
|
|
30
32
|
count: swarm.count,
|
|
31
33
|
startedAt: swarm.startedAt,
|
|
32
34
|
untilStop: swarm.untilStop === true,
|
|
35
|
+
budget: swarm.budget,
|
|
33
36
|
partition: swarm.partition ?? [],
|
|
34
37
|
runs: swarm.runs.map((run) => ({
|
|
35
38
|
label: `h${run.horde}`,
|
package/src/run/swarm-report.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import { formatDateTime, formatElapsed, formatTokens, oneLine } from "../format.js";
|
|
2
|
+
import { formatDateTime, formatElapsed, formatTokens, formatUsd, oneLine } from "../format.js";
|
|
3
3
|
import { listFindingsForRun } from "../ledger/findings.js";
|
|
4
4
|
import { statusCounts, totalUsage } from "../prompt.js";
|
|
5
5
|
import { hordeLabel } from "./types.js";
|
|
@@ -96,6 +96,7 @@ export function swarmReportMarkdown(swarm: Swarm, now: number = Date.now()): str
|
|
|
96
96
|
`- elapsed: ${formatElapsed(now - swarm.startedAt)}`,
|
|
97
97
|
`- hordes: ${swarm.hordes} x ${swarm.count} members`,
|
|
98
98
|
`- until stop: ${swarm.untilStop ? "yes" : "no"}`,
|
|
99
|
+
...(swarm.budget === undefined ? [] : [`- budget: ${formatUsd(swarm.budget)} across every horde`]),
|
|
99
100
|
`- outcomes: ${rows.map((row) => `${row.label} ${row.outcome}`).join(", ")}`,
|
|
100
101
|
`- merged deliverable: ${swarm.deliverable ? "DELIVERABLE.md" : "not written"}`,
|
|
101
102
|
"",
|
package/src/run/swarm.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { formatDateTime, formatRunId, taskHash } from "../format.js";
|
|
3
|
+
import { formatDateTime, formatRunId, formatUsd, taskHash } from "../format.js";
|
|
4
4
|
import { hordePaths, initHordeDirAt } from "../ledger/files.js";
|
|
5
5
|
import type { SiblingHorde } from "../member.js";
|
|
6
6
|
import { hordeLabel } from "./types.js";
|
|
@@ -99,6 +99,7 @@ export function swarmMarkdown(swarm: Swarm): string {
|
|
|
99
99
|
`Hordes: ${swarm.hordes} x ${swarm.count} members (${hordeLabel(1)} .. ${hordeLabel(swarm.hordes)})`,
|
|
100
100
|
`Model: ${swarm.runs[0]?.model ?? "parent default"}`,
|
|
101
101
|
`Until stop: ${swarm.untilStop ? "yes (every horde continues until stop-clean or stop-hard)" : "no"}`,
|
|
102
|
+
`Budget: ${swarm.budget === undefined ? "none" : `${formatUsd(swarm.budget)} across every horde`}`,
|
|
102
103
|
`Partition: ${swarm.plan ? "plan layers dealt round-robin" : "planner helper"}`,
|
|
103
104
|
`Task-SHA256: ${taskHash(swarm.prompt)}`,
|
|
104
105
|
"",
|
package/src/run/types.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface HordeRun extends MemberRunInfo {
|
|
|
38
38
|
repairTokens?: number;
|
|
39
39
|
/** Members whose queued repair work was skipped because the budget could not fund an attempt. */
|
|
40
40
|
repairSkipped?: string[];
|
|
41
|
+
/** Members whose remaining repair threads their own completed attempt could not move. */
|
|
42
|
+
repairStalled?: string[];
|
|
41
43
|
/** True when a user command or session shutdown ended the run. */
|
|
42
44
|
userStop?: boolean | undefined;
|
|
43
45
|
/** Human-readable stop reason for the report, e.g. "stopped by user". */
|
|
@@ -81,6 +83,8 @@ export interface HordeRun extends MemberRunInfo {
|
|
|
81
83
|
solvedContest?: { proposal: SolvedSignal; blockers: number[] } | undefined;
|
|
82
84
|
/** True once the parent has warned that a validated solve still has unverified findings. */
|
|
83
85
|
solvedVerificationNotice?: boolean | undefined;
|
|
86
|
+
/** The swarm's `--budget` in dollars, recorded per run for TASK.md and the events. */
|
|
87
|
+
budget?: number | undefined;
|
|
84
88
|
/** Solves banked so far in an `--until-stop` run. */
|
|
85
89
|
solveRounds?: number | undefined;
|
|
86
90
|
/** The latest banked solve of an `--until-stop` run: the answer unless a later proposal is validated. */
|
|
@@ -114,6 +118,10 @@ export interface Swarm {
|
|
|
114
118
|
/** Per-horde workstream, indexed by horde - 1; absent for a swarm of one. */
|
|
115
119
|
partition?: string[] | undefined;
|
|
116
120
|
untilStop?: boolean | undefined;
|
|
121
|
+
/** `--budget`: combined member spend in dollars at which every horde is clean-stopped. */
|
|
122
|
+
budget?: number | undefined;
|
|
123
|
+
/** True once the budget tripped, so the stop is requested exactly once. */
|
|
124
|
+
budgetHit?: boolean | undefined;
|
|
117
125
|
dashboardUrl?: string | undefined;
|
|
118
126
|
/** Generated merged deliverable across the hordes' deliverables. */
|
|
119
127
|
deliverable?: string | undefined;
|
package/src/tool/actions.ts
CHANGED
|
@@ -7,7 +7,7 @@ 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";
|
|
@@ -23,7 +23,7 @@ import { readEvents, recordEvent } from "../ledger/events.js";
|
|
|
23
23
|
import type { EventContext } from "../ledger/events.js";
|
|
24
24
|
|
|
25
25
|
const CLEAN_STOP_NOTICE =
|
|
26
|
-
"A clean stop
|
|
26
|
+
"A clean stop is in effect for this run. Stop starting new work. Mark your claimed ideas done " +
|
|
27
27
|
"or drop them, post a one-line status to BOARD.md, then write your final summary and make no " +
|
|
28
28
|
"further tool calls.";
|
|
29
29
|
|
|
@@ -466,13 +466,13 @@ function resourcesAction({ hordeDir, siblings }: ActionContext): ActionResult {
|
|
|
466
466
|
}
|
|
467
467
|
|
|
468
468
|
/**
|
|
469
|
-
* An answer that cites a superseded finding, or
|
|
470
|
-
* stale reading as the run's result. The proposer cites the
|
|
471
|
-
* 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.
|
|
472
472
|
*/
|
|
473
473
|
function staleAnswer(ctx: ActionContext, text: string, cites?: Record<string, string>): string | undefined {
|
|
474
474
|
const runId = requireRun(ctx);
|
|
475
|
-
const stale =
|
|
475
|
+
const stale = staleAnswerFor(ctx.hordeDir, runId, text, cites);
|
|
476
476
|
if (!stale.length) return undefined;
|
|
477
477
|
return `cites ${stale.join(", ")}`;
|
|
478
478
|
}
|
|
@@ -489,7 +489,7 @@ async function solvedAction(ctx: ActionContext): Promise<ActionResult> {
|
|
|
489
489
|
runId,
|
|
490
490
|
ctx.member,
|
|
491
491
|
ctx.params.text,
|
|
492
|
-
answerCitations(listFindingsForRun(ctx.hordeDir, runId), text),
|
|
492
|
+
answerCitations(listFindingsForRun(ctx.hordeDir, runId), text, collaborationState(ctx.hordeDir, runId).artifacts),
|
|
493
493
|
);
|
|
494
494
|
return {
|
|
495
495
|
text: alreadySolved
|
|
@@ -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/params.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Type } from "typebox";
|
|
1
|
+
import { Type, type TUnsafe } from "typebox";
|
|
3
2
|
import { FINDING_CONFIDENCES, FINDING_IMPORTANCE, FINDING_SUPPORT } from "../ledger/findings.js";
|
|
4
3
|
|
|
4
|
+
/**
|
|
5
|
+
* A string enum schema whose static type is the union of its values.
|
|
6
|
+
*
|
|
7
|
+
* pi-ai's StringEnum returns a pi-schema type since pi 1.5, which TypeBox's `Type.Object`
|
|
8
|
+
* cannot infer a static type from, so the schema is built here with TypeBox directly.
|
|
9
|
+
*/
|
|
10
|
+
function StringEnum<T extends readonly string[]>(
|
|
11
|
+
values: T,
|
|
12
|
+
options: { description?: string } = {},
|
|
13
|
+
): TUnsafe<T[number]> {
|
|
14
|
+
return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
|
|
15
|
+
}
|
|
16
|
+
|
|
5
17
|
export const HordeParams = Type.Object({
|
|
6
18
|
action: StringEnum(
|
|
7
19
|
[
|
package/src/widget.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
-
import { formatElapsed, formatTokens } from "./format.js";
|
|
3
|
+
import { formatElapsed, formatTokens, formatUsd } from "./format.js";
|
|
4
4
|
import type { MemberState } from "./member.js";
|
|
5
5
|
import { memberActivity, statusCounts, totalUsage } from "./prompt.js";
|
|
6
6
|
|
|
@@ -16,12 +16,15 @@ interface WidgetRun {
|
|
|
16
16
|
reported?: boolean | undefined;
|
|
17
17
|
finalized?: boolean | undefined;
|
|
18
18
|
outcome?: string | undefined;
|
|
19
|
+
/** `--budget` in dollars; the header shows spend against it. */
|
|
20
|
+
budget?: number | undefined;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
interface WidgetSwarm {
|
|
22
24
|
id: string;
|
|
23
25
|
runs: WidgetRun[];
|
|
24
26
|
startedAt: number;
|
|
27
|
+
budget?: number | undefined;
|
|
25
28
|
dashboardUrl?: string | undefined;
|
|
26
29
|
/** The swarm step still running after every horde finalized, e.g. ["merge"]. */
|
|
27
30
|
finalizing?: string[] | undefined;
|
|
@@ -56,6 +59,7 @@ export function headerLine(run: WidgetRun, now: number, theme: Theme): string {
|
|
|
56
59
|
if (c.failed) parts.push(theme.fg("error", `${c.failed} failed`));
|
|
57
60
|
if (c.stopped) parts.push(theme.fg("warning", `${c.stopped} stopped`));
|
|
58
61
|
parts.push(formatElapsed(now - run.startedAt));
|
|
62
|
+
if (run.budget !== undefined) parts.push(budgetPart(run.members, run.budget, theme));
|
|
59
63
|
if (run.cleanStopUntil !== undefined) {
|
|
60
64
|
const left = Math.max(0, Math.ceil((run.cleanStopUntil - now) / 1000));
|
|
61
65
|
parts.push(theme.fg("warning", `clean stop in ${left}s`));
|
|
@@ -67,6 +71,11 @@ export function headerLine(run: WidgetRun, now: number, theme: Theme): string {
|
|
|
67
71
|
return `${parts.join(" ")} ${theme.fg("dim", "/agent-horde status | stop-hard | stop-clean")}`;
|
|
68
72
|
}
|
|
69
73
|
|
|
74
|
+
/** `$1.20 of $5.00`: spend over every session so far against the `--budget`. */
|
|
75
|
+
function budgetPart(members: MemberState[], budget: number, theme: Theme): string {
|
|
76
|
+
return theme.fg("dim", `${formatUsd(totalUsage(members).cost)} of ${formatUsd(budget)}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
70
79
|
function memberLine(m: MemberState, now: number, frame: number, theme: Theme): string {
|
|
71
80
|
const elapsed = formatElapsed((m.endedAt ?? now) - m.startedAt);
|
|
72
81
|
const activity = memberActivity(m);
|
|
@@ -118,7 +127,7 @@ export function hordeLine(run: WidgetRun, now: number, frame: number, theme: The
|
|
|
118
127
|
];
|
|
119
128
|
if (c.failed) parts.push(theme.fg("error", `${c.failed} failed`));
|
|
120
129
|
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
|
|
130
|
+
parts.push(theme.fg("dim", `t${usage.turns} ${formatTokens(usage.input + usage.output)} tok ${formatUsd(usage.cost)}`));
|
|
122
131
|
parts.push(theme.fg("muted", hordeActivity(run, c)));
|
|
123
132
|
return ` ${glyph} ${`h${run.horde}`.padEnd(3)} ${parts.join(" ")}`;
|
|
124
133
|
}
|
|
@@ -135,6 +144,7 @@ function swarmHeader(swarm: WidgetSwarm, now: number, theme: Theme): string {
|
|
|
135
144
|
if (c.failed) parts.push(theme.fg("error", `${c.failed} failed`));
|
|
136
145
|
if (c.stopped) parts.push(theme.fg("warning", `${c.stopped} stopped`));
|
|
137
146
|
parts.push(formatElapsed(now - swarm.startedAt));
|
|
147
|
+
if (swarm.budget !== undefined) parts.push(budgetPart(members, swarm.budget, theme));
|
|
138
148
|
if (swarm.finalizing && swarm.finalizing.length > 0) {
|
|
139
149
|
parts.push(theme.fg("warning", `synthesizing ${swarm.finalizing.join(", ")}`));
|
|
140
150
|
}
|