agent-horde 0.1.0 → 0.1.2

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 CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p align="center">
8
8
  <a href="https://www.npmjs.com/package/agent-horde"><img src="https://img.shields.io/npm/v/agent-horde" alt="npm version"></a>
9
- <a href="LICENSE"><img src="https://img.shields.io/github/license/eliyastein/agent-horde" alt="MIT license"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
10
10
  <img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen" alt="Node 20 or newer">
11
11
  <a href="https://pi.dev/packages"><img src="https://img.shields.io/badge/pi-package-6f42c1" alt="pi package"></a>
12
12
  <a href="https://github.com/eliyastein/agent-horde/issues"><img src="https://img.shields.io/github/issues/eliyastein/agent-horde" alt="open issues"></a>
@@ -88,7 +88,7 @@ pi install npm:agent-horde
88
88
  Or from the repository, pinned to a tag:
89
89
 
90
90
  ```
91
- pi install git:github.com/eliyastein/agent-horde@v0.1.0
91
+ pi install git:github.com/eliyastein/agent-horde@v0.1.2
92
92
  ```
93
93
 
94
94
  To hack on it, clone and install in place. pi loads the files from the clone, so edits take effect
package/docs/REFERENCE.md CHANGED
@@ -81,7 +81,7 @@ Every page has a JSON twin under `/api/<same path>`.
81
81
  | `/runs/<id>/sessions[/<file>[/<i>]]` | session files not mapped to a member (runs older than per-member session dirs) |
82
82
  | `/runs/<id>/files/<NAME>` | any run document; 404 until written |
83
83
  | `/runs/<id>/collaboration` | messages, review scope, artifact acceptance, resources, event audit |
84
- | `/runs/<id>/graph` | the run as a swimlane: one lane per member plus the parent, phase bands, every event a link to its ledger entry (`?w=<px>`, `?from=<s>&to=<s>`) |
84
+ | `/runs/<id>/graph` | the run as a swimlane: one lane per member plus the parent, phase bands, every event a link to its ledger entry (`?w=<px>`, `?from=<s>&to=<s>`); the axis is real time until the last member exits, then the synthesis phases share a compressed tail |
85
85
  | `/runs/<id>/graph.mmd` | the same run as a Mermaid flowchart with absolute dashboard links (text/plain) |
86
86
  | `/ideas`, `/board`, `/findings`, `/claims`, `/facts`, `/resources` | the shared ledgers; `?run=<id>\|all`, `?view=clusters`, `?owner=<m>` filter them |
87
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-horde",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Run a horde of pi agents on one task: shared ledgers, peer review, an auditable event log, and a live dashboard",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,6 +21,9 @@ const HEADER_H = 36;
21
21
  const AXIS_H = 18;
22
22
  const TICK_STEPS_MS = [5_000, 10_000, 15_000, 30_000, 60_000, 120_000, 300_000, 600_000, 900_000, 1_800_000, 3_600_000, 7_200_000];
23
23
  const MAX_TICKS = 14;
24
+ /** Share of the plot for everything after the last member exits, when that tail would otherwise dominate. */
25
+ const TAIL_FRACTION = 0.15;
26
+ const TAIL_AXIS_H = 30;
24
27
 
