@signal9/era-ui 4.6.0 → 4.7.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.
Files changed (54) hide show
  1. package/dist/ai/error-panel/error-panel.svelte +1 -30
  2. package/dist/ai/index.d.ts +1 -0
  3. package/dist/ai/index.js +1 -0
  4. package/dist/ai/runtime-feed/runtime-feed-item.svelte +4 -1
  5. package/dist/ai/swarm/context.d.ts +26 -0
  6. package/dist/ai/swarm/context.js +14 -0
  7. package/dist/ai/swarm/index.d.ts +16 -0
  8. package/dist/ai/swarm/index.js +15 -0
  9. package/dist/ai/swarm/swarm-agent.svelte +132 -0
  10. package/dist/ai/swarm/swarm-agent.svelte.d.ts +16 -0
  11. package/dist/ai/swarm/swarm-artifact.svelte +45 -0
  12. package/dist/ai/swarm/swarm-artifact.svelte.d.ts +8 -0
  13. package/dist/ai/swarm/swarm-artifacts.svelte +42 -0
  14. package/dist/ai/swarm/swarm-artifacts.svelte.d.ts +15 -0
  15. package/dist/ai/swarm/swarm-barrier.svelte +63 -0
  16. package/dist/ai/swarm/swarm-barrier.svelte.d.ts +10 -0
  17. package/dist/ai/swarm/swarm-counts.svelte +52 -0
  18. package/dist/ai/swarm/swarm-counts.svelte.d.ts +13 -0
  19. package/dist/ai/swarm/swarm-dot.svelte +52 -0
  20. package/dist/ai/swarm/swarm-dot.svelte.d.ts +23 -0
  21. package/dist/ai/swarm/swarm-footer.svelte +63 -0
  22. package/dist/ai/swarm/swarm-footer.svelte.d.ts +16 -0
  23. package/dist/ai/swarm/swarm-grid.svelte +51 -0
  24. package/dist/ai/swarm/swarm-grid.svelte.d.ts +14 -0
  25. package/dist/ai/swarm/swarm-header.svelte +64 -0
  26. package/dist/ai/swarm/swarm-header.svelte.d.ts +14 -0
  27. package/dist/ai/swarm/swarm-node.svelte +258 -0
  28. package/dist/ai/swarm/swarm-node.svelte.d.ts +32 -0
  29. package/dist/ai/swarm/swarm-role.svelte +71 -0
  30. package/dist/ai/swarm/swarm-role.svelte.d.ts +17 -0
  31. package/dist/ai/swarm/swarm-root.svelte +55 -0
  32. package/dist/ai/swarm/swarm-root.svelte.d.ts +16 -0
  33. package/dist/ai/swarm/swarm-tree.svelte +201 -0
  34. package/dist/ai/swarm/swarm-tree.svelte.d.ts +25 -0
  35. package/dist/ai/swarm/swarm.d.ts +218 -0
  36. package/dist/ai/swarm/swarm.js +187 -0
  37. package/dist/ai/swarm/swarm.svelte.d.ts +59 -0
  38. package/dist/ai/swarm/swarm.svelte.js +125 -0
  39. package/dist/apps/llm-shell/index.d.ts +1 -1
  40. package/dist/apps/llm-shell/llm-shell.svelte +65 -1
  41. package/dist/apps/llm-shell/llm-shell.svelte.d.ts +7 -0
  42. package/dist/apps/llm-shell/types.d.ts +22 -0
  43. package/dist/dev/audit/audits/clipped-glyphs.js +7 -0
  44. package/dist/era-ui.css +1 -1
  45. package/dist/generated-docs/llm-shell.md +1 -1
  46. package/dist/generated-docs/llms-full.txt +53 -11
  47. package/dist/generated-docs/llms.txt +1 -0
  48. package/dist/generated-docs/manifest.json +16 -5
  49. package/dist/generated-docs/utilities.json +12 -2
  50. package/dist/generated-docs/utilities.md +51 -10
  51. package/dist/styles/index.css +63 -9
  52. package/dist/styles/themes.css +27 -15
  53. package/dist/ui/step/step-content.svelte +5 -34
  54. package/package.json +1 -1
