agent-horde 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dashboard.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import * as http from "node:http";
2
2
  import {
3
3
  DashboardError,
4
- latestRunId,
5
4
  listRunSessions,
6
5
  listRuns,
7
6
  loadBoard,
@@ -18,10 +17,22 @@ import {
18
17
  loadResources,
19
18
  loadSessionEntry,
20
19
  loadSessionTranscript,
20
+ loadSwarm,
21
+ loadSwarmBoard,
22
+ loadSwarmClaims,
23
+ loadSwarmCollaboration,
24
+ loadSwarmFacts,
25
+ loadSwarmFile,
26
+ loadSwarmFindings,
27
+ loadSwarmGraph,
28
+ loadSwarmIdeas,
29
+ loadSwarmResources,
21
30
  loadTranscript,
22
31
  loadTranscriptEntry,
23
32
  MEMBER_RE,
24
- RUN_ID_RE,
33
+ requireRun,
34
+ swarmHordes,
35
+ swarmOfRun,
25
36
  } from "./dashboard-data.js";
26
37
  import type { DashboardSource, GraphView } from "./dashboard-data.js";
27
38
  import {
@@ -42,10 +53,19 @@ import {
42
53
  renderRunOverview,
43
54
  renderResources,
44
55
  renderSessions,
56
+ renderSwarm,
57
+ renderSwarmBoard,
58
+ renderSwarmClaims,
59
+ renderSwarmCollaboration,
60
+ renderSwarmFacts,
61
+ renderSwarmFindings,
62
+ renderSwarmGraph,
63
+ renderSwarmIdeas,
64
+ renderSwarmResources,
45
65
  renderTranscript,
46
66
  renderTranscriptEntry,
47
67
  } from "./dashboard-html.js";
48
- import type { RenderContext } from "./dashboard-html.js";
68
+ import type { PageKind, RenderContext, Scope } from "./dashboard-html.js";
49
69
  import { collect, exportDashboard } from "./dashboard-export.js";
50
70
  import type { ExportPage } from "./dashboard-export.js";
51
71
 
@@ -72,6 +92,10 @@ interface Route {
72
92
  live: (vm: unknown, src: DashboardSource, params: string[], query: URLSearchParams) => boolean;
73
93
  load: (src: DashboardSource, params: string[], query: URLSearchParams) => unknown;
74
94
  render: (vm: never, ctx: RenderContext) => string;
95
+ /** What the page is about; params[0] is the run or swarm id for those scopes. */
96
+ scope: "project" | "run" | "swarm";
97
+ /** Which page-row item the page is, so the nav can mark it and horde tabs can keep it. */
98
+ page: PageKind;
75
99
  /** Serve the renderer's output as text/plain instead of a page. */
76
100
  text?: boolean;
77
101
  }
@@ -103,12 +127,6 @@ function queryFrom(query: URLSearchParams): number | "last" {
103
127
  return query.get("from") === "last" ? "last" : queryInt(query, "from", 0, 999_999_999, 0);
104
128
  }
105
129
 
106
- function queryRun(query: URLSearchParams): string {
107
- const value = query.get("run") ?? "all";
108
- if (value !== "all" && !RUN_ID_RE.test(value)) throw new DashboardError(400, `invalid run: ${value}`);
109
- return value;
110
- }
111
-
112
130
  function queryOwner(query: URLSearchParams): string | undefined {
113
131
  const value = query.get("owner");
114
132
  if (value === null || value === "") return undefined;
@@ -116,17 +134,39 @@ function queryOwner(query: URLSearchParams): string | undefined {
116
134
  return value;
117
135
  }
118
136
 
137
+ function queryOrder(query: URLSearchParams): "newest" | "oldest" {
138
+ return queryEnum(query, "order", ["newest", "oldest"] as const, "newest");
139
+ }
140
+
141
+ function queryView(query: URLSearchParams): "list" | "clusters" {
142
+ return queryEnum(query, "view", ["list", "clusters"] as const, "list");
143
+ }
144
+
119
145
  function isActive(src: DashboardSource, runId: string | undefined): boolean {
120
- return runId !== undefined && src.getActive()?.id === runId;
146
+ return runId !== undefined && src.getActive().some((run) => run.id === runId);
121
147
  }
122
148
 
123
149
  function isFinalizing(src: DashboardSource, runId: string | undefined): boolean {
124
- return runId !== undefined && src.getFinalizing?.()?.id === runId;
150
+ return runId !== undefined && (src.getFinalizing?.() ?? []).some((run) => run.id === runId);
151
+ }
152
+
153
+ function isRunLive(src: DashboardSource, runId: string): boolean {
154
+ return isActive(src, runId) || isFinalizing(src, runId);
155
+ }
156
+
157
+ /** A swarm page is live while the swarm is in memory: members running or the merge step pending. */
158
+ function isSwarmLive(src: DashboardSource, swarmId: string | undefined): boolean {
159
+ return swarmId !== undefined && src.getSwarm?.()?.id === swarmId;
160
+ }
161
+
162
+ /** The project ledgers change while a single run (one writing to the project `.horde`) is live. */
163
+ function isProjectLive(src: DashboardSource): boolean {
164
+ return src.getActive().some((run) => run.hordeDir === src.hordeDir);
125
165
  }
126
166
 
127
167
  function memberRunning(src: DashboardSource, runId: string, member: string): boolean {
128
- const state = src.getActive()?.members.find((m) => m.name === member);
129
- return isActive(src, runId) && state !== undefined && (state.status === "running" || state.status === "starting");
168
+ const state = src.getActive().find((run) => run.id === runId)?.members.find((m) => m.name === member);
169
+ return state !== undefined && (state.status === "running" || state.status === "starting");
130
170
  }
131
171
 
132
172
  const p = (params: string[], i: number): string => params[i] ?? "";
@@ -142,84 +182,112 @@ function graphView(query: URLSearchParams): GraphView {
142
182
  };
143
183
  }
144
184
 
145
- const ROUTES: Route[] = [
146
- {
147
- pattern: /^\/$/,
148
- live: () => true,
149
- load: (src) => listRuns(src),
150
- render: renderRunIndex,
151
- },
152
- {
153
- pattern: /^\/runs\/([^/]+)$/,
154
- live: (_vm, src, params) => isActive(src, p(params, 0)) || isFinalizing(src, p(params, 0)),
155
- load: (src, params) => loadRunOverview(src, p(params, 0)),
156
- render: renderRunOverview,
157
- },
158
- {
159
- pattern: /^\/runs\/([^/]+)\/collaboration$/,
160
- live: (_vm, src, params) => isActive(src, p(params, 0)),
161
- load: (src, params, query) => loadCollaboration(src, p(params, 0), queryInt(query, "from", 0, 999_999_999, 0)),
162
- render: renderCollaboration,
163
- },
164
- {
165
- pattern: /^\/runs\/([^/]+)\/graph$/,
166
- live: (_vm, src, params) => isActive(src, p(params, 0)) || isFinalizing(src, p(params, 0)),
167
- load: (src, params, query) => loadRunGraph(src, p(params, 0), graphView(query)),
168
- render: renderGraph,
169
- },
170
- {
171
- pattern: /^\/runs\/([^/]+)\/graph\.mmd$/,
172
- live: () => false,
173
- load: (src, params) => loadRunGraph(src, p(params, 0), { width: GRAPH_WIDTH_DEFAULT, from: 0, to: 0 }),
174
- render: renderGraphMermaid,
175
- text: true,
176
- },
185
+ const LEDGERS = ["ideas", "board", "findings", "claims", "facts", "resources"] as const;
186
+ type Ledger = (typeof LEDGERS)[number];
187
+
188
+ /** A ledger page for the project (`run` undefined) or for one run, which may be a swarm horde. */
189
+ const LEDGER_LOAD: Record<Ledger, (src: DashboardSource, run: string | undefined, query: URLSearchParams) => unknown> = {
190
+ ideas: (src, run, query) => loadIdeas(src, queryOwner(query), run),
191
+ board: (src, run, query) => loadBoard(src, run ?? "all", queryOrder(query)),
192
+ findings: (src, run, query) => loadFindings(src, run ?? "all", queryView(query), queryIds(query, "open")),
193
+ claims: (src, run) => loadClaims(src, run),
194
+ facts: (src, run) => loadFacts(src, run),
195
+ resources: (src, run) => loadResources(src, run),
196
+ };
197
+
198
+ const SWARM_LEDGER_LOAD: Record<Ledger, (src: DashboardSource, swarmId: string, query: URLSearchParams) => unknown> = {
199
+ ideas: (src, swarmId, query) => loadSwarmIdeas(src, swarmId, queryOwner(query)),
200
+ board: (src, swarmId, query) => loadSwarmBoard(src, swarmId, queryOrder(query)),
201
+ findings: (src, swarmId, query) => loadSwarmFindings(src, swarmId, queryView(query), queryIds(query, "open")),
202
+ claims: (src, swarmId) => loadSwarmClaims(src, swarmId),
203
+ facts: (src, swarmId) => loadSwarmFacts(src, swarmId),
204
+ resources: (src, swarmId) => loadSwarmResources(src, swarmId),
205
+ };
206
+
207
+ const LEDGER_RENDER: Record<Ledger, Route["render"]> = {
208
+ ideas: renderIdeas, board: renderBoard, findings: renderFindings, claims: renderClaims, facts: renderFacts, resources: renderResources,
209
+ };
210
+
211
+ const SWARM_LEDGER_RENDER: Record<Ledger, Route["render"]> = {
212
+ ideas: renderSwarmIdeas, board: renderSwarmBoard, findings: renderSwarmFindings,
213
+ claims: renderSwarmClaims, facts: renderSwarmFacts, resources: renderSwarmResources,
214
+ };
215
+
216
+ /** Every ledger page in its three scopes: `/<ledger>`, `/runs/<id>/<ledger>`, `/swarms/<id>/<ledger>`. */
217
+ function ledgerRoutes(): Route[] {
218
+ return LEDGERS.flatMap((kind): Route[] => [
219
+ {
220
+ pattern: new RegExp(`^/${kind}$`), scope: "project", page: kind,
221
+ live: (_vm, src) => isProjectLive(src),
222
+ load: (src, _params, query) => LEDGER_LOAD[kind](src, undefined, query),
223
+ render: LEDGER_RENDER[kind],
224
+ },
225
+ {
226
+ pattern: new RegExp(`^/runs/([^/]+)/${kind}$`), scope: "run", page: kind,
227
+ live: (_vm, src, params) => isRunLive(src, p(params, 0)),
228
+ load: (src, params, query) => {
229
+ requireRun(src, p(params, 0));
230
+ return LEDGER_LOAD[kind](src, p(params, 0), query);
231
+ },
232
+ render: LEDGER_RENDER[kind],
233
+ },
234
+ {
235
+ pattern: new RegExp(`^/swarms/([^/]+)/${kind}$`), scope: "swarm", page: kind,
236
+ live: (_vm, src, params) => isSwarmLive(src, p(params, 0)),
237
+ load: (src, params, query) => SWARM_LEDGER_LOAD[kind](src, p(params, 0), query),
238
+ render: SWARM_LEDGER_RENDER[kind],
239
+ },
240
+ ]);
241
+ }
242
+
243
+ /** Pages that exist for one run only: members, logs, transcripts, sessions, run files. */
244
+ const RUN_DETAIL_ROUTES: Route[] = [
177
245
  {
178
- pattern: /^\/runs\/([^/]+)\/members\/([^/]+)$/,
246
+ pattern: /^\/runs\/([^/]+)\/members\/([^/]+)$/, scope: "run", page: "other",
179
247
  live: (_vm, src, params) => memberRunning(src, p(params, 0), p(params, 1)),
180
248
  load: (src, params, query) => loadMemberDetail(src, p(params, 0), p(params, 1), queryInt(query, "tail", 1, 2000, 60)),
181
249
  render: renderMemberDetail,
182
250
  },
183
251
  {
184
- pattern: /^\/runs\/([^/]+)\/members\/([^/]+)\/log$/,
252
+ pattern: /^\/runs\/([^/]+)\/members\/([^/]+)\/log$/, scope: "run", page: "other",
185
253
  live: () => false,
186
254
  load: (src, params, query) => loadMemberLog(src, p(params, 0), p(params, 1), queryInt(query, "tail", 0, 2000, 500)),
187
255
  render: renderMemberLog,
188
256
  },
189
257
  {
190
- pattern: /^\/runs\/([^/]+)\/members\/([^/]+)\/transcript$/,
258
+ pattern: /^\/runs\/([^/]+)\/members\/([^/]+)\/transcript$/, scope: "run", page: "other",
191
259
  live: (_vm, src, params, query) => query.get("from") === "last" && memberRunning(src, p(params, 0), p(params, 1)),
192
260
  load: (src, params, query) =>
193
261
  loadTranscript(src, p(params, 0), p(params, 1), queryFrom(query), queryInt(query, "n", 1, 200, 50)),
194
262
  render: renderTranscript,
195
263
  },
196
264
  {
197
- pattern: /^\/runs\/([^/]+)\/members\/([^/]+)\/transcript\/(\d{1,9})$/,
265
+ pattern: /^\/runs\/([^/]+)\/members\/([^/]+)\/transcript\/(\d{1,9})$/, scope: "run", page: "other",
198
266
  live: () => false,
199
267
  load: (src, params) => loadTranscriptEntry(src, p(params, 0), p(params, 1), Number(p(params, 2))),
200
268
  render: renderTranscriptEntry,
201
269
  },
202
270
  {
203
- pattern: /^\/runs\/([^/]+)\/sessions$/,
271
+ pattern: /^\/runs\/([^/]+)\/sessions$/, scope: "run", page: "other",
204
272
  live: (_vm, src, params) => isActive(src, p(params, 0)),
205
273
  load: (src, params) => listRunSessions(src, p(params, 0)),
206
274
  render: renderSessions,
207
275
  },
208
276
  {
209
- pattern: /^\/runs\/([^/]+)\/sessions\/([^/]+)$/,
277
+ pattern: /^\/runs\/([^/]+)\/sessions\/([^/]+)$/, scope: "run", page: "other",
210
278
  live: () => false,
211
279
  load: (src, params, query) =>
212
280
  loadSessionTranscript(src, p(params, 0), p(params, 1), queryFrom(query), queryInt(query, "n", 1, 200, 50)),
213
281
  render: renderTranscript,
214
282
  },
215
283
  {
216
- pattern: /^\/runs\/([^/]+)\/sessions\/([^/]+)\/(\d{1,9})$/,
284
+ pattern: /^\/runs\/([^/]+)\/sessions\/([^/]+)\/(\d{1,9})$/, scope: "run", page: "other",
217
285
  live: () => false,
218
286
  load: (src, params) => loadSessionEntry(src, p(params, 0), p(params, 1), Number(p(params, 2))),
219
287
  render: renderTranscriptEntry,
220
288
  },
221
289
  {
222
- pattern: /^\/runs\/([^/]+)\/files\/([^/]+)$/,
290
+ pattern: /^\/runs\/([^/]+)\/files\/([^/]+)$/, scope: "run", page: "other",
223
291
  live: () => false,
224
292
  load: (src, params) => {
225
293
  const vm = loadRunFile(src, p(params, 0), p(params, 1));
@@ -228,52 +296,70 @@ const ROUTES: Route[] = [
228
296
  },
229
297
  render: renderFile,
230
298
  },
299
+ ];
300
+
301
+ const ROUTES: Route[] = [
231
302
  {
232
- pattern: /^\/ideas$/,
303
+ pattern: /^\/$/, scope: "project", page: "overview",
233
304
  live: () => true,
234
- load: (src, _params, query) => loadIdeas(src, queryOwner(query)),
235
- render: renderIdeas,
305
+ load: (src) => listRuns(src),
306
+ render: renderRunIndex,
236
307
  },
237
308
  {
238
- pattern: /^\/board$/,
239
- live: () => true,
240
- load: (src, _params, query) =>
241
- loadBoard(src, queryRun(query), queryEnum(query, "order", ["newest", "oldest"] as const, "newest")),
242
- render: renderBoard,
309
+ pattern: /^\/runs\/([^/]+)$/, scope: "run", page: "overview",
310
+ live: (_vm, src, params) => isRunLive(src, p(params, 0)),
311
+ load: (src, params) => loadRunOverview(src, p(params, 0)),
312
+ render: renderRunOverview,
243
313
  },
244
314
  {
245
- pattern: /^\/findings$/,
246
- live: () => true,
247
- load: (src, _params, query) =>
248
- loadFindings(
249
- src,
250
- queryRun(query),
251
- queryEnum(query, "view", ["list", "clusters"] as const, "list"),
252
- queryIds(query, "open"),
253
- ),
254
- render: renderFindings,
315
+ pattern: /^\/runs\/([^/]+)\/collaboration$/, scope: "run", page: "collaboration",
316
+ live: (_vm, src, params) => isActive(src, p(params, 0)),
317
+ load: (src, params, query) => loadCollaboration(src, p(params, 0), queryInt(query, "from", 0, 999_999_999, 0)),
318
+ render: renderCollaboration,
255
319
  },
256
320
  {
257
- pattern: /^\/claims$/,
258
- live: () => true,
259
- load: (src) => loadClaims(src),
260
- render: renderClaims,
321
+ pattern: /^\/runs\/([^/]+)\/graph$/, scope: "run", page: "graph",
322
+ live: (_vm, src, params) => isRunLive(src, p(params, 0)),
323
+ load: (src, params, query) => loadRunGraph(src, p(params, 0), graphView(query)),
324
+ render: renderGraph,
261
325
  },
262
326
  {
263
- pattern: /^\/facts$/,
264
- live: () => true,
265
- load: (src, _params, query) => {
266
- const run = queryRun(query);
267
- return loadFacts(src, run === "all" ? undefined : run);
268
- },
269
- render: renderFacts,
327
+ pattern: /^\/runs\/([^/]+)\/graph\.mmd$/, scope: "run", page: "graph",
328
+ live: () => false,
329
+ load: (src, params) => loadRunGraph(src, p(params, 0), { width: GRAPH_WIDTH_DEFAULT, from: 0, to: 0 }),
330
+ render: renderGraphMermaid,
331
+ text: true,
270
332
  },
333
+ ...RUN_DETAIL_ROUTES,
271
334
  {
272
- pattern: /^\/resources$/,
273
- live: () => true,
274
- load: (src) => loadResources(src),
275
- render: renderResources,
335
+ pattern: /^\/swarms\/([^/]+)$/, scope: "swarm", page: "overview",
336
+ live: (_vm, src, params) => isSwarmLive(src, p(params, 0)),
337
+ load: (src, params) => loadSwarm(src, p(params, 0)),
338
+ render: renderSwarm,
339
+ },
340
+ {
341
+ pattern: /^\/swarms\/([^/]+)\/collaboration$/, scope: "swarm", page: "collaboration",
342
+ live: (_vm, src, params) => isSwarmLive(src, p(params, 0)),
343
+ load: (src, params) => loadSwarmCollaboration(src, p(params, 0)),
344
+ render: renderSwarmCollaboration,
345
+ },
346
+ {
347
+ pattern: /^\/swarms\/([^/]+)\/graph$/, scope: "swarm", page: "graph",
348
+ live: (_vm, src, params) => isSwarmLive(src, p(params, 0)),
349
+ load: (src, params, query) => loadSwarmGraph(src, p(params, 0), graphView(query)),
350
+ render: renderSwarmGraph,
276
351
  },
352
+ {
353
+ pattern: /^\/swarms\/([^/]+)\/files\/([^/]+)$/, scope: "swarm", page: "other",
354
+ live: () => false,
355
+ load: (src, params) => {
356
+ const vm = loadSwarmFile(src, p(params, 0), p(params, 1));
357
+ if (!vm.present) throw new DashboardError(404, `${vm.name}.md has not been written yet`);
358
+ return vm;
359
+ },
360
+ render: renderFile,
361
+ },
362
+ ...ledgerRoutes(),
277
363
  ];
278
364
 
279
365
  function baseHeaders(): Record<string, string> {
@@ -329,12 +415,36 @@ function routeMatch(pathname: string): { route: Route; params: string[] } | unde
329
415
  return undefined;
330
416
  }
331
417
 
332
- /** The run this page belongs to, and the run the nav's collaboration and graph links target. */
333
- function runContext(src: DashboardSource, url: URL, params: string[]): Pick<RenderContext, "runId" | "navRun"> {
334
- const runId = RUN_ID_RE.test(params[0] ?? "") ? params[0] : undefined;
335
- const filterRun = url.searchParams.get("run");
336
- const navRun = runId ?? (filterRun && RUN_ID_RE.test(filterRun) ? filterRun : undefined) ?? latestRunId(src);
337
- return { runId, navRun };
418
+ /**
419
+ * The scope the route declares, filled in from its id. A horde run also carries its swarm's hordes
420
+ * for the tabs; a swarm that cannot be read leaves the run without tabs rather than failing the page.
421
+ */
422
+ function scopeOf(src: DashboardSource, route: Route, params: string[]): Scope {
423
+ if (route.scope === "project") return { kind: "project" };
424
+ const id = p(params, 0);
425
+ if (route.scope === "swarm") return { kind: "swarm", swarmId: id, hordes: swarmHordes(src, id) };
426
+ const swarm = swarmOfRun(id);
427
+ if (!swarm) return { kind: "run", runId: id };
428
+ try {
429
+ return { kind: "run", runId: id, swarm: { id: swarm.swarmId, hordes: swarmHordes(src, swarm.swarmId) } };
430
+ } catch {
431
+ return { kind: "run", runId: id };
432
+ }
433
+ }
434
+
435
+ /** The page's place in the nav: its scope and which page-row item it is. */
436
+ /** A swarm (not a swarm of one, which writes the project ledgers) the parent still holds in memory. */
437
+ function liveSwarmOf(src: DashboardSource): RenderContext["liveSwarm"] {
438
+ const swarm = src.getSwarm?.();
439
+ if (!swarm || swarm.dir === undefined) return undefined;
440
+ const running = src.getActive().some((run) => swarmOfRun(run.id)?.swarmId === swarm.id);
441
+ return { id: swarm.id, finishing: !running };
442
+ }
443
+
444
+ /** The page's place in the nav: its scope, which page-row item it is, and a live swarm for project pages. */
445
+ function pageContext(src: DashboardSource, route: Route, params: string[]): Pick<RenderContext, "scope" | "pageKind" | "liveSwarm"> {
446
+ const liveSwarm = route.scope === "project" ? liveSwarmOf(src) : undefined;
447
+ return { scope: scopeOf(src, route, params), pageKind: route.page, liveSwarm };
338
448
  }
339
449
 
340
450
  /** The export costs seconds of work per request, so pages from other sites may not start one. */
@@ -355,7 +465,7 @@ function handleRoute(src: DashboardSource, url: URL, json: boolean, ctx: RenderC
355
465
  const on = live && refreshParam !== "0";
356
466
  const pageCtx: RenderContext = {
357
467
  ...ctx,
358
- ...runContext(src, url, params),
468
+ ...pageContext(src, route, params),
359
469
  refresh: live ? { on, toggleHref: refreshToggle(url, on), seconds: REFRESH_SECONDS } : undefined,
360
470
  };
361
471
  const body = route.render(vm as never, pageCtx);
@@ -365,13 +475,13 @@ function handleRoute(src: DashboardSource, url: URL, json: boolean, ctx: RenderC
365
475
 
366
476
  /** One page of the static export; errors become pages so no exported link dangles. */
367
477
  function exportPage(src: DashboardSource, url: URL): ExportPage {
368
- const ctx: RenderContext = { activeId: src.getActive()?.id, href: url.pathname + url.search, export: true };
478
+ const ctx: RenderContext = { href: url.pathname + url.search, export: true };
369
479
  try {
370
480
  const matched = routeMatch(url.pathname);
371
481
  if (!matched) throw new DashboardError(404, `no such page: ${url.pathname}`);
372
482
  const { route, params } = matched;
373
483
  const vm = route.load(src, params, url.searchParams);
374
- return { body: route.render(vm as never, { ...ctx, ...runContext(src, url, params) }), text: route.text };
484
+ return { body: route.render(vm as never, { ...ctx, ...pageContext(src, route, params) }), text: route.text };
375
485
  } catch (error) {
376
486
  const status = error instanceof DashboardError ? error.status : 500;
377
487
  const message = error instanceof DashboardError ? error.message : `dashboard error: ${error instanceof Error ? error.message : String(error)}`;
@@ -414,7 +524,6 @@ function exportResponse(src: DashboardSource, url: URL, ctx: RenderContext): Das
414
524
  export function routeRequest(src: DashboardSource, method: string, url: URL, host: string | undefined, fetchSite?: string): DashboardResponse {
415
525
  const json = url.pathname === "/api" || url.pathname.startsWith("/api/");
416
526
  const ctx: RenderContext = {
417
- activeId: src.getActive()?.id,
418
527
  origin: host ? `http://${host}` : undefined,
419
528
  href: url.pathname + url.search,
420
529
  };
@@ -79,7 +79,12 @@ function writeIfMissing(filePath: string, content: string): void {
79
79
  }
80
80
 
81
81
  export function initHordeDir(cwd: string): string {
82
- const paths = hordePaths(path.join(cwd, ".horde"));
82
+ return initHordeDirAt(path.join(cwd, ".horde"));
83
+ }
84
+
85
+ /** Create a complete horde directory (ledgers, lock, runs/) at `hordeDir`; existing ledgers are kept. */
86
+ export function initHordeDirAt(hordeDir: string): string {
87
+ const paths = hordePaths(hordeDir);
83
88
  fs.mkdirSync(paths.runs, { recursive: true });
84
89
  writeIfMissing(paths.gitignore, "runs/\n");
85
90
  writeIfMissing(paths.ideas, IDEAS_HEADER);
package/src/member.ts CHANGED
@@ -77,6 +77,15 @@ export interface MemberReset {
77
77
  tokens: number;
78
78
  }
79
79
 
80
+ /** Another horde of the same swarm, whose ledgers a member may read but never write. */
81
+ export interface SiblingHorde {
82
+ /** `h1`, `h2`, ... */
83
+ label: string;
84
+ hordeDir: string;
85
+ /** That horde's workstream, when the swarm partitioned the task. */
86
+ slice?: string | undefined;
87
+ }
88
+
80
89
  export interface MemberRunInfo {
81
90
  id: string;
82
91
  hordeDir: string;
@@ -85,6 +94,13 @@ export interface MemberRunInfo {
85
94
  count: number;
86
95
  /** True when started with `--until-stop`: the run continues until the user stops it. */
87
96
  untilStop?: boolean | undefined;
97
+ /** 1-based horde index in the swarm and the swarm's size; a single run is horde 1 of 1. */
98
+ horde?: number | undefined;
99
+ hordes?: number | undefined;
100
+ /** This horde's workstream, told to every member, when the swarm partitioned the task. */
101
+ slice?: string | undefined;
102
+ /** The other hordes of the swarm. */
103
+ siblings?: SiblingHorde[] | undefined;
88
104
  }
89
105
 
90
106
  interface SpawnMemberInput {
@@ -294,9 +310,21 @@ export const UNTIL_STOP_NOTE =
294
310
  "SOLVED when the task has a complete answer; once an answer is banked, propose again only when a " +
295
311
  "materially better one exists.";
296
312
 
313
+ /** What a member of a partitioned swarm is told about its horde's workstream. */
314
+ export function sliceNote(run: MemberRunInfo): string | undefined {
315
+ if (!run.slice || !run.horde || !run.hordes || run.hordes < 2) return undefined;
316
+ return (
317
+ `Your horde is h${run.horde} of ${run.hordes} hordes working the same task in parallel, each in its own ` +
318
+ `ledgers. Your horde owns workstream ${run.horde}: "${run.slice}". Work only on your horde's workstream; ` +
319
+ "do not duplicate another horde's."
320
+ );
321
+ }
322
+
297
323
  /** The per-member system line: name, assigned role, a reset note for a replacement, the until-stop rule. */
298
324
  function identityLine(run: MemberRunInfo, member: MemberState, role: string | undefined): string {
299
325
  const parts = [`You are ${member.name}.`];
326
+ const slice = sliceNote(run);
327
+ if (slice) parts.push(slice);
300
328
  if (role) parts.push(role);
301
329
  if (member.resetNote) parts.push(member.resetNote);
302
330
  if (run.untilStop) parts.push(UNTIL_STOP_NOTE);
@@ -319,6 +347,7 @@ export function buildMemberArgs(input: SpawnMemberInput): string[] {
319
347
  }
320
348
 
321
349
  export function memberEnv(input: SpawnMemberInput): NodeJS.ProcessEnv {
350
+ const siblings = input.run.siblings ?? [];
322
351
  return childEnv({
323
352
  PI_HORDE_MEMBER: "1",
324
353
  PI_HORDE_DIR: input.run.hordeDir,
@@ -326,9 +355,27 @@ export function memberEnv(input: SpawnMemberInput): NodeJS.ProcessEnv {
326
355
  PI_HORDE_RUN: input.run.id,
327
356
  PI_HORDE_ROLE: input.role ?? "",
328
357
  PI_HORDE_ATTEMPT: input.member.attempt ??= randomUUID(),
358
+ PI_HORDE_SIBLINGS: siblings.length > 0 ? JSON.stringify(siblings.map((s) => ({ label: s.label, hordeDir: s.hordeDir }))) : "",
329
359
  });
330
360
  }
331
361
 
362
+ /** The sibling hordes a member process was given, from `PI_HORDE_SIBLINGS`; empty outside a swarm. */
363
+ export function parseSiblings(raw: string | undefined): SiblingHorde[] {
364
+ if (!raw) return [];
365
+ try {
366
+ const items = JSON.parse(raw) as unknown;
367
+ if (!Array.isArray(items)) return [];
368
+ return items
369
+ .filter((item): item is { label: string; hordeDir: string } =>
370
+ typeof item === "object" && item !== null &&
371
+ typeof (item as { label?: unknown }).label === "string" &&
372
+ typeof (item as { hordeDir?: unknown }).hordeDir === "string")
373
+ .map((item) => ({ label: item.label, hordeDir: item.hordeDir }));
374
+ } catch {
375
+ return [];
376
+ }
377
+ }
378
+
332
379
  export function splitNdjson(buffer: string, onLine: (line: string) => void): string {
333
380
  const lines = buffer.split("\n");
334
381
  const remainder = lines.pop() ?? "";
package/src/prompt.ts CHANGED
@@ -9,7 +9,7 @@ import type { PeerMessage, ReviewRecord } from "./ledger/collaboration.js";
9
9
  import type { Idea } from "./ledger/ideas.js";
10
10
  import type { ResourceEntry } from "./ledger/resources.js";
11
11
  import type { SolvedSignal } from "./ledger/run-signals.js";
12
- import type { MemberReset, MemberState } from "./member.js";
12
+ import type { MemberReset, MemberState, SiblingHorde } from "./member.js";
13
13
 
14
14
  /** A resource with the non-owner members whose findings, evidence, reviews or leases cite it. */
15
15
  export type ReportResource = ResourceEntry & { citedBy?: string[] | undefined };
@@ -169,7 +169,26 @@ Collaboration loop:
169
169
  attempt after discovery exits. Repair attempts must address assigned feedback only.
170
170
  `;
171
171
 
172
- export function memberSystemPrompt(input: { hordeDir: string; count: number }): string {
172
+ /** What a member of a swarm is told about the other hordes: shared read, local write. */
173
+ function siblingsSection(siblings: SiblingHorde[]): string {
174
+ if (siblings.length === 0) return "";
175
+ const lines = siblings.map(
176
+ (sibling) => `- ${sibling.label}: ${sibling.hordeDir}/${sibling.slice ? ` (workstream: ${sibling.slice})` : ""}`,
177
+ );
178
+ return `
179
+ Your horde is one of ${siblings.length + 1} hordes working the same task in parallel, each with its
180
+ own ledgers. The other hordes' directories are read-only for you:
181
+
182
+ ${lines.join("\n")}
183
+
184
+ 'horde facts' and 'horde resources' include their entries tagged [hN], and 'horde list' includes
185
+ their done and ruled-out ideas, so a fact, a rule-out, or a resource another horde recorded is not
186
+ re-derived here; reuse a sibling's resource by its path. Every write goes to your own horde's
187
+ ledgers; never write to a sibling's directory, and never claim or cite a sibling's ideas as yours.
188
+ `;
189
+ }
190
+
191
+ export function memberSystemPrompt(input: { hordeDir: string; count: number; siblings?: SiblingHorde[] | undefined }): string {
173
192
  const count = input.count;
174
193
  return `You are one member of a horde of ${count} agents. Every member received the same task
175
194
  and works in parallel in this repository with its own separate context. Coordinate through the
@@ -188,9 +207,9 @@ horde tool. Durable run events retain messages, evidence, reviews and revisions;
188
207
  - PLAN.md workstream partition and your assigned layer, when the run was started with plan.
189
208
 
190
209
  Read them with the read tool. Write them ONLY with the horde tool; never edit them directly.
191
- Put reproduction snippets under .horde/runs/<run-id>/artifacts/ using PI_HORDE_RUN, so evidence
192
- survives finalization and line references do not drift.
193
-
210
+ Put reproduction snippets under ${input.hordeDir}/runs/<run-id>/artifacts/ using PI_HORDE_RUN, so
211
+ evidence survives finalization and line references do not drift.
212
+ ${siblingsSection(input.siblings ?? [])}
194
213
  Protocol:
195
214
  1. Run 'horde list', 'horde findings', 'horde facts', and read BOARD.md before planning.
196
215
  2. Prefer the shared queue. Add an idea only for an uncovered question or concrete evidence gap.
@@ -275,7 +294,10 @@ export function taskMarkdown(input: {
275
294
  startedAt: number;
276
295
  verifier?: string | undefined;
277
296
  untilStop?: boolean | undefined;
297
+ /** Set for a horde of a larger swarm: which horde this is and its workstream. */
298
+ swarm?: { id: string; horde: number; hordes: number; slice?: string | undefined } | undefined;
278
299
  }): string {
300
+ const swarm = input.swarm;
279
301
  return [
280
302
  `# Horde run ${input.id}`,
281
303
  "",
@@ -285,6 +307,8 @@ export function taskMarkdown(input: {
285
307
  `Thinking: ${input.thinkingLevel ?? "parent default"}`,
286
308
  `Verifier: ${input.verifier ?? "none"}`,
287
309
  `Until stop: ${input.untilStop ? "yes (the run continues until stop-clean or stop-hard)" : "no"}`,
310
+ ...(swarm ? [`Swarm: ${swarm.id} (horde h${swarm.horde} of ${swarm.hordes})`] : []),
311
+ ...(swarm?.slice ? [`Workstream: ${swarm.slice}`] : []),
288
312
  `Task-SHA256: ${taskHash(input.prompt)}`,
289
313
  "",
290
314
  "## Prompt",