@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/dist/tui.js +392 -228
- package/package.json +2 -2
- 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/src/seats.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Seat tracker (1.1.3, #60): follows factory seats (registry subagents running
|
|
2
|
+
// as host child sessions) through host events and records their state, last
|
|
3
|
+
// tool and last activity in .factory/runtime/seats.json. es_status, `es
|
|
4
|
+
// status`, the dashboard and the headless driver read it; the foreground guard
|
|
5
|
+
// and the continuation ask it what is running right now.
|
|
6
|
+
import { agentSpec, type SeatRecord, updateSeats } from "@heretek-ai/es-core"
|
|
7
|
+
import type { Runtime } from "./runtime.ts"
|
|
8
|
+
|
|
9
|
+
/** Activity-only updates (tool calls, steps) are persisted at most this often per seat. */
|
|
10
|
+
const ACTIVITY_PERSIST_MS = 10_000
|
|
11
|
+
|
|
12
|
+
interface HostEvent {
|
|
13
|
+
readonly type: string
|
|
14
|
+
readonly data?: {
|
|
15
|
+
readonly sessionID?: string
|
|
16
|
+
readonly parentID?: string
|
|
17
|
+
readonly agent?: string
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SeatTracker {
|
|
22
|
+
/** Feed one host event (session.created, session.execution.*, session.step.ended). */
|
|
23
|
+
onEvent(event: HostEvent, agentOf: (sessionID: string) => Promise<string | undefined>): Promise<void>
|
|
24
|
+
/** A seat is about to run `tool` (from the execute.before hook, which knows the tool name). */
|
|
25
|
+
onTool(sessionID: string, agent: string, tool: string): Promise<void>
|
|
26
|
+
/** Running seats in this host, optionally only one agent's. */
|
|
27
|
+
running(agent?: string): SeatRecord[]
|
|
28
|
+
/** Whether `parentID` has a seat child still running. */
|
|
29
|
+
hasRunningChildren(parentID: string): boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Factory seats are the registry's subagents; primary agents (factory, grill) are drivers, not seats. */
|
|
33
|
+
const isSeat = (agent: string | undefined): agent is string => agentSpec(agent)?.mode === "subagent"
|
|
34
|
+
|
|
35
|
+
export function createSeatTracker(
|
|
36
|
+
runtime: Runtime,
|
|
37
|
+
options: { readonly onChange?: () => void; readonly now?: () => Date } = {},
|
|
38
|
+
): SeatTracker {
|
|
39
|
+
const seats = new Map<string, SeatRecord>()
|
|
40
|
+
const persistedAt = new Map<string, number>()
|
|
41
|
+
const now = () => (options.now ?? (() => new Date()))()
|
|
42
|
+
|
|
43
|
+
const persist = async (record: SeatRecord, transition: boolean) => {
|
|
44
|
+
const at = now().getTime()
|
|
45
|
+
if (!transition && at - (persistedAt.get(record.sessionID) ?? 0) < ACTIVITY_PERSIST_MS) return
|
|
46
|
+
persistedAt.set(record.sessionID, at)
|
|
47
|
+
const state = await runtime.factory.read().catch(() => undefined)
|
|
48
|
+
if (state)
|
|
49
|
+
await updateSeats(runtime.root, state.runId, (list) => {
|
|
50
|
+
const index = list.findIndex((item) => item.sessionID === record.sessionID)
|
|
51
|
+
if (index === -1) list.push(record)
|
|
52
|
+
else list[index] = record
|
|
53
|
+
}).catch(() => undefined)
|
|
54
|
+
if (transition) options.onChange?.()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const touch = async (sessionID: string, patch: Partial<SeatRecord>, transition: boolean) => {
|
|
58
|
+
const current = seats.get(sessionID)
|
|
59
|
+
if (!current) return
|
|
60
|
+
const next: SeatRecord = { ...current, lastActivityAt: now().toISOString(), ...patch }
|
|
61
|
+
seats.set(sessionID, next)
|
|
62
|
+
await persist(next, transition)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const start = async (sessionID: string, agent: string, parentID: string | undefined) => {
|
|
66
|
+
const at = now().toISOString()
|
|
67
|
+
// A continued seat keeps its start and last tool; it is running again, so it has no end.
|
|
68
|
+
const { endedAt: _ended, ...base } = seats.get(sessionID) ?? { sessionID, agent, startedAt: at }
|
|
69
|
+
const record: SeatRecord = { ...base, ...(parentID ? { parentID } : {}), lastActivityAt: at, state: "running" }
|
|
70
|
+
seats.set(sessionID, record)
|
|
71
|
+
await persist(record, true)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
async onEvent(event, agentOf) {
|
|
76
|
+
const sessionID = event.data?.sessionID
|
|
77
|
+
if (!sessionID) return
|
|
78
|
+
if (event.type === "session.created") {
|
|
79
|
+
if (event.data?.parentID && isSeat(event.data.agent))
|
|
80
|
+
await start(sessionID, event.data.agent, event.data.parentID)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
if (event.type === "session.execution.started") {
|
|
84
|
+
// A seat created before this host started (or continued later) is picked up here.
|
|
85
|
+
const agent = seats.get(sessionID)?.agent ?? (await agentOf(sessionID))
|
|
86
|
+
if (isSeat(agent)) await start(sessionID, agent, seats.get(sessionID)?.parentID)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
if (!seats.has(sessionID)) return
|
|
90
|
+
const at = now().toISOString()
|
|
91
|
+
if (event.type === "session.execution.succeeded")
|
|
92
|
+
await touch(sessionID, { state: "completed", endedAt: at }, true)
|
|
93
|
+
else if (event.type === "session.execution.failed") await touch(sessionID, { state: "failed", endedAt: at }, true)
|
|
94
|
+
else if (event.type === "session.execution.interrupted")
|
|
95
|
+
await touch(sessionID, { state: "interrupted", endedAt: at }, true)
|
|
96
|
+
else if (event.type === "session.step.ended") await touch(sessionID, {}, false)
|
|
97
|
+
},
|
|
98
|
+
async onTool(sessionID, agent, tool) {
|
|
99
|
+
if (!isSeat(agent) || !seats.has(sessionID)) return
|
|
100
|
+
await touch(sessionID, { lastTool: tool }, false)
|
|
101
|
+
},
|
|
102
|
+
running(agent) {
|
|
103
|
+
return [...seats.values()].filter((seat) => seat.state === "running" && (!agent || seat.agent === agent))
|
|
104
|
+
},
|
|
105
|
+
hasRunningChildren(parentID) {
|
|
106
|
+
return [...seats.values()].some((seat) => seat.state === "running" && seat.parentID === parentID)
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -9,9 +9,11 @@ import {
|
|
|
9
9
|
factoryLayout,
|
|
10
10
|
formatReport,
|
|
11
11
|
git,
|
|
12
|
+
livenessLines,
|
|
12
13
|
loadSkills,
|
|
13
14
|
parseAuditTarget,
|
|
14
15
|
readJson,
|
|
16
|
+
readLiveness,
|
|
15
17
|
researchSourcesDir,
|
|
16
18
|
runGates,
|
|
17
19
|
} from "@heretek-ai/es-core"
|
|
@@ -24,6 +26,7 @@ import { createWebCache, PendingSearches, registerWebsearch } from "./research.t
|
|
|
24
26
|
import { createRpcHandlers } from "./rpc.ts"
|
|
25
27
|
import { EsRpc } from "./rpc-def.ts"
|
|
26
28
|
import { createRuntime, parseOptions } from "./runtime.ts"
|
|
29
|
+
import { createSeatTracker } from "./seats.ts"
|
|
27
30
|
import { createSessionHooks, createSpendTracker } from "./session.ts"
|
|
28
31
|
import { registerTools } from "./tools.ts"
|
|
29
32
|
|
|
@@ -74,8 +77,26 @@ export default Plugin.define({
|
|
|
74
77
|
const searches = new PendingSearches()
|
|
75
78
|
await ctx.websearch.transform((editor) => registerWebsearch(editor as any, runtime, searches))
|
|
76
79
|
|
|
80
|
+
// `changed` reaches the TUI (dashboard refetch, stage toast, footer). Bursts
|
|
81
|
+
// (seat activity, tool runs) are coalesced to at most one emit a second.
|
|
82
|
+
let emitChanged: (notice?: string) => Promise<void> = async () => {}
|
|
83
|
+
let lastEmit = 0
|
|
84
|
+
let queued: ReturnType<typeof setTimeout> | undefined
|
|
85
|
+
const notifySoon = () => {
|
|
86
|
+
if (queued) return
|
|
87
|
+
queued = setTimeout(
|
|
88
|
+
() => {
|
|
89
|
+
queued = undefined
|
|
90
|
+
lastEmit = Date.now()
|
|
91
|
+
void emitChanged()
|
|
92
|
+
},
|
|
93
|
+
Math.max(0, 1000 - (Date.now() - lastEmit)),
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
// Seat liveness (#60): fed by host events and the policy hook; read by the guard and the continuation.
|
|
97
|
+
const seats = createSeatTracker(runtime, { onChange: notifySoon })
|
|
77
98
|
// Our policy runs first, so its denials win over any user hook.
|
|
78
|
-
const policy = createPolicyHooks(runtime)
|
|
99
|
+
const policy = createPolicyHooks(runtime, seats)
|
|
79
100
|
const bridge = await createHookBridge(ctx as any, runtime, () => servers)
|
|
80
101
|
const sessionAgents = new Map<string, string | undefined>()
|
|
81
102
|
const agentOf = async (sessionID: string) => {
|
|
@@ -104,17 +125,35 @@ export default Plugin.define({
|
|
|
104
125
|
await ctx.session.hook("context", session.context as any)
|
|
105
126
|
await ctx.session.hook("context", bridge.context as any)
|
|
106
127
|
await ctx.session.hook("compaction", session.compaction as any)
|
|
107
|
-
const continuation = createFactoryContinuation(ctx as any, runtime
|
|
128
|
+
const continuation = createFactoryContinuation(ctx as any, runtime, {
|
|
129
|
+
seats,
|
|
130
|
+
onPause: (notice) => void emitChanged(notice),
|
|
131
|
+
})
|
|
108
132
|
|
|
109
133
|
// Human-only channel for the TUI (approvals, trust, resume).
|
|
110
134
|
let registration: { events: { emit: (...args: any[]) => Promise<void> } } | undefined
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
.
|
|
135
|
+
// Fire-and-forget from event handlers: a state that cannot be read (halted
|
|
136
|
+
// mid-write, forged seal) must not surface as an unhandled rejection.
|
|
137
|
+
emitChanged = async (notice) => {
|
|
138
|
+
try {
|
|
139
|
+
const state = await runtime.factory.read()
|
|
140
|
+
await registration?.events.emit("changed", {
|
|
141
|
+
stage: state?.stage ?? "NONE",
|
|
142
|
+
summary: await runtime.factory.summary(state),
|
|
143
|
+
...(notice ? { notice } : {}),
|
|
144
|
+
})
|
|
145
|
+
} catch {
|
|
146
|
+
// The next change or the panels' poll catches up.
|
|
147
|
+
}
|
|
116
148
|
}
|
|
117
|
-
registration = await ctx.rpc.register(
|
|
149
|
+
registration = await ctx.rpc.register(
|
|
150
|
+
EsRpc,
|
|
151
|
+
createRpcHandlers(runtime, () => emitChanged(), bridge.engine, { paused: () => continuation.paused() }) as any,
|
|
152
|
+
)
|
|
153
|
+
// Every es_* tool may move the factory: let the TUI know (#62).
|
|
154
|
+
await ctx.tool.hook("execute.after", (async (event: { tool: string }) => {
|
|
155
|
+
if (event.tool.startsWith("es_")) notifySoon()
|
|
156
|
+
}) as any)
|
|
118
157
|
|
|
119
158
|
await ctx.command.transform((editor) => {
|
|
120
159
|
editor.add({
|
|
@@ -309,7 +348,10 @@ export default Plugin.define({
|
|
|
309
348
|
name: "status",
|
|
310
349
|
description: "Show the factory state (Epistemic Swarm)",
|
|
311
350
|
execute: async ({ sessionID }) => {
|
|
312
|
-
|
|
351
|
+
const state = await runtime.factory.read()
|
|
352
|
+
const liveness = await readLiveness(runtime.root, state)
|
|
353
|
+
const text = [await runtime.factory.summary(state), ...livenessLines(state, liveness)].join("\n")
|
|
354
|
+
await ctx.session.synthetic({ sessionID, text } as any)
|
|
313
355
|
},
|
|
314
356
|
})
|
|
315
357
|
editor.add({
|
|
@@ -365,6 +407,7 @@ export default Plugin.define({
|
|
|
365
407
|
try {
|
|
366
408
|
for await (const event of ctx.event.subscribe({ signal: abort.signal }) as AsyncIterable<any>) {
|
|
367
409
|
await track(event).catch(() => undefined)
|
|
410
|
+
await seats.onEvent(event, agentOf).catch(() => undefined)
|
|
368
411
|
await bridge.onEvent(event, agentOf).catch(() => undefined)
|
|
369
412
|
await continuation(event, agentOf).catch(() => undefined)
|
|
370
413
|
}
|
|
@@ -374,6 +417,7 @@ export default Plugin.define({
|
|
|
374
417
|
})()
|
|
375
418
|
return async () => {
|
|
376
419
|
abort.abort()
|
|
420
|
+
if (queued) clearTimeout(queued)
|
|
377
421
|
await runtime.lsp.stopAll()
|
|
378
422
|
}
|
|
379
423
|
},
|
package/src/tui.tsx
CHANGED
|
@@ -117,6 +117,14 @@ export default Plugin.define({
|
|
|
117
117
|
toast((await call("lspInstall", { id, user, token })).message, "success")
|
|
118
118
|
})
|
|
119
119
|
|
|
120
|
+
// Panels open beside the session (the host goes fullscreen on narrow
|
|
121
|
+
// terminals; f toggles it, Esc or q closes it). Only a session route can
|
|
122
|
+
// show a panel: say so instead of doing nothing.
|
|
123
|
+
const openPanel = (name: string) => {
|
|
124
|
+
if (!context.ui.panel.open(name, { presentation: "panel" }))
|
|
125
|
+
toast("Open a session first: Epistemic Swarm panels show beside a session.", "info")
|
|
126
|
+
}
|
|
127
|
+
|
|
120
128
|
const status = guarded(async () => {
|
|
121
129
|
const result = await call("status")
|
|
122
130
|
await context.ui.dialog.alert({
|
|
@@ -174,9 +182,15 @@ export default Plugin.define({
|
|
|
174
182
|
group: "Epistemic Swarm",
|
|
175
183
|
palette: true,
|
|
176
184
|
slash: { name: "es-factory" },
|
|
177
|
-
run: () =>
|
|
178
|
-
|
|
179
|
-
|
|
185
|
+
run: () => openPanel("factory"),
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: "es.panel.close",
|
|
189
|
+
title: "Epistemic Swarm: close the panel",
|
|
190
|
+
group: "Epistemic Swarm",
|
|
191
|
+
palette: true,
|
|
192
|
+
slash: { name: "es-close" },
|
|
193
|
+
run: () => context.ui.panel.close(),
|
|
180
194
|
},
|
|
181
195
|
{
|
|
182
196
|
id: "es.panel.lsp",
|
|
@@ -184,9 +198,7 @@ export default Plugin.define({
|
|
|
184
198
|
group: "Epistemic Swarm",
|
|
185
199
|
palette: true,
|
|
186
200
|
slash: { name: "es-lsp-panel" },
|
|
187
|
-
run: () =>
|
|
188
|
-
context.ui.panel.open("lsp", { presentation: "fullscreen" })
|
|
189
|
-
},
|
|
201
|
+
run: () => openPanel("lsp"),
|
|
190
202
|
},
|
|
191
203
|
{
|
|
192
204
|
id: "es.panel.hooks",
|
|
@@ -194,9 +206,7 @@ export default Plugin.define({
|
|
|
194
206
|
group: "Epistemic Swarm",
|
|
195
207
|
palette: true,
|
|
196
208
|
slash: { name: "es-hooks" },
|
|
197
|
-
run: () =>
|
|
198
|
-
context.ui.panel.open("hooks", { presentation: "fullscreen" })
|
|
199
|
-
},
|
|
209
|
+
run: () => openPanel("hooks"),
|
|
200
210
|
},
|
|
201
211
|
{
|
|
202
212
|
id: "es.panel.brainstorm",
|
|
@@ -204,14 +214,20 @@ export default Plugin.define({
|
|
|
204
214
|
group: "Epistemic Swarm",
|
|
205
215
|
palette: true,
|
|
206
216
|
slash: { name: "es-brainstorm" },
|
|
207
|
-
run: () =>
|
|
208
|
-
context.ui.panel.open("brainstorm", { presentation: "fullscreen" })
|
|
209
|
-
},
|
|
217
|
+
run: () => openPanel("brainstorm"),
|
|
210
218
|
},
|
|
211
219
|
],
|
|
212
220
|
}))
|
|
213
221
|
|
|
214
|
-
|
|
222
|
+
// `changed` fires on every seat transition and es_* tool run: toast only
|
|
223
|
+
// a stage change, or an announcement such as a paused continuation.
|
|
224
|
+
let lastStage: string | undefined
|
|
225
|
+
const stop = rpc.events.on("changed", (event: any) => {
|
|
226
|
+
const { stage, notice } = event.data ?? {}
|
|
227
|
+
if (notice) toast(notice, "info")
|
|
228
|
+
if (lastStage !== undefined && stage !== lastStage) toast(`Factory → ${stage}`)
|
|
229
|
+
lastStage = stage
|
|
230
|
+
})
|
|
215
231
|
return () => {
|
|
216
232
|
stopPanels()
|
|
217
233
|
stop()
|