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/README.md +22 -3
- package/docs/REFERENCE.md +86 -11
- package/package.json +1 -1
- package/src/args.ts +107 -47
- 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/member.ts +47 -0
- package/src/prompt.ts +29 -5
- 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 +24 -14
- package/src/run/launch.ts +179 -34
- package/src/run/setup.ts +35 -25
- 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 +64 -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 +61 -32
- package/src/tool/register.ts +3 -1
- package/src/widget.ts +80 -8
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
|
|
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
|
|
42
|
-
const widgetOwners = new WeakMap<ExtensionUIContext,
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 =
|
|
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.
|
|
60
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
70
|
-
return new HordeWidget(
|
|
114
|
+
swarm.tui = tui;
|
|
115
|
+
return new HordeWidget(swarm, theme);
|
|
71
116
|
},
|
|
72
117
|
{ placement: "aboveEditor" },
|
|
73
118
|
);
|
|
74
|
-
|
|
75
|
-
|
|
119
|
+
swarm.timer = setInterval(() => refreshSwarm(swarm), RENDER_INTERVAL_MS);
|
|
120
|
+
swarm.timer.unref();
|
|
76
121
|
}
|
|
77
122
|
|
|
78
|
-
export function unmountWidget(
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
if (!
|
|
82
|
-
widgetOwners.delete(
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
120
|
+
const base = `/runs/${encodeURIComponent(runId)}`;
|
|
121
121
|
switch (letter) {
|
|
122
|
-
case "I": return
|
|
123
|
-
case "F": return
|
|
124
|
-
case "T": return
|
|
125
|
-
case "C": return
|
|
126
|
-
case "R": return
|
|
127
|
-
case "A": case "V": case "Q": return
|
|
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
|
+
}
|
package/src/tool/actions.ts
CHANGED
|
@@ -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
|
|
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,19 +450,19 @@ 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
|
/**
|
|
@@ -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] : [] });
|
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 {}
|