@heretek-ai/epistemic-swarm 1.1.2 → 1.1.4
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/dist/tui.js +392 -228
- package/package.json +3 -3
- package/src/continue.ts +51 -14
- package/src/panels.tsx +159 -57
- package/src/policy.ts +25 -2
- package/src/rpc-def.ts +17 -3
- package/src/rpc.ts +30 -4
- package/src/seats.ts +109 -0
- package/src/server.ts +53 -9
- package/src/tui.tsx +29 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heretek-ai/epistemic-swarm",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
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,11 +41,11 @@
|
|
|
41
41
|
"test": "bun test"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@heretek-ai/es-core": "1.1.
|
|
44
|
+
"@heretek-ai/es-core": "1.1.4",
|
|
45
45
|
"@opencode/plugin": "2.0.24"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@babel/core": "7.
|
|
48
|
+
"@babel/core": "7.29.6",
|
|
49
49
|
"@babel/preset-typescript": "7.27.1",
|
|
50
50
|
"@heretek-ai/es-testkit": "1.0.0",
|
|
51
51
|
"@opencode/sdk": "2.0.24",
|
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
|
|
4
|
-
//
|
|
5
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
27
|
-
|
|
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)
|
|
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
|
|
3
|
-
// renders the one whose name is
|
|
4
|
-
// server plugin over RPC — the same
|
|
5
|
-
// open, a panel refetches on every
|
|
6
|
-
// seconds, so it follows a running
|
|
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
|
|
21
|
-
function usePanelState(
|
|
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,
|
|
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
|
-
|
|
64
|
-
{data().
|
|
65
|
-
</
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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>
|
|
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
|
-
<
|
|
89
|
-
|
|
90
|
-
{(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
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
|
-
|
|
229
|
-
|
|
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
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
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 =
|
|
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
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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:
|
|
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),
|