@heretek-ai/epistemic-swarm 1.1.2 → 1.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretek-ai/epistemic-swarm",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "Epistemic Swarm: an AI build factory for OpenCode v2 — grill, research, spec, build, QA and release, gated mechanically.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -41,7 +41,7 @@
41
41
  "test": "bun test"
42
42
  },
43
43
  "dependencies": {
44
- "@heretek-ai/es-core": "1.1.2",
44
+ "@heretek-ai/es-core": "1.1.3",
45
45
  "@opencode/plugin": "2.0.24"
46
46
  },
47
47
  "devDependencies": {
package/src/continue.ts CHANGED
@@ -1,40 +1,77 @@
1
1
  // Factory continuation: when the factory's own session finishes a turn while
2
2
  // work remains (not halted, not waiting on a human approval or the grill), we
3
- // re-prompt it to continue. Progress-aware: three idle turns without any state
4
- // change stop the loop so a confused agent cannot spin on budget.
5
- import { pendingApprovals } from "@heretek-ai/es-core"
3
+ // re-prompt it to continue. Progress-aware (core's progressPrint: stage
4
+ // machine, research artifacts, seat outcomes, audit head): three idle turns
5
+ // without progress pause the loop so a confused agent cannot spin on budget,
6
+ // and the pause is announced instead of silent. While one of its seats is
7
+ // still running the factory is not re-prompted: the host wakes it when the
8
+ // seat finishes (#60).
9
+ import { headline, pendingApprovals, progressPrint, readLiveness } from "@heretek-ai/es-core"
6
10
  import type { Runtime } from "./runtime.ts"
11
+ import type { SeatTracker } from "./seats.ts"
7
12
 
8
13
  const ACTIVE = new Set(["RESEARCH", "SPEC", "BUILD", "QA", "RELEASE"])
9
- const STALL_LIMIT = 3
14
+ export const STALL_LIMIT = 3
15
+
16
+ export interface FactoryContinuation {
17
+ (
18
+ event: { type: string; data?: { sessionID?: string } },
19
+ agentOf: (sessionID: string) => Promise<string | undefined>,
20
+ ): Promise<void>
21
+ /** The pause notice for the current run, if the loop paused and nothing has moved since. */
22
+ paused(): string | undefined
23
+ }
10
24
 
11
25
  export function createFactoryContinuation(
12
26
  ctx: { session: { prompt(input: any): Promise<unknown> } },
13
27
  runtime: Runtime,
14
- ) {
28
+ options: {
29
+ readonly seats?: Pick<SeatTracker, "hasRunningChildren">
30
+ readonly onPause?: (notice: string) => void
31
+ } = {},
32
+ ): FactoryContinuation {
15
33
  const stalls = new Map<string, { print: string; count: number }>()
16
- return async (
34
+ let notice: string | undefined
35
+
36
+ const continuation = async (
17
37
  event: { type: string; data?: { sessionID?: string } },
18
38
  agentOf: (sessionID: string) => Promise<string | undefined>,
19
39
  ) => {
20
40
  const sessionID = event.data?.sessionID
21
- if (event.type !== "session.execution.succeeded" || !sessionID) return
41
+ if (!sessionID) return
42
+ // The human (or the host) set the factory going again: the pause is over,
43
+ // and the restarted factory gets a fresh budget of idle turns.
44
+ if (event.type === "session.execution.started" && notice && (await agentOf(sessionID)) === "factory") {
45
+ notice = undefined
46
+ stalls.delete(sessionID)
47
+ return
48
+ }
49
+ if (event.type !== "session.execution.succeeded") return
22
50
  if ((await agentOf(sessionID)) !== "factory") return
51
+ if (options.seats?.hasRunningChildren(sessionID)) return
23
52
  const state = await runtime.factory.read()
24
53
  if (!state || !ACTIVE.has(state.stage)) return
25
54
  if ((await pendingApprovals(runtime.root)).length) return
26
- const print = JSON.stringify([
27
- state.stage,
28
- state.activePhase,
29
- state.phases.map((phase) => [phase.status, phase.failures, phase.history.length]),
30
- ])
55
+ const liveness = await readLiveness(runtime.root, state)
56
+ const print = progressPrint(state, liveness)
31
57
  const previous = stalls.get(sessionID)
32
58
  const count = previous?.print === print ? previous.count + 1 : 0
33
59
  stalls.set(sessionID, { print, count })
34
- if (count >= STALL_LIMIT) return
60
+ if (count >= STALL_LIMIT) {
61
+ if (count === STALL_LIMIT) {
62
+ const text = `Factory paused: ${STALL_LIMIT} turns in ${state.stage} without progress. Check es status, then run /factory to continue.`
63
+ notice = text
64
+ options.onPause?.(text)
65
+ }
66
+ return
67
+ }
68
+ notice = undefined
35
69
  await ctx.session.prompt({
36
70
  sessionID,
37
- text: `${await runtime.factory.summary(state)}\nThe factory run is not finished. Continue from the current stage (no human is needed unless an approval is due).`,
71
+ text: `${await runtime.factory.summary(state)}\n${headline(state, liveness)}\nThe factory run is not finished. Continue from the current stage (no human is needed unless an approval is due).`,
38
72
  })
39
73
  }
74
+
75
+ const paused = () => notice
76
+ return Object.assign(continuation, { paused })
40
77
  }
package/src/panels.tsx CHANGED
@@ -1,28 +1,53 @@
1
1
  // The four TUI panels: factory dashboard, LSP manager, hook inspector and
2
- // brainstorm board. Each is a session.panel slot contribution (the host
3
- // renders the one whose name is open) and reads structured state from the
4
- // server plugin over RPC — the same human-only channel the dialogs use. While
5
- // open, a panel refetches on every server `changed` event and every couple of
6
- // seconds, so it follows a running factory.
2
+ // brainstorm board, plus the prompt-footer liveness indicator. Each panel is a
3
+ // session.panel slot contribution (the host renders the one whose name is
4
+ // open) and reads structured state from the server plugin over RPC — the same
5
+ // human-only channel the dialogs use. While open, a panel refetches on every
6
+ // server `changed` event and every couple of seconds, so it follows a running
7
+ // factory. The host gives panels no key bindings of their own (#57): each
8
+ // panel binds Esc/q (close), f (fullscreen) and r (refresh) through a keymap
9
+ // layer created inside it, which the host scopes to the panel's focus, and
10
+ // shows them in a footer row.
7
11
  import { createResource, createSignal, For, onCleanup, Show } from "solid-js"
8
12
 
9
13
  export type PanelCall = (method: string, input?: unknown) => Promise<any>
10
14
  /** Subscribe to server state changes; returns the unsubscribe function. */
11
15
  export type PanelSubscribe = (listener: () => void) => () => void
12
16
 
17
+ /** The host's session.panel slot input (the parts a panel uses). */
18
+ export interface PanelHandle {
19
+ readonly close: () => void
20
+ readonly toggleFullscreen: () => void
21
+ }
22
+
23
+ interface PanelKeyCommand {
24
+ readonly title: string
25
+ readonly bind: string
26
+ readonly run: () => void
27
+ }
28
+ /** The host's keymap.layer: a layer created during render belongs to that panel's focus scope. */
29
+ export type PanelLayer = (factory: () => { readonly commands: readonly PanelKeyCommand[] }) => void
30
+
13
31
  export interface PanelProps {
14
32
  readonly call: PanelCall
15
33
  readonly subscribe: PanelSubscribe
34
+ readonly panel?: PanelHandle
35
+ readonly layer?: PanelLayer
16
36
  }
17
37
 
18
38
  export const PANEL_REFRESH_MS = 2_000
39
+ export const FOOTER_REFRESH_MS = 5_000
19
40
 
20
- /** Panel data, refetched on change events and on an interval while mounted. */
21
- function usePanelState(props: PanelProps, method: string): { data: () => any; error: () => string | undefined } {
41
+ /** Panel data, refetched on change events, on an interval while mounted, and on demand. */
42
+ function usePanelState(
43
+ props: PanelProps,
44
+ method: string,
45
+ everyMs = PANEL_REFRESH_MS,
46
+ ): { data: () => any; error: () => string | undefined; refresh: () => void } {
22
47
  const [version, setVersion] = createSignal(0)
23
48
  const bump = () => setVersion((value) => value + 1)
24
49
  const unsubscribe = props.subscribe(bump)
25
- const timer = setInterval(bump, PANEL_REFRESH_MS)
50
+ const timer = setInterval(bump, everyMs)
26
51
  onCleanup(() => {
27
52
  unsubscribe()
28
53
  clearInterval(timer)
@@ -35,13 +60,29 @@ function usePanelState(props: PanelProps, method: string): { data: () => any; er
35
60
  (error: any) => ({ error: String(error?.data?.reason ?? error?.message ?? error) }),
36
61
  ),
37
62
  )
38
- return { data: () => state.latest?.data, error: () => state.latest?.error }
63
+ return { data: () => state.latest?.data, error: () => state.latest?.error, refresh: bump }
64
+ }
65
+
66
+ /** The panel's keys: Esc or q closes it, f toggles fullscreen, r refetches. */
67
+ export const PANEL_KEYS_HINT = "esc/q close · f fullscreen · r refresh"
68
+
69
+ function usePanelKeys(props: PanelProps, refresh: () => void) {
70
+ const panel = props.panel
71
+ if (!panel || !props.layer) return
72
+ props.layer(() => ({
73
+ commands: [
74
+ { title: "close", bind: "escape,q", run: () => panel.close() },
75
+ { title: "fullscreen", bind: "f", run: () => panel.toggleFullscreen() },
76
+ { title: "refresh", bind: "r", run: refresh },
77
+ ],
78
+ }))
39
79
  }
40
80
 
41
81
  const Frame = (props: { title: string; children?: any }) => (
42
82
  <box flexDirection="column" padding={1} gap={1}>
43
83
  <text>{props.title}</text>
44
84
  {props.children}
85
+ <text>{PANEL_KEYS_HINT}</text>
45
86
  </box>
46
87
  )
47
88
 
@@ -53,21 +94,24 @@ const Failed = (props: { error: () => string | undefined }) => (
53
94
 
54
95
  const FactoryPanel = (props: PanelProps) => {
55
96
  const state = usePanelState(props, "factoryState")
97
+ usePanelKeys(props, state.refresh)
56
98
  return (
57
99
  <Frame title="Factory dashboard">
58
100
  <Failed error={state.error} />
59
101
  <Show when={state.data()} fallback={<text>{state.error() ? "" : "Loading factory state…"}</text>}>
60
102
  {(data: () => any) => (
61
103
  <>
62
- <text>
63
- Stage: {data().stage}
64
- {data().runId ? ` · ${data().runId}` : ""}
65
- </text>
66
- <text>
67
- Spend: ${data().spend.usd.toFixed(2)}
68
- {data().spend.ceilingUSD ? ` / $${data().spend.ceilingUSD}` : ""}
69
- {data().spend.estimated ? " (estimated)" : ""}
70
- </text>
104
+ <text>{data().headline}</text>
105
+ <Show when={data().header} fallback={<text>Stage: {data().stage}</text>}>
106
+ <text>{data().header}</text>
107
+ </Show>
108
+ <Show when={data().stage !== "NONE"}>
109
+ <text>
110
+ Spend: ${data().spend.usd.toFixed(2)}
111
+ {data().spend.ceilingUSD ? ` / $${data().spend.ceilingUSD}` : ""}
112
+ {data().spend.estimated ? " (estimated)" : ""}
113
+ </text>
114
+ </Show>
71
115
  <Show when={data().tree}>
72
116
  <text>
73
117
  Design tree (round {data().tree.round}): {data().tree.settled} settled · {data().tree.open} open ·{" "}
@@ -75,36 +119,65 @@ const FactoryPanel = (props: PanelProps) => {
75
119
  {data().tree.facts ? ` (${data().tree.facts} for research)` : ""}
76
120
  </text>
77
121
  </Show>
122
+ <Show when={data().paused}>
123
+ <text>⏸ {data().paused}</text>
124
+ </Show>
78
125
  <Show when={data().halt}>
79
126
  <text>HALTED: {data().halt}</text>
80
127
  </Show>
81
- <Show when={data().release}>
82
- <text>PR: {data().release}</text>
83
- </Show>
84
128
  <Show when={data().pending.length}>
85
- <text>Waiting on human: {data().pending.join(", ")}</text>
129
+ <text>
130
+ Waiting on you: approve {data().pending.join(", ")}: run `es approve {data().pending[0]}` in a terminal
131
+ (/es-approve previews it)
132
+ </text>
133
+ </Show>
134
+ <Show when={data().seats.length}>
135
+ <box flexDirection="column">
136
+ <text>Seats:</text>
137
+ <For each={data().seats}>{(line: string) => <text>{` ${line}`}</text>}</For>
138
+ </box>
139
+ </Show>
140
+ <Show when={data().research}>
141
+ <text>Research: {data().research}</text>
142
+ </Show>
143
+ <Show when={data().phases.length}>
144
+ <box flexDirection="column">
145
+ <text>Phases:</text>
146
+ <For each={data().phases}>
147
+ {(phase: any) => (
148
+ <text>
149
+ {phase.id === data().activePhase ? "▶ " : " "}
150
+ {phase.id}
151
+ {phase.title ? ` ${phase.title}` : ""} · {phase.status} · failures {phase.failures}
152
+ </text>
153
+ )}
154
+ </For>
155
+ </box>
86
156
  </Show>
87
157
  <Show when={data().audits?.length}>
88
- <text>Audits:</text>
89
- <For each={data().audits}>
90
- {(audit: any) => (
91
- <text>
92
- {" "}
93
- {audit.id} · {audit.target} · {audit.status} r{audit.round} · t:{audit.thesis} a:{audit.antithesis}
94
- {audit.tiebreak ? ` x:${audit.tiebreak}` : ""}
95
- </text>
96
- )}
97
- </For>
158
+ <box flexDirection="column">
159
+ <text>Audits:</text>
160
+ <For each={data().audits}>
161
+ {(audit: any) => (
162
+ <text>
163
+ {" "}
164
+ {audit.id} · {audit.target} · {audit.status} r{audit.round} · t:{audit.thesis} a:
165
+ {audit.antithesis}
166
+ {audit.tiebreak ? ` x:${audit.tiebreak}` : ""}
167
+ </text>
168
+ )}
169
+ </For>
170
+ </box>
171
+ </Show>
172
+ <Show when={data().events.length}>
173
+ <box flexDirection="column">
174
+ <text>Recent events:</text>
175
+ <For each={data().events}>{(line: string) => <text>{` ${line}`}</text>}</For>
176
+ </box>
177
+ </Show>
178
+ <Show when={data().release}>
179
+ <text>PR: {data().release}</text>
98
180
  </Show>
99
- <text>Phases:</text>
100
- <For each={data().phases}>
101
- {(phase: any) => (
102
- <text>
103
- {phase.id === data().activePhase ? "▶ " : " "}
104
- {phase.id} · {phase.status} · failures {phase.failures}
105
- </text>
106
- )}
107
- </For>
108
181
  </>
109
182
  )}
110
183
  </Show>
@@ -114,6 +187,7 @@ const FactoryPanel = (props: PanelProps) => {
114
187
 
115
188
  const LspPanel = (props: PanelProps) => {
116
189
  const state = usePanelState(props, "lspState")
190
+ usePanelKeys(props, state.refresh)
117
191
  return (
118
192
  <Frame title="Language servers">
119
193
  <Failed error={state.error} />
@@ -140,6 +214,7 @@ const LspPanel = (props: PanelProps) => {
140
214
 
141
215
  const HooksPanel = (props: PanelProps) => {
142
216
  const state = usePanelState(props, "hooksState")
217
+ usePanelKeys(props, state.refresh)
143
218
  return (
144
219
  <Frame title="Hook bridge">
145
220
  <Failed error={state.error} />
@@ -172,6 +247,7 @@ const HooksPanel = (props: PanelProps) => {
172
247
 
173
248
  const BrainstormPanel = (props: PanelProps) => {
174
249
  const state = usePanelState(props, "brainstormState")
250
+ usePanelKeys(props, state.refresh)
175
251
  return (
176
252
  <Frame title="Brainstorm board">
177
253
  <Failed error={state.error} />
@@ -218,28 +294,54 @@ export const PANEL_COMPONENTS: Readonly<Record<string, (props: PanelProps) => an
218
294
  brainstorm: BrainstormPanel,
219
295
  }
220
296
 
221
- const PANELS = Object.entries(PANEL_COMPONENTS).map(([name, Panel]) => ({
222
- name,
223
- render: (props: PanelProps) => <Panel call={props.call} subscribe={props.subscribe} />,
224
- }))
297
+ /** The prompt-footer liveness indicator: "ES · RESEARCH · es-research-alpha running · 12s"; hidden without a run. */
298
+ export const FactoryFooter = (props: PanelProps) => {
299
+ const state = usePanelState(props, "status", FOOTER_REFRESH_MS)
300
+ return (
301
+ <Show when={state.data()?.footer}>
302
+ <text>{state.data().footer}</text>
303
+ </Show>
304
+ )
305
+ }
306
+
307
+ /**
308
+ * Claim a slot. The entry point moved across host releases: opencode 2.0.x
309
+ * exposes it as context.ui.slot (arity 1 object form or arity 2 name/render
310
+ * form), later hosts as bare context.slot. Without either the slot stays
311
+ * unclaimed, but setup must not throw (commands keep working).
312
+ */
313
+ function claimSlot(context: any, name: string, render: (input: any) => any): (() => void) | undefined {
314
+ const slotFn = typeof context.slot === "function" ? context.slot : context.ui?.slot
315
+ if (typeof slotFn !== "function") return undefined
316
+ const dispose = slotFn.length <= 1 ? slotFn({ append: name, render }) : slotFn(name, render)
317
+ return typeof dispose === "function" ? dispose : undefined
318
+ }
225
319
 
226
- /** Claim the session.panel slot once per panel; each claim filters by name. */
320
+ /** Claim the session.panel slot once per panel (each claim filters by name), and the footer indicator. */
227
321
  export function registerPanels(context: any, call: PanelCall, subscribe: PanelSubscribe): () => void {
228
- // Slot entry point moved across host releases: opencode 2.0.x exposes it
229
- // as context.ui.slot (arity 1 object form or arity 2 name/render form),
230
- // later hosts as bare context.slot. Without either, panels stay
231
- // unregistered but setup must not throw (commands keep working).
232
- const slotFn = typeof context.slot === "function" ? context.slot : context.ui?.slot
233
- if (typeof slotFn !== "function") return () => {}
322
+ const layer: PanelLayer | undefined =
323
+ typeof context.keymap?.layer === "function" ? (factory) => context.keymap.layer(factory) : undefined
234
324
  const disposers: Array<() => void> = []
235
- for (const panel of PANELS) {
236
- const render = (input: { name: string }) => (input.name === panel.name ? panel.render({ call, subscribe }) : null)
237
- const dispose = slotFn.length <= 1 ? slotFn({ append: "session.panel", render }) : slotFn("session.panel", render)
238
- if (typeof dispose === "function") disposers.push(dispose)
325
+ for (const [name, Panel] of Object.entries(PANEL_COMPONENTS)) {
326
+ const dispose = claimSlot(context, "session.panel", (input: { name: string } & Partial<PanelHandle>) =>
327
+ input.name === name ? (
328
+ <Panel
329
+ call={call}
330
+ subscribe={subscribe}
331
+ {...(input.close && input.toggleFullscreen
332
+ ? { panel: { close: input.close, toggleFullscreen: input.toggleFullscreen } }
333
+ : {})}
334
+ {...(layer ? { layer } : {})}
335
+ />
336
+ ) : null,
337
+ )
338
+ if (dispose) disposers.push(dispose)
239
339
  }
340
+ const footer = claimSlot(context, "prompt.footer.status", () => <FactoryFooter call={call} subscribe={subscribe} />)
341
+ if (footer) disposers.push(footer)
240
342
  return () => {
241
343
  for (const dispose of disposers) dispose()
242
344
  }
243
345
  }
244
346
 
245
- export const PANEL_NAMES = PANELS.map((panel) => panel.name)
347
+ export const PANEL_NAMES = Object.keys(PANEL_COMPONENTS)
package/src/policy.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  import { Error as ToolError } from "@opencode/plugin/promise/tool"
23
23
  import { HOST_WEB_TOOLS } from "./agents.ts"
24
24
  import type { Runtime } from "./runtime.ts"
25
+ import type { SeatTracker } from "./seats.ts"
25
26
 
26
27
  const WRITE_TOOLS = new Set(["write", "edit"])
27
28
 
@@ -49,10 +50,32 @@ const appendContent = (content: unknown, text: string) =>
49
50
  ? [...content, { type: "text", text }]
50
51
  : text
51
52
 
52
- export function createPolicyHooks(runtime: Runtime) {
53
- const before = async (event: { tool: string; agent: string; id: string; input: unknown }) => {
53
+ /**
54
+ * Factory seats launch seats in the foreground only (#60). A background seat
55
+ * ends the launcher's turn, and in a headless run that turn's process exits
56
+ * and suspends the seat until the next turn; the launcher then reads "no
57
+ * progress" as a stall and relaunches a second writer. A foreground call
58
+ * returns when the seat finishes; two calls in one message run in parallel.
59
+ */
60
+ function guardSeatLaunch(event: { agent: string; input: Record<string, any> }, seats: SeatTracker | undefined) {
61
+ if (event.input.background === true)
62
+ deny(
63
+ "Factory seats run in the foreground: call the subagent tool without background (it returns when the seat finishes). To run a pair in parallel, put both subagent calls in the same message.",
64
+ )
65
+ const target = typeof event.input.agent === "string" ? event.input.agent : undefined
66
+ const live = target ? seats?.running(target).find((seat) => seat.sessionID !== event.input.sessionID) : undefined
67
+ if (live)
68
+ deny(
69
+ `${live.agent} is still running (session ${live.sessionID}, last activity ${live.lastActivityAt}); wait for it to finish instead of launching a second one. es_status lists the running seats.`,
70
+ )
71
+ }
72
+
73
+ export function createPolicyHooks(runtime: Runtime, seats?: SeatTracker) {
74
+ const before = async (event: { tool: string; agent: string; id: string; sessionID?: string; input: unknown }) => {
54
75
  const seat = seatOf(event.agent)
55
76
  const input = (event.input ?? {}) as Record<string, any>
77
+ if (seat && event.tool === "subagent") guardSeatLaunch({ agent: event.agent, input }, seats)
78
+ if (seat && event.sessionID) await seats?.onTool(event.sessionID, event.agent, event.tool)
56
79
  if (seat || event.tool.startsWith("es_")) {
57
80
  const stop = await checkStopFile(runtime.root)
58
81
  if (stop.stopped)
package/src/rpc-def.ts CHANGED
@@ -23,9 +23,15 @@ const Preview = obj({ ok: { type: "boolean" }, title: str, lines: strArray, prob
23
23
  export const EsRpc = Rpc.define({
24
24
  id: "epistemic-swarm",
25
25
  methods: {
26
+ /** The summary block, plus the plain headline and the one-line footer indicator (absent without a run). */
26
27
  status: {
27
28
  input: obj({}),
28
- output: obj({ summary: str, stage: str, pending: strArray }, ["summary", "stage", "pending"]),
29
+ output: obj({ summary: str, stage: str, pending: strArray, headline: str, footer: str, paused: str }, [
30
+ "summary",
31
+ "stage",
32
+ "pending",
33
+ "headline",
34
+ ]),
29
35
  },
30
36
  /** The effective config (JSON), the files it came from, and load warnings to show once. */
31
37
  configState: {
@@ -60,6 +66,13 @@ export const EsRpc = Rpc.define({
60
66
  phases: { type: "array" },
61
67
  pending: strArray,
62
68
  audits: { type: "array" },
69
+ /** Liveness (1.1.3), rendered on the server with core's formatters. */
70
+ headline: str,
71
+ header: str,
72
+ seats: strArray,
73
+ research: str,
74
+ events: strArray,
75
+ paused: str,
63
76
  tree: obj(
64
77
  Object.fromEntries(
65
78
  ["round", "total", "settled", "open", "deferred", "facts", "frontier"].map((key) => [
@@ -70,7 +83,7 @@ export const EsRpc = Rpc.define({
70
83
  ["round", "total", "settled", "open", "deferred", "facts", "frontier"],
71
84
  ),
72
85
  },
73
- ["stage", "spend", "phases", "pending"],
86
+ ["stage", "spend", "phases", "pending", "headline", "seats", "events"],
74
87
  ),
75
88
  },
76
89
  lspState: {
@@ -119,6 +132,7 @@ export const EsRpc = Rpc.define({
119
132
  },
120
133
  },
121
134
  events: {
122
- changed: { schema: obj({ stage: str, summary: str }, ["stage", "summary"]) },
135
+ /** The factory state changed; `notice` carries an announcement (a paused continuation). */
136
+ changed: { schema: obj({ stage: str, summary: str, notice: str }, ["stage", "summary"]) },
123
137
  },
124
138
  })
package/src/rpc.ts CHANGED
@@ -10,23 +10,29 @@ import {
10
10
  capabilityLoss,
11
11
  commandSetHash,
12
12
  describeTarget,
13
+ eventLine,
13
14
  factorySummary,
15
+ footerLine,
14
16
  type HookEngine,
15
17
  hashJson,
18
+ headerLine,
19
+ headline,
16
20
  installServer,
17
21
  isTrusted,
18
22
  loadGatesConfig,
19
23
  readFrontier,
20
24
  readIdeas,
25
+ readLiveness,
21
26
  readPlan,
22
27
  readResult,
23
28
  readScores,
24
29
  recordConsent,
30
+ researchLine,
31
+ seatLine,
25
32
  treeCounts,
26
33
  verifyControl,
27
34
  } from "@heretek-ai/es-core"
28
35
  import type { Runtime } from "./runtime.ts"
29
- import { pendingApprovals } from "./tools.ts"
30
36
 
31
37
  interface Ticket {
32
38
  readonly kind: string
@@ -36,7 +42,12 @@ interface Ticket {
36
42
 
37
43
  const TTL_MS = 120_000
38
44
 
39
- export function createRpcHandlers(runtime: Runtime, _notify: () => Promise<void>, hooks?: HookEngine) {
45
+ export function createRpcHandlers(
46
+ runtime: Runtime,
47
+ _notify: () => Promise<void>,
48
+ hooks?: HookEngine,
49
+ live: { readonly paused?: () => string | undefined } = {},
50
+ ) {
40
51
  const tickets = new Map<string, Ticket>()
41
52
  const issue = (kind: string, subject: unknown) => {
42
53
  const token = randomBytes(12).toString("hex")
@@ -74,10 +85,16 @@ export function createRpcHandlers(runtime: Runtime, _notify: () => Promise<void>
74
85
  return {
75
86
  status: async () => {
76
87
  const state = await runtime.factory.read()
88
+ const liveness = await readLiveness(runtime.root, state)
89
+ const paused = live.paused?.()
90
+ const footer = footerLine(state, liveness, paused)
77
91
  return {
78
92
  summary: await runtime.factory.summary(state),
79
93
  stage: state?.stage ?? "NONE",
80
- pending: (await pendingApprovals(runtime.root)).map((item) => item.stage),
94
+ pending: liveness.pending.map((item) => item.stage),
95
+ headline: headline(state, liveness),
96
+ ...(footer ? { footer } : {}),
97
+ ...(paused ? { paused } : {}),
81
98
  }
82
99
  },
83
100
  configState: async () => ({
@@ -88,7 +105,16 @@ export function createRpcHandlers(runtime: Runtime, _notify: () => Promise<void>
88
105
  factoryState: async () => {
89
106
  const state = await runtime.factory.read()
90
107
  const frontier = await readFrontier(runtime.root).catch(() => undefined)
108
+ const liveness = await readLiveness(runtime.root, state)
109
+ const research = researchLine(liveness)
110
+ const paused = live.paused?.()
91
111
  return {
112
+ headline: headline(state, liveness),
113
+ ...(state ? { header: headerLine(state, liveness) } : {}),
114
+ seats: liveness.seats.map((seat) => seatLine(liveness, seat)),
115
+ ...(research ? { research } : {}),
116
+ events: liveness.events.map(eventLine),
117
+ ...(paused ? { paused } : {}),
92
118
  ...(frontier ? { tree: treeCounts(frontier) } : {}),
93
119
  stage: state?.stage ?? "NONE",
94
120
  ...(state?.runId ? { runId: state.runId } : {}),
@@ -107,7 +133,7 @@ export function createRpcHandlers(runtime: Runtime, _notify: () => Promise<void>
107
133
  failures: phase.failures,
108
134
  qa: Object.fromEntries(Object.entries(phase.qa).filter(([, verdict]) => verdict !== undefined)),
109
135
  })),
110
- pending: (await pendingApprovals(runtime.root)).map((item) => item.stage),
136
+ pending: liveness.pending.map((item) => item.stage),
111
137
  audits: (state ? allAudits(state) : []).map((audit) => ({
112
138
  id: audit.id,
113
139
  target: describeTarget(audit.target),