@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/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
- const notify = async () => {
112
- const state = await runtime.factory.read()
113
- await registration?.events
114
- .emit("changed", { stage: state?.stage ?? "NONE", summary: await runtime.factory.summary(state) })
115
- .catch(() => undefined)
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(EsRpc, createRpcHandlers(runtime, notify, bridge.engine) as any)
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
- await ctx.session.synthetic({ sessionID, text: await runtime.factory.summary() } as any)
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
- context.ui.panel.open("factory", { presentation: "fullscreen" })
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
- const stop = rpc.events.on("changed", (event: any) => toast(`Factory → ${event.data.stage}`))
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()