@@ -0,0 +1,201 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import { cn } from '../../utils/index.js';
5
+ import Node from './swarm-node.svelte';
6
+ import { buildSwarmTree, type SwarmAgent, type SwarmNode } from './swarm.js';
7
+
8
+ let {
9
+ ref = $bindable(null),
10
+ agents = [],
11
+ expandable,
12
+ detail,
13
+ meta,
14
+ empty,
15
+ onToggle,
16
+ class: className,
17
+ ...restProps
18
+ }: Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
19
+ ref?: HTMLDivElement | null;
20
+ /** The swarm's agents, flat. The tree is built from each one's `parentId`. */
21
+ agents?: SwarmAgent[];
22
+ /**
23
+ * Which rows can expand beyond their children. Defaults to "every row,
24
+ * once a detail snippet exists" — pass a predicate when the detail is empty
25
+ * for some agents, so those rows don't get a chevron that opens nothing.
26
+ */
27
+ expandable?: (agent: SwarmAgent) => boolean;
28
+ /** Expanded body for a row — artifacts, a transcript, tool output. */
29
+ detail?: Snippet<[SwarmAgent]>;
30
+ /** Trailing row content, rendered before the elapsed readout. */
31
+ meta?: Snippet<[SwarmAgent]>;
32
+ /** Replaces the built-in "no agents yet" line. */
33
+ empty?: Snippet;
34
+ /** Fired when a row expands or collapses, with that agent. */
35
+ onToggle?: (agent: SwarmAgent, open: boolean) => void;
36
+ } = $props();
37
+
38
+ const tree = $derived(buildSwarmTree(agents));
39
+
40
+ /** One answer for the row, the keyboard walk, and the chevron — they must agree. */
41
+ const nodeExpandable = (node: SwarmNode) =>
42
+ node.children.length > 0 || (detail != null && (expandable?.(node.agent) ?? true));
43
+
44
+ // Rows default to OPEN and remember only what the user closed. A swarm streams
45
+ // in — an agent that appears three seconds from now has no entry here, and
46
+ // defaulting to open is what makes it visible the moment it lands instead of
47
+ // hiding new work inside a collapsed parent.
48
+ let closed = $state<Record<string, boolean>>({});
49
+ const isOpen = (id: string) => !closed[id];
50
+
51
+ function setOpen(agent: SwarmAgent, open: boolean) {
52
+ closed = { ...closed, [agent.id]: !open };
53
+ onToggle?.(agent, open);
54
+ }
55
+
56
+ /**
57
+ * The visible rows, in the order the eye reads them — which is exactly the
58
+ * order the arrow keys have to move through, so navigation is derived from
59
+ * the same walk that renders rather than from the DOM.
60
+ */
61
+ type Row = { id: string; parentId: string | null; expandable: boolean; open: boolean };
62
+ const rows = $derived.by(() => {
63
+ const out: Row[] = [];
64
+ const walk = (nodes: SwarmNode[], parentId: string | null) => {
65
+ for (const node of nodes) {
66
+ const canExpand = nodeExpandable(node);
67
+ const open = isOpen(node.agent.id);
68
+ out.push({ id: node.agent.id, parentId, expandable: canExpand, open });
69
+ if (canExpand && open) walk(node.children, node.agent.id);
70
+ }
71
+ };
72
+ walk(tree, null);
73
+ return out;
74
+ });
75
+
76
+ // Roving tabindex: exactly one row is in the tab order, and Tab out of the
77
+ // tree returns to it. Falls back to the first row so a freshly rendered tree
78
+ // is reachable at all.
79
+ let activeId = $state<string | null>(null);
80
+ const focusedId = $derived(
81
+ activeId && rows.some((row) => row.id === activeId) ? activeId : (rows[0]?.id ?? null)
82
+ );
83
+
84
+ function focusRow(id: string | undefined | null) {
85
+ if (!id || !ref) return;
86
+ activeId = id;
87
+ ref.querySelector<HTMLElement>(`[data-agent-id="${CSS.escape(id)}"]`)?.focus();
88
+ }
89
+
90
+ /** Focus follows the DOM, so clicking a row (or one of its buttons) roves too. */
91
+ function onfocusin(event: FocusEvent) {
92
+ const el = (event.target as HTMLElement | null)?.closest?.('[data-agent-id]');
93
+ const id = el?.getAttribute('data-agent-id');
94
+ if (id) activeId = id;
95
+ }
96
+
97
+ /**
98
+ * WAI-ARIA tree navigation. Enter and Space are deliberately absent — they
99
+ * activate the row, which the node itself owns (it is the thing that knows
100
+ * whether there is an `onOpen` to run).
101
+ */
102
+ function onkeydown(event: KeyboardEvent) {
103
+ const index = rows.findIndex((row) => row.id === focusedId);
104
+ const current = index >= 0 ? rows[index] : undefined;
105
+
106
+ switch (event.key) {
107
+ case 'ArrowDown':
108
+ event.preventDefault();
109
+ focusRow(rows[Math.min(index + 1, rows.length - 1)]?.id);
110
+ break;
111
+ case 'ArrowUp':
112
+ event.preventDefault();
113
+ focusRow(rows[Math.max(index - 1, 0)]?.id);
114
+ break;
115
+ case 'ArrowRight':
116
+ // Collapsed: open it. Open: step into the branch it just revealed.
117
+ if (!current) break;
118
+ event.preventDefault();
119
+ if (current.expandable && !current.open) {
120
+ const agent = agents.find((a) => a.id === current.id);
121
+ if (agent) setOpen(agent, true);
122
+ } else if (rows[index + 1]?.parentId === current.id) {
123
+ focusRow(rows[index + 1].id);
124
+ }
125
+ break;
126
+ case 'ArrowLeft':
127
+ // Open: close it. Closed (or a leaf): climb to the parent.
128
+ if (!current) break;
129
+ event.preventDefault();
130
+ if (current.expandable && current.open) {
131
+ const agent = agents.find((a) => a.id === current.id);
132
+ if (agent) setOpen(agent, false);
133
+ } else {
134
+ focusRow(current.parentId);
135
+ }
136
+ break;
137
+ case 'Home':
138
+ event.preventDefault();
139
+ focusRow(rows[0]?.id);
140
+ break;
141
+ case 'End':
142
+ event.preventDefault();
143
+ focusRow(rows.at(-1)?.id);
144
+ break;
145
+ }
146
+ }
147
+ </script>
148
+
149
+ <!-- The delegation tree. `@container` scopes the responsive rules inside to the
150
+ TREE's width rather than the viewport's: the same swarm renders inside a
151
+ narrow chat message and in a full-width console, and only the component can
152
+ know which one it is in. -->
153
+ <div
154
+ bind:this={ref}
155
+ role="tree"
156
+ aria-label="Swarm agents"
157
+ {onkeydown}
158
+ {onfocusin}
159
+ class={cn('@container min-w-0 py-(--era-gap)', className)}
160
+ {...restProps}
161
+ >
162
+ {#if rows.length === 0}
163
+ <div class="px-(--era-inset-md) py-(--era-gap) text-body text-muted">
164
+ {#if empty}{@render empty()}{:else}No agents yet.{/if}
165
+ </div>
166
+ {:else}
167
+ {#each tree as node (node.agent.id)}
168
+ {@render nodeView(node, 1)}
169
+ {/each}
170
+ {/if}
171
+ </div>
172
+
173
+ <!-- The recursion lives in a snippet rather than in the node component: the tree
174
+ owns expansion and focus for the whole forest (it is the only thing that
175
+ knows the visible order), and a self-recursing node would have to hand every
176
+ level a copy of that state. -->
177
+ {#snippet nodeView(node: SwarmNode, level: number)}
178
+ <!-- A leaf gets NO children snippet, rather than one that renders nothing:
179
+ the node reads the snippet's presence to decide whether it is expandable,
180
+ so an always-passed empty group would put a dead chevron on every leaf. -->
181
+ {@const props = {
182
+ agent: node.agent,
183
+ level,
184
+ expandable: nodeExpandable(node),
185
+ detail,
186
+ meta,
187
+ open: isOpen(node.agent.id),
188
+ active: focusedId === node.agent.id,
189
+ tabindex: focusedId === node.agent.id ? 0 : -1,
190
+ onToggle: (open: boolean) => setOpen(node.agent, open)
191
+ }}
192
+ {#if node.children.length > 0}
193
+ <Node {...props}>
194
+ {#each node.children as child (child.agent.id)}
195
+ {@render nodeView(child, level + 1)}
196
+ {/each}
197
+ </Node>
198
+ {:else}
199
+ <Node {...props} />
200
+ {/if}
201
+ {/snippet}
@@ -0,0 +1,25 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import { type SwarmAgent } from './swarm.js';
4
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
5
+ ref?: HTMLDivElement | null;
6
+ /** The swarm's agents, flat. The tree is built from each one's `parentId`. */
7
+ agents?: SwarmAgent[];
8
+ /**
9
+ * Which rows can expand beyond their children. Defaults to "every row,
10
+ * once a detail snippet exists" — pass a predicate when the detail is empty
11
+ * for some agents, so those rows don't get a chevron that opens nothing.
12
+ */
13
+ expandable?: (agent: SwarmAgent) => boolean;
14
+ /** Expanded body for a row — artifacts, a transcript, tool output. */
15
+ detail?: Snippet<[SwarmAgent]>;
16
+ /** Trailing row content, rendered before the elapsed readout. */
17
+ meta?: Snippet<[SwarmAgent]>;
18
+ /** Replaces the built-in "no agents yet" line. */
19
+ empty?: Snippet;
20
+ /** Fired when a row expands or collapses, with that agent. */
21
+ onToggle?: (agent: SwarmAgent, open: boolean) => void;
22
+ };
23
+ declare const SwarmTree: import("svelte").Component<$$ComponentProps, {}, "ref">;
24
+ type SwarmTree = ReturnType<typeof SwarmTree>;
25
+ export default SwarmTree;
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The swarm model — the shape of a multi-agent run, and the projection logic
3
+ * the UI needs, with no transport attached.
4
+ *
5
+ * A swarm is one orchestrated effort: a root agent that decomposes a task,
6
+ * workers it delegates to, optional reviewers, and fan-in gates (barriers)
7
+ * where parallel branches rejoin before the work is merged into artifacts.
8
+ * Every real implementation streams that state as an append-only event log with
9
+ * a monotonic sequence number, because an hour-long run WILL outlive a socket.
10
+ *
11
+ * era ships the projection half of that contract and nothing else. The host
12
+ * owns the transport (oRPC, SSE, WebSocket, polling — era does not care) and
13
+ * feeds `SwarmController` snapshots and events; the controller owns the one
14
+ * invariant that makes reconnection tractable:
15
+ *
16
+ * the view is a pure function of (snapshot at seq S, every event with seq > S)
17
+ *
18
+ * which turns "resume a live view" into a watermark comparison instead of a
19
+ * state-transfer negotiation. Take a snapshot, subscribe from its seq, and a
20
+ * dropped socket, a server restart and a browser refresh all re-enter through
21
+ * the same door.
22
+ *
23
+ * This module is the pure half — shapes and projections, no runes, no clock.
24
+ * `SwarmController` (swarm.svelte.ts) is the live half, and it is optional: a
25
+ * host that already holds swarm state in its own store calls these functions
26
+ * directly and never constructs one.
27
+ */
28
+ /**
29
+ * What an agent was spawned to be.
30
+ *
31
+ * `orchestrator` plans and delegates (usually the root), `worker` does a
32
+ * delegated subtask, `reviewer` and `verifier` check other agents' output
33
+ * (review reads, verification re-runs), `aggregator` merges a branch's results
34
+ * into one artifact.
35
+ */
36
+ export type SwarmRole = 'orchestrator' | 'worker' | 'reviewer' | 'verifier' | 'aggregator';
37
+ /**
38
+ * An agent's lifecycle.
39
+ *
40
+ * The two waiting states are deliberately distinct, and collapsing them hides
41
+ * the thing you open a swarm console to diagnose: `queued` is waiting for a
42
+ * slot (bounded parallelism), `blocked` is waiting on someone else (a barrier
43
+ * or an unfinished dependency). One clears itself; the other may never.
44
+ */
45
+ export type SwarmAgentStatus = 'blocked' | 'queued' | 'running' | 'done' | 'failed' | 'cancelled';
46
+ /** The plan's own lifecycle — `active` until every branch has landed. */
47
+ export type SwarmPlanStatus = 'active' | 'done' | 'failed' | 'cancelled';
48
+ /** A fan-in gate: `open` while any member is still working. */
49
+ export type SwarmBarrierStatus = 'open' | 'done' | 'failed' | 'cancelled';
50
+ /**
51
+ * The stream's health, which is NOT the swarm's health — a swarm can be running
52
+ * perfectly while your connection to it is dead. Surfaced separately (see
53
+ * `Swarm.Footer`) for exactly that reason.
54
+ */
55
+ export type SwarmConnection = 'idle' | 'loading' | 'live' | 'reconnecting' | 'error';
56
+ /** The VM an agent executes in, when it has one. */
57
+ export interface SwarmSandbox {
58
+ /** Provider id, shown on hover. */
59
+ id?: string;
60
+ status: 'running' | 'paused' | 'terminated';
61
+ }
62
+ /** One agent in the swarm. */
63
+ export interface SwarmAgent {
64
+ id: string;
65
+ /** Who delegated this agent. Absent (or unknown) makes it a root. */
66
+ parentId?: string | null;
67
+ role?: SwarmRole;
68
+ /** What it was asked to do — the row's primary line. */
69
+ task: string;
70
+ status: SwarmAgentStatus;
71
+ /** Specialization, when the role alone is too coarse: "api", "docs", "perf". */
72
+ label?: string;
73
+ /** The plan's stable key for this task ("t3"), rendered as a tag. */
74
+ key?: string;
75
+ /** One-line outcome, shown under the task once the agent lands. */
76
+ result?: string;
77
+ /** Fan-in gate this agent reports into. */
78
+ barrierId?: string | null;
79
+ /**
80
+ * The agent's own transcript, when it has one. This GATES the open
81
+ * affordance: a row or card shows the open button only when the host has both
82
+ * an `onOpen` handler and an agent with somewhere to go.
83
+ */
84
+ conversationId?: string;
85
+ /**
86
+ * The agent's VM.
87
+ *
88
+ * `null` means "known to have none"; `undefined` in an EVENT means "not
89
+ * reported" and leaves the last known value alone (see `mergeAgent`) —
90
+ * sandbox lifecycle is usually not journaled as swarm events, so a value
91
+ * carried on every agent update would be stale by construction.
92
+ */
93
+ sandbox?: SwarmSandbox | null;
94
+ /** Epoch ms. `startedAt` + `endedAt` drive the elapsed readout. */
95
+ startedAt?: number;
96
+ endedAt?: number;
97
+ }
98
+ /** The decomposition the root agent committed to. */
99
+ export interface SwarmPlan {
100
+ id?: string;
101
+ title: string;
102
+ status?: SwarmPlanStatus;
103
+ }
104
+ /** A fan-in gate: the point where parallel branches rejoin. */
105
+ export interface SwarmBarrier {
106
+ id: string;
107
+ /** Short stable key ("research"), for the tag beside the title. */
108
+ key?: string;
109
+ title: string;
110
+ /** Omit to let `summarizeBarrier` derive it from the members. */
111
+ status?: SwarmBarrierStatus;
112
+ }
113
+ /** A summarized output: a merged finding, a report, a deliverable. */
114
+ export interface SwarmArtifact {
115
+ id: string;
116
+ /** Which agent produced it. */
117
+ agentId?: string;
118
+ /** Set when the artifact is a barrier's merged summary. */
119
+ barrierId?: string | null;
120
+ /** Free-form producer tag ("report", "diff", "barrier_summary"). */
121
+ kind: string;
122
+ title?: string;
123
+ summary: string;
124
+ /** Epoch ms. */
125
+ createdAt?: number;
126
+ }
127
+ /** One agent plus its subtree, as `buildSwarmTree` returns it. */
128
+ export interface SwarmNode {
129
+ agent: SwarmAgent;
130
+ /** Distance from the root, computed from the tree — never trusted from data. */
131
+ depth: number;
132
+ children: SwarmNode[];
133
+ }
134
+ /** Aggregate agent counts, as `summarizeSwarm` returns them. */
135
+ export interface SwarmCounts {
136
+ total: number;
137
+ blocked: number;
138
+ queued: number;
139
+ running: number;
140
+ done: number;
141
+ failed: number;
142
+ cancelled: number;
143
+ /** blocked + queued + running — everything that has not landed yet. */
144
+ active: number;
145
+ /** Agents holding a VM that is not terminated. */
146
+ sandboxes: number;
147
+ }
148
+ /**
149
+ * Build the delegation forest from a flat agent list.
150
+ *
151
+ * Roots are agents with no parent AND agents whose parent is not in the list,
152
+ * so a filtered view (one branch, a search result) still renders as a tree
153
+ * instead of vanishing. Sibling order is the input order — the caller sorts.
154
+ *
155
+ * Cycle-safe: a parent chain that loops (which only a buggy producer can emit)
156
+ * drops the repeat instead of recursing forever.
157
+ */
158
+ export declare function buildSwarmTree(agents: SwarmAgent[]): SwarmNode[];
159
+ /** Walk a forest depth-first, parents before children (visible tree order). */
160
+ export declare function flattenSwarmTree(nodes: SwarmNode[]): SwarmNode[];
161
+ /** Count agents by status, plus live VMs. Pure — safe to call in a `$derived`. */
162
+ export declare function summarizeSwarm(agents: SwarmAgent[]): SwarmCounts;
163
+ /**
164
+ * A barrier's progress, derived from its members.
165
+ *
166
+ * The gate is only as done as the branch under it, so `status` is computed from
167
+ * the members unless the barrier carries an explicit one: any member still
168
+ * working keeps it `open`, a failure fails it, a cancellation cancels it. A
169
+ * barrier with no members yet reads `open` with a zero total, which is exactly
170
+ * what a just-declared gate is.
171
+ */
172
+ export declare function summarizeBarrier(barrier: SwarmBarrier, agents: SwarmAgent[]): {
173
+ done: number;
174
+ failed: number;
175
+ total: number;
176
+ status: SwarmBarrierStatus;
177
+ };
178
+ /**
179
+ * How long an agent has been working, in ms — `null` when it never started.
180
+ *
181
+ * `now` is an argument rather than a `Date.now()` call so the same inputs always
182
+ * render the same output (testable, SSR-safe, and a host that ticks a clock in
183
+ * `$state` gets a live counter for free).
184
+ */
185
+ export declare function swarmElapsed(agent: SwarmAgent, now: number): number | null;
186
+ /**
187
+ * How long ago something happened, as a console reads it: `now`, `4m`, `2h`,
188
+ * `3d`. Same clock rule as everything else here — `now` is an argument.
189
+ */
190
+ export declare function formatSwarmAge(at: number, now: number): string;
191
+ /** Compact elapsed readout: `840ms`, `4.2s`, `3m 07s`, `1h 04m`. */
192
+ export declare function formatSwarmDuration(ms: number): string;
193
+ /**
194
+ * One record from the swarm's event log.
195
+ *
196
+ * `seq` is the whole resumption contract: monotonic per swarm, and the client's
197
+ * watermark is the last one APPLIED — never the number of records held. Those
198
+ * differ the moment an event updates an agent already in the map, which is most
199
+ * events, and confusing the two silently re-requests or skips history.
200
+ */
201
+ export interface SwarmEvent {
202
+ seq: number;
203
+ /** Free-form producer label ("agent.completed"), passed through untouched. */
204
+ kind?: string;
205
+ agent?: SwarmAgent;
206
+ plan?: SwarmPlan;
207
+ barrier?: SwarmBarrier;
208
+ artifact?: SwarmArtifact;
209
+ }
210
+ /** A point-in-time view of the whole swarm, as the host's snapshot API returns it. */
211
+ export interface SwarmSnapshot {
212
+ /** The seq this snapshot is current as of — subscribe from here. */
213
+ seq?: number;
214
+ plan?: SwarmPlan | null;
215
+ agents?: SwarmAgent[];
216
+ barriers?: SwarmBarrier[];
217
+ artifacts?: SwarmArtifact[];
218
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The swarm model — the shape of a multi-agent run, and the projection logic
3
+ * the UI needs, with no transport attached.
4
+ *
5
+ * A swarm is one orchestrated effort: a root agent that decomposes a task,
6
+ * workers it delegates to, optional reviewers, and fan-in gates (barriers)
7
+ * where parallel branches rejoin before the work is merged into artifacts.
8
+ * Every real implementation streams that state as an append-only event log with
9
+ * a monotonic sequence number, because an hour-long run WILL outlive a socket.
10
+ *
11
+ * era ships the projection half of that contract and nothing else. The host
12
+ * owns the transport (oRPC, SSE, WebSocket, polling — era does not care) and
13
+ * feeds `SwarmController` snapshots and events; the controller owns the one
14
+ * invariant that makes reconnection tractable:
15
+ *
16
+ * the view is a pure function of (snapshot at seq S, every event with seq > S)
17
+ *
18
+ * which turns "resume a live view" into a watermark comparison instead of a
19
+ * state-transfer negotiation. Take a snapshot, subscribe from its seq, and a
20
+ * dropped socket, a server restart and a browser refresh all re-enter through
21
+ * the same door.
22
+ *
23
+ * This module is the pure half — shapes and projections, no runes, no clock.
24
+ * `SwarmController` (swarm.svelte.ts) is the live half, and it is optional: a
25
+ * host that already holds swarm state in its own store calls these functions
26
+ * directly and never constructs one.
27
+ */
28
+ /**
29
+ * Build the delegation forest from a flat agent list.
30
+ *
31
+ * Roots are agents with no parent AND agents whose parent is not in the list,
32
+ * so a filtered view (one branch, a search result) still renders as a tree
33
+ * instead of vanishing. Sibling order is the input order — the caller sorts.
34
+ *
35
+ * Cycle-safe: a parent chain that loops (which only a buggy producer can emit)
36
+ * drops the repeat instead of recursing forever.
37
+ */
38
+ export function buildSwarmTree(agents) {
39
+ const nodes = new Map();
40
+ for (const agent of agents)
41
+ nodes.set(agent.id, { agent, depth: 0, children: [] });
42
+ const roots = [];
43
+ for (const agent of agents) {
44
+ const node = nodes.get(agent.id);
45
+ const parent = agent.parentId ? nodes.get(agent.parentId) : undefined;
46
+ if (parent && parent !== node)
47
+ parent.children.push(node);
48
+ else
49
+ roots.push(node);
50
+ }
51
+ // Depth from the structure, and the visited set is what makes a cyclic chain
52
+ // terminate: a node reached twice is already placed, so it is not descended
53
+ // into again.
54
+ const seen = new Set();
55
+ const assign = (node, depth) => {
56
+ if (seen.has(node.agent.id))
57
+ return;
58
+ seen.add(node.agent.id);
59
+ node.depth = depth;
60
+ for (const child of node.children)
61
+ assign(child, depth + 1);
62
+ };
63
+ for (const root of roots)
64
+ assign(root, 0);
65
+ // A cycle leaves its members unreachable from any root — surface them as
66
+ // roots rather than dropping agents the caller can see in the list.
67
+ for (const agent of agents) {
68
+ if (seen.has(agent.id))
69
+ continue;
70
+ const node = nodes.get(agent.id);
71
+ roots.push(node);
72
+ assign(node, 0);
73
+ }
74
+ return roots;
75
+ }
76
+ /** Walk a forest depth-first, parents before children (visible tree order). */
77
+ export function flattenSwarmTree(nodes) {
78
+ const out = [];
79
+ const walk = (list) => {
80
+ for (const node of list) {
81
+ out.push(node);
82
+ walk(node.children);
83
+ }
84
+ };
85
+ walk(nodes);
86
+ return out;
87
+ }
88
+ /** Count agents by status, plus live VMs. Pure — safe to call in a `$derived`. */
89
+ export function summarizeSwarm(agents) {
90
+ const counts = {
91
+ total: agents.length,
92
+ blocked: 0,
93
+ queued: 0,
94
+ running: 0,
95
+ done: 0,
96
+ failed: 0,
97
+ cancelled: 0,
98
+ active: 0,
99
+ sandboxes: 0
100
+ };
101
+ for (const agent of agents) {
102
+ counts[agent.status]++;
103
+ if (agent.status === 'blocked' || agent.status === 'queued' || agent.status === 'running') {
104
+ counts.active++;
105
+ }
106
+ if (agent.sandbox && agent.sandbox.status !== 'terminated')
107
+ counts.sandboxes++;
108
+ }
109
+ return counts;
110
+ }
111
+ /**
112
+ * A barrier's progress, derived from its members.
113
+ *
114
+ * The gate is only as done as the branch under it, so `status` is computed from
115
+ * the members unless the barrier carries an explicit one: any member still
116
+ * working keeps it `open`, a failure fails it, a cancellation cancels it. A
117
+ * barrier with no members yet reads `open` with a zero total, which is exactly
118
+ * what a just-declared gate is.
119
+ */
120
+ export function summarizeBarrier(barrier, agents) {
121
+ let done = 0;
122
+ let failed = 0;
123
+ let total = 0;
124
+ let open = false;
125
+ let cancelled = false;
126
+ for (const agent of agents) {
127
+ if (agent.barrierId !== barrier.id)
128
+ continue;
129
+ total++;
130
+ if (agent.status === 'done')
131
+ done++;
132
+ else if (agent.status === 'failed')
133
+ failed++;
134
+ else if (agent.status === 'cancelled')
135
+ cancelled = true;
136
+ else
137
+ open = true;
138
+ }
139
+ const derived = open
140
+ ? 'open'
141
+ : failed > 0
142
+ ? 'failed'
143
+ : cancelled
144
+ ? 'cancelled'
145
+ : 'done';
146
+ return { done, failed, total, status: barrier.status ?? (total === 0 ? 'open' : derived) };
147
+ }
148
+ /**
149
+ * How long an agent has been working, in ms — `null` when it never started.
150
+ *
151
+ * `now` is an argument rather than a `Date.now()` call so the same inputs always
152
+ * render the same output (testable, SSR-safe, and a host that ticks a clock in
153
+ * `$state` gets a live counter for free).
154
+ */
155
+ export function swarmElapsed(agent, now) {
156
+ if (agent.startedAt == null)
157
+ return null;
158
+ return Math.max(0, (agent.endedAt ?? now) - agent.startedAt);
159
+ }
160
+ /**
161
+ * How long ago something happened, as a console reads it: `now`, `4m`, `2h`,
162
+ * `3d`. Same clock rule as everything else here — `now` is an argument.
163
+ */
164
+ export function formatSwarmAge(at, now) {
165
+ const seconds = Math.max(0, Math.floor((now - at) / 1000));
166
+ if (seconds < 60)
167
+ return 'now';
168
+ const minutes = Math.floor(seconds / 60);
169
+ if (minutes < 60)
170
+ return `${minutes}m`;
171
+ const hours = Math.floor(minutes / 60);
172
+ if (hours < 24)
173
+ return `${hours}h`;
174
+ return `${Math.floor(hours / 24)}d`;
175
+ }
176
+ /** Compact elapsed readout: `840ms`, `4.2s`, `3m 07s`, `1h 04m`. */
177
+ export function formatSwarmDuration(ms) {
178
+ if (ms < 1000)
179
+ return `${Math.round(ms)}ms`;
180
+ const seconds = ms / 1000;
181
+ if (seconds < 60)
182
+ return `${seconds.toFixed(1)}s`;
183
+ const minutes = Math.floor(seconds / 60);
184
+ if (minutes < 60)
185
+ return `${minutes}m ${String(Math.floor(seconds % 60)).padStart(2, '0')}s`;
186
+ return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
187
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The live half of the swarm model: one reconnectable, reactive view of a run.
3
+ *
4
+ * The shapes and the pure projections live next door in `swarm.ts` — everything
5
+ * here is state, so this is the only file in the family that needs runes (and
6
+ * the only one a host has to construct). See that module for the model itself
7
+ * and for why the sequence number is the whole resumption contract.
8
+ */
9
+ import { type SwarmAgent, type SwarmArtifact, type SwarmBarrier, type SwarmConnection, type SwarmEvent, type SwarmPlan, type SwarmSnapshot } from './swarm.js';
10
+ /**
11
+ * A reconnectable view of one swarm.
12
+ *
13
+ * Backend-free by design: the host does the fetching and the socket handling and
14
+ * hands the results here. A typical wiring is
15
+ *
16
+ * ```ts
17
+ * const swarm = new SwarmController();
18
+ * swarm.connection = 'loading';
19
+ * swarm.snapshot(await api.swarmSnapshot(id)); // sets the watermark
20
+ * swarm.connection = 'live';
21
+ * for await (const event of api.swarmStream(id, swarm.seq)) swarm.apply(event);
22
+ * ```
23
+ *
24
+ * and the retry path is the same three lines — re-snapshot (never rewinding the
25
+ * watermark), then subscribe from `seq` again. Everything the components need
26
+ * (`tree`, `counts`) is derived, so nothing has to be recomputed by hand.
27
+ */
28
+ export declare class SwarmController {
29
+ #private;
30
+ agents: SwarmAgent[];
31
+ plan: SwarmPlan | null;
32
+ barriers: SwarmBarrier[];
33
+ artifacts: SwarmArtifact[];
34
+ /** Stream health, for `Swarm.Footer`. The host sets it; nothing here does. */
35
+ connection: SwarmConnection;
36
+ /** Last transport error, shown beside the watermark. */
37
+ error: string | null;
38
+ /** The watermark: the last APPLIED event seq, and the resume point. */
39
+ seq: number;
40
+ constructor(snapshot?: SwarmSnapshot);
41
+ readonly tree: import("./swarm.js").SwarmNode[];
42
+ readonly counts: import("./swarm.js").SwarmCounts;
43
+ /**
44
+ * Merge a snapshot in.
45
+ *
46
+ * The watermark only ever moves FORWARD: a refresh taken while live events
47
+ * are already flowing is older than the view it is refreshing, and rewinding
48
+ * to its seq would replay every event since.
49
+ */
50
+ snapshot(snapshot: SwarmSnapshot): void;
51
+ /**
52
+ * Apply one event. Returns false when it was already applied — a backfill
53
+ * that overlaps what the snapshot already covered is a no-op, not a
54
+ * double-apply, which is what lets the resume path be careless about overlap.
55
+ */
56
+ apply(event: SwarmEvent): boolean;
57
+ /** Drop everything — switching to a different swarm, not reconnecting to this one. */
58
+ reset(): void;
59
+ }