25
28
  const TONE_COLORS: Record<EdgeTone, string> = {
26
29
  structure: "#000", collaboration: "#1f5fbf", verification: "#1a7f37", negative: "#c62828", lifecycle: "#888",
@@ -57,13 +60,49 @@ interface LaneBand {
57
60
  interface Layout {
58
61
  width: number;
59
62
  height: number;
63
+ axisH: number;
60
64
  t0: number;
61
65
  t1: number;
66
+ cut?: number | undefined;
62
67
  bands: LaneBand[];
63
68
  placed: Map<string, Placed>;
64
69
  x: (t: number) => number;
65
70
  }
66
71
 
72
+ export interface TimeScale {
73
+ t0: number;
74
+ t1: number;
75
+ /** Where the axis switches from real time to the compressed tail; absent when the axis is linear. */
76
+ cut?: number | undefined;
77
+ x: (t: number) => number;
78
+ }
79
+
80
+ function unit(value: number): number {
81
+ return Math.min(Math.max(0, value), 1);
82
+ }
83
+
84
+ /**
85
+ * Real time until the last member exits. The synthesis that follows can outlast discovery, so it
86
+ * gets a fixed share of the width instead of squeezing the members' work into the left margin.
87
+ * The cut applies as soon as a parent phase starts after the members are done, so a live run's
88
+ * main axis freezes the moment synthesis begins; without such a phase only a long tail triggers it.
89
+ */
90
+ export function graphTimeScale(graph: RunGraph, window: GraphWindow): TimeScale {
91
+ const { t0, t1 } = windowOf(graph, window);
92
+ const plotW = window.width;
93
+ const cut = graph.membersEnd;
94
+ const phaseAfterMembers = graph.phases.some((phase) => phase.from >= cut && phase.from < t1);
95
+ const longTail = (t1 - cut) / (t1 - t0) > TAIL_FRACTION;
96
+ if (cut <= t0 || cut >= t1 || (!phaseAfterMembers && !longTail)) {
97
+ return { t0, t1, x: (t) => LABEL_W + unit((t - t0) / (t1 - t0)) * plotW };
98
+ }
99
+ const mainW = plotW * (1 - TAIL_FRACTION);
100
+ const tailW = plotW - mainW;
101
+ const x = (t: number): number =>
102
+ t <= cut ? LABEL_W + unit((t - t0) / (cut - t0)) * mainW : LABEL_W + mainW + unit((t - cut) / (t1 - cut)) * tailW;
103
+ return { t0, t1, cut, x };
104
+ }
105
+
67
106
  function nodeWidth(node: GraphNode): number {
68
107
  return node.marker ? DOT_R * 2 : Math.max(MIN_BOX_W, Math.round(node.label.length * CHAR_W) + 8);
69
108
  }
@@ -75,13 +114,16 @@ function windowOf(graph: RunGraph, window: GraphWindow): { t0: number; t1: numbe
75
114
  return { t0, t1: requested > t0 ? requested : t0 + 1_000 };
76
115
  }
77
116
 
78
- /** Rows within a lane: a node takes the first row whose last occupant ends before it starts. */
79
- function packLane(nodes: GraphNode[], x: (t: number) => number, top: number): { placed: Placed[]; rows: number } {
117
+ /**
118
+ * Rows within a lane: a node takes the first row whose last occupant ends before it starts. A box
119
+ * anchored near the end of the axis is pulled left so its right edge stays inside the plot.
120
+ */
121
+ function packLane(nodes: GraphNode[], x: (t: number) => number, top: number, plotEnd: number): { placed: Placed[]; rows: number } {
80
122
  const rowEnds: number[] = [];
81
123
  const placed: Placed[] = [];
82
124
  for (const node of nodes) {
83
125
  const w = nodeWidth(node);
84
- const left = node.marker ? x(node.at) - DOT_R : x(node.at);
126
+ const left = node.marker ? x(node.at) - DOT_R : Math.min(x(node.at), plotEnd - w);
85
127
  let row = rowEnds.findIndex((end) => end + GAP <= left);
86
128
  if (row === -1) row = rowEnds.length;
87
129
  rowEnds[row] = left + w;
@@ -91,22 +133,22 @@ function packLane(nodes: GraphNode[], x: (t: number) => number, top: number): {
91
133
  }
92
134
 
93
135
  function layoutGraph(graph: RunGraph, window: GraphWindow): Layout {
94
- const { t0, t1 } = windowOf(graph, window);
136
+ const { t0, t1, cut, x } = graphTimeScale(graph, window);
95
137
  const plotW = window.width;
96
- const x = (t: number): number => LABEL_W + Math.min(Math.max(0, (t - t0) / (t1 - t0)), 1) * plotW;
97
138
  const visible = graph.nodes.filter((node) => node.at >= t0 && node.at <= t1).sort((a, b) => a.at - b.at);
98
139
  const placed = new Map<string, Placed>();
99
140
  const bands: LaneBand[] = [];
100
141
  let y = HEADER_H;
101
142
  for (const lane of graph.lanes) {
102
143
  const own = visible.filter((node) => node.lane === lane.id);
103
- const packed = packLane(own, x, y);
144
+ const packed = packLane(own, x, y, LABEL_W + plotW);
104
145
  for (const item of packed.placed) placed.set(item.node.id, item);
105
146
  const h = PAD * 2 + packed.rows * ROW_H;
106
147
  bands.push({ lane, y, h });
107
148
  y += h;
108
149
  }
109
- return { width: LABEL_W + plotW + PAD, height: y + AXIS_H, t0, t1, bands, placed, x };
150
+ const axisH = cut === undefined ? AXIS_H : TAIL_AXIS_H;
151
+ return { width: LABEL_W + plotW + PAD, height: y + axisH, axisH, t0, t1, cut, bands, placed, x };
110
152
  }
111
153
 
112
154
  function renderDefs(): string {
@@ -117,8 +159,16 @@ function renderDefs(): string {
117
159
  return `<defs>${markers.join("")}</defs>`;
118
160
  }
119
161
 
162
+ /** The full label if the band is wide enough, else the bare id, else nothing; the band's title keeps the full text. */
163
+ function phaseText(id: string, label: string, bandW: number): string | undefined {
164
+ const fits = (text: string): boolean => text.length * CHAR_W + 6 <= bandW;
165
+ if (fits(label)) return label;
166
+ if (fits(id)) return id;
167
+ return undefined;
168
+ }
169
+
120
170
  function renderPhases(graph: RunGraph, layout: Layout): string {
121
- const bottom = layout.height - AXIS_H;
171
+ const bottom = layout.height - layout.axisH;
122
172
  return graph.phases
123
173
  .map((phase, index) => {
124
174
  const from = Math.max(phase.from, layout.t0);
@@ -127,9 +177,10 @@ function renderPhases(graph: RunGraph, layout: Layout): string {
127
177
  const x0 = layout.x(from);
128
178
  const w = Math.max(1, layout.x(to) - x0);
129
179
  const label = `${phase.id} +${formatElapsed(phase.from - graph.start)} to +${formatElapsed(phase.to - graph.start)}`;
180
+ const text = phaseText(phase.id, label, w);
130
181
  return (
131
- `<rect class="phase${index % 2 ? " phase-alt" : ""}" x="${px(x0)}" y="${HEADER_H - 16}" width="${px(w)}" height="${px(bottom - HEADER_H + 16)}"/>` +
132
- `<text x="${px(x0 + 3)}" y="${HEADER_H - 5}" class="phase-label"><title>${e(label)}</title>${e(label)}</text>`
182
+ `<rect class="phase${index % 2 ? " phase-alt" : ""}" x="${px(x0)}" y="${HEADER_H - 16}" width="${px(w)}" height="${px(bottom - HEADER_H + 16)}"><title>${e(label)}</title></rect>` +
183
+ (text === undefined ? "" : `<text x="${px(x0 + 3)}" y="${HEADER_H - 5}" class="phase-label">${e(text)}</text>`)
133
184
  );
134
185
  })
135
186
  .join("");
@@ -146,17 +197,36 @@ function renderLanes(layout: Layout): string {
146
197
  .join("");
147
198
  }
148
199
 
200
+ function tick(graph: RunGraph, layout: Layout, t: number, cls: string): string {
201
+ const x = layout.x(t);
202
+ const y = layout.height - layout.axisH;
203
+ const anchor = t >= layout.t1 ? ` text-anchor="end" x="${px(x - 2)}"` : ` x="${px(x + 2)}"`;
204
+ return (
205
+ `<line class="tick" x1="${px(x)}" y1="${HEADER_H}" x2="${px(x)}" y2="${px(y)}"/>` +
206
+ `<text${anchor} y="${px(y + 12)}" class="${cls}">+${e(formatElapsed(t - graph.start))}</text>`
207
+ );
208
+ }
209
+
210
+ /** Regular ticks up to the cut; past it only phase boundaries and the end, since the scale differs. */
149
211
  function renderAxis(graph: RunGraph, layout: Layout): string {
150
- const span = layout.t1 - layout.t0;
212
+ const mainEnd = layout.cut ?? layout.t1;
213
+ const span = mainEnd - layout.t0;
151
214
  const step = TICK_STEPS_MS.find((candidate) => span / candidate <= MAX_TICKS) ?? TICK_STEPS_MS[TICK_STEPS_MS.length - 1]!;
152
215
  const firstTick = graph.start + Math.ceil((layout.t0 - graph.start) / step) * step;
153
216
  const parts: string[] = [];
154
- const y = layout.height - AXIS_H;
155
- for (let t = firstTick; t <= layout.t1; t += step) {
156
- const x = layout.x(t);
157
- parts.push(`<line class="tick" x1="${px(x)}" y1="${HEADER_H}" x2="${px(x)}" y2="${px(y)}"/>`);
158
- parts.push(`<text x="${px(x + 2)}" y="${px(y + 12)}" class="tick-label">+${e(formatElapsed(t - graph.start))}</text>`);
159
- }
217
+ for (let t = firstTick; t <= mainEnd; t += step) parts.push(tick(graph, layout, t, "tick-label"));
218
+ if (layout.cut === undefined) return parts.join("");
219
+ const bottom = layout.height - layout.axisH;
220
+ const cx = layout.x(layout.cut);
221
+ parts.push(`<line class="cut" x1="${px(cx)}" y1="${HEADER_H - 16}" x2="${px(cx)}" y2="${px(bottom)}"/>`);
222
+ const note = `members done +${formatElapsed(layout.cut - graph.start)}; the rest is compressed`;
223
+ parts.push(`<text x="${px(cx - 3)}" y="${px(bottom + 24)}" text-anchor="end" class="tick-label tail">${e(note)}</text>`);
224
+ // One tick per distinct second: phase boundaries and the end often sit milliseconds apart.
225
+ const { cut, t1 } = layout;
226
+ const tailTicks = new Map<string, number>();
227
+ const candidates = graph.phases.flatMap((phase) => [phase.from, phase.to]).filter((t) => t > cut && t < t1);
228
+ for (const t of [...candidates, layout.t1]) tailTicks.set(formatElapsed(t - graph.start), t);
229
+ for (const t of [...tailTicks.values()].sort((a, b) => a - b)) parts.push(tick(graph, layout, t, "tick-label tail"));
160
230
  return parts.join("");
161
231
  }
162
232
 
@@ -216,6 +286,7 @@ export const GRAPH_CSS =
216
286
  ".graph{overflow:auto;border:1px solid #000;margin:4px 0;background:#fff}" +
217
287
  "svg text{fill:#000}svg .phase{fill:#000;opacity:.04}svg .phase-alt{opacity:.09}svg .phase-label{font-weight:bold}" +
218
288
  "svg .lane-line{stroke:#bbb}svg .tick{stroke:#e5e5e5}svg .tick-label{fill:#555}" +
289
+ "svg .cut{stroke:#999;stroke-width:1.5;stroke-dasharray:4 3}svg .tail{font-style:italic}" +
219
290
  "svg .n{stroke:#000;stroke-width:1}svg .n-prior{stroke-dasharray:3 2}" +
220
291
  "svg .m{stroke:#333;stroke-width:1}svg .m-rejected{stroke:#c62828;stroke-width:2}svg .m-noop{stroke-dasharray:2 1}" +
221
292
  "svg a:hover .n,svg a:hover .m{stroke-width:2.5}svg a:hover text{text-decoration:underline}" +
package/src/run-graph.ts CHANGED
@@ -54,6 +54,8 @@ export interface RunGraph {
54
54
  runId: string;
55
55
  start: number;
56
56
  end: number;
57
+ /** When the last member activity happened; equals `end` while members still run. */
58
+ membersEnd: number;
57
59
  lanes: GraphLane[];
58
60
  phases: GraphPhase[];
59
61
  nodes: GraphNode[];
@@ -553,6 +555,16 @@ function phasesOf(draft: Draft, nodes: GraphNode[], start: number, end: number):
553
555
  return phases;
554
556
  }
555
557
 
558
+ const TERMINAL_STATUSES = new Set(["done", "failed", "stopped"]);
559
+
560
+ /** The last member-lane event once every member has exited; the whole span while any still runs. */
561
+ function membersEndOf(nodes: GraphNode[], members: GraphMember[], live: boolean, start: number, end: number): number {
562
+ const running = live && members.some((m) => m.status !== undefined && !TERMINAL_STATUSES.has(m.status));
563
+ if (running) return end;
564
+ const times = nodes.filter((n) => n.lane !== "parent" && !n.placeholder).map((n) => n.at);
565
+ return Math.min(end, Math.max(start, ...times));
566
+ }
567
+
556
568
  /** Project a run's events (plus what the parent knows about its members) into lanes, phases, nodes, and edges. */
557
569
  export function buildRunGraph(input: RunGraphInput): RunGraph {
558
570
  const events = input.events.filter((event) => event.outcome !== "intent");
@@ -570,6 +582,7 @@ export function buildRunGraph(input: RunGraphInput): RunGraph {
570
582
  runId: input.runId,
571
583
  start,
572
584
  end,
585
+ membersEnd: membersEndOf(nodes, members, input.now !== undefined, start, end),
573
586
  lanes: lanesOf(draft, nodes, members),
574
587
  phases: phasesOf(draft, nodes, start, end),
575
588
  nodes,