@heretek-ai/epistemic-swarm 1.1.4 → 2.3.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.
package/src/server.ts CHANGED
@@ -1,16 +1,21 @@
1
1
  // Epistemic Swarm for OpenCode v2 (server plugin). Additive: registers our
2
2
  // agents, tools, skills, commands, hooks and RPC through runtime transforms;
3
- // writes no config files and never touches built-in agents or defaults.
3
+ // setup writes no project-tree files and no config files (the engine key goes
4
+ // to the masked state dir; spend and seat tracking later write
5
+ // .factory/runtime/* while sessions run). It never touches built-in agents or
6
+ // defaults.
4
7
  import { readdir, readFile } from "node:fs/promises"
5
8
  import path from "node:path"
6
9
  import {
7
10
  describeTarget,
8
11
  ensureEngineKey,
9
12
  factoryLayout,
13
+ flag,
10
14
  formatReport,
11
15
  git,
12
16
  livenessLines,
13
17
  loadSkills,
18
+ parseArgs,
14
19
  parseAuditTarget,
15
20
  readJson,
16
21
  readLiveness,
@@ -18,20 +23,373 @@ import {
18
23
  runGates,
19
24
  } from "@heretek-ai/es-core"
20
25
  import { Plugin } from "@opencode/plugin"
21
- import { compileAgents } from "./agents.ts"
26
+ import { compileAgents, modelProblems } from "./agents.ts"
22
27
  import { createFactoryContinuation } from "./continue.ts"
23
28
  import { createHookBridge } from "./hooks.ts"
24
29
  import { createPolicyHooks } from "./policy.ts"
25
30
  import { createWebCache, PendingSearches, registerWebsearch } from "./research.ts"
26
31
  import { createRpcHandlers } from "./rpc.ts"
27
32
  import { EsRpc } from "./rpc-def.ts"
28
- import { createRuntime, parseOptions } from "./runtime.ts"
33
+ import { createRuntime, parseOptions, type Runtime } from "./runtime.ts"
29
34
  import { createSeatTracker } from "./seats.ts"
30
35
  import { createSessionHooks, createSpendTracker } from "./session.ts"
31
36
  import { registerTools } from "./tools.ts"
32
37
 
33
38
  export const PLUGIN_ID = "epistemic-swarm"
34
39
 
40
+ /** `/research deep <question> --max-usd N`, split with the core CLI grammar (no command-local regex). */
41
+ export type DeepResearchArgs = { query: string; ceiling: string } | { error: "usage" | "ceiling" }
42
+
43
+ export function parseDeepResearchArgs(text: string): DeepResearchArgs {
44
+ const argv = parseArgs(text.split(/\s+/).filter(Boolean))
45
+ const [head, ...rest] = argv.positionals
46
+ const query = rest.join(" ").trim()
47
+ if (head?.toLowerCase() !== "deep" || query === "") return { error: "usage" }
48
+ const ceiling = flag(argv, "max-usd")
49
+ if (ceiling === undefined || !(Number(ceiling) > 0)) return { error: "ceiling" }
50
+ return { query, ceiling }
51
+ }
52
+
53
+ /** The session surface slash commands use (the host's `ctx.session`; stubbed in tests). */
54
+ export interface CommandSession {
55
+ switchAgent(sessionID: string, agent: string): Promise<unknown>
56
+ sendPrompt(sessionID: string, prompt: Record<string, unknown>, text: string, delivery: unknown): Promise<unknown>
57
+ synthetic(sessionID: string, text: string): Promise<unknown>
58
+ }
59
+
60
+ /** One slash command: its name, description and host-independent body. */
61
+ export interface CommandSpec {
62
+ readonly name: string
63
+ readonly description: string
64
+ readonly execute: (args: {
65
+ readonly sessionID: string
66
+ readonly prompt: { readonly text?: string } & Record<string, unknown>
67
+ readonly delivery?: unknown
68
+ }) => Promise<void>
69
+ }
70
+
71
+ /** The hook-bridge surface slash commands read (status for `/hooks`). */
72
+ export interface CommandBridge {
73
+ readonly engine: {
74
+ refresh(): Promise<void>
75
+ status(): {
76
+ readonly handlers: number
77
+ readonly projectHandlers: number
78
+ readonly trusted: boolean
79
+ readonly projectLines: readonly string[]
80
+ readonly diagnostics: readonly string[]
81
+ }
82
+ readonly recent: readonly string[]
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Every slash command the plugin registers, as data: the host loop below
88
+ * adds each one, and tests invoke each body against a stub session to prove
89
+ * routing (seat/verb per command) and name uniqueness (one `research`).
90
+ */
91
+ export function esCommandSpecs(
92
+ io: CommandSession,
93
+ runtime: Runtime,
94
+ bridge: CommandBridge,
95
+ runners: { readonly runGates?: typeof runGates; readonly git?: typeof git } = {},
96
+ ): CommandSpec[] {
97
+ const specs: CommandSpec[] = []
98
+ const gates = runners.runGates ?? runGates
99
+ const gitStatus = runners.git ?? git
100
+ specs.push({
101
+ name: "grill",
102
+ description: "Grill an idea until the design and spend ceiling are settled (Epistemic Swarm)",
103
+ execute: async ({ sessionID, prompt, delivery }) => {
104
+ await io.switchAgent(sessionID, "grill")
105
+ await io.sendPrompt(
106
+ sessionID,
107
+ prompt,
108
+ prompt.text?.trim() ? `Grill me on: ${prompt.text}` : "Start grilling me on my idea.",
109
+ delivery,
110
+ )
111
+ },
112
+ })
113
+ specs.push({
114
+ name: "brainstorm",
115
+ description: "Fan out divergent lenses on an idea and return a diversified shortlist (Epistemic Swarm)",
116
+ execute: async ({ sessionID, prompt, delivery }) => {
117
+ await io.switchAgent(sessionID, "brainstormer")
118
+ await io.sendPrompt(
119
+ sessionID,
120
+ prompt,
121
+ prompt.text?.trim()
122
+ ? `Brainstorm: ${prompt.text}`
123
+ : "Ask me for the idea to brainstorm (one sentence), then run the lens fan-out.",
124
+ delivery,
125
+ )
126
+ },
127
+ })
128
+ specs.push({
129
+ name: "harvest",
130
+ description: "Tear down competitor projects: licences, feature matrix, clean-room specs (Epistemic Swarm)",
131
+ execute: async ({ sessionID, prompt, delivery }) => {
132
+ await io.switchAgent(sessionID, "harvester")
133
+ await io.sendPrompt(
134
+ sessionID,
135
+ prompt,
136
+ prompt.text?.trim()
137
+ ? `Darkharvest: ${prompt.text}`
138
+ : "Ask me for the teardown objective and the candidate projects, then run the harvest.",
139
+ delivery,
140
+ )
141
+ },
142
+ })
143
+ specs.push({
144
+ name: "design",
145
+ description: "Interview me into a design system: tokens, contrast gates, style guide (Epistemic Swarm)",
146
+ execute: async ({ sessionID, prompt, delivery }) => {
147
+ await io.switchAgent(sessionID, "designer")
148
+ await io.sendPrompt(
149
+ sessionID,
150
+ prompt,
151
+ prompt.text?.trim()
152
+ ? `Run the design interview. Product: ${prompt.text}`
153
+ : "Ask me for the product name and start the design interview, one question at a time.",
154
+ delivery,
155
+ )
156
+ },
157
+ })
158
+ specs.push({
159
+ name: "scout",
160
+ description: "Find and vet open-source candidates for a feature: licenses, maintenance, CVEs (Epistemic Swarm)",
161
+ execute: async ({ sessionID, prompt, delivery }) => {
162
+ await io.switchAgent(sessionID, "scout")
163
+ await io.sendPrompt(
164
+ sessionID,
165
+ prompt,
166
+ prompt.text?.trim()
167
+ ? `Scout: ${prompt.text}`
168
+ : "Ask me for the feature to scout and our license posture, then plan the candidates.",
169
+ delivery,
170
+ )
171
+ },
172
+ })
173
+ specs.push({
174
+ name: "research",
175
+ // One name, one body (#274): a leading `deep` runs the adversarial deep
176
+ // flow (switching to the deep-researcher); anything else reads the
177
+ // research stage (report, audit coverage, cached sources).
178
+ description:
179
+ "Deep research to a grounded report, or the research stage: report, audit coverage and cached sources (Epistemic Swarm)",
180
+ execute: async ({ sessionID, prompt, delivery }) => {
181
+ const say = async (text: string) => {
182
+ await io.synthetic(sessionID, text)
183
+ }
184
+ const text = prompt.text?.trim() ?? ""
185
+ if (text.split(/\s+/)[0]?.toLowerCase() !== "deep") {
186
+ const layout = factoryLayout(runtime.root)
187
+ const report = await readFile(layout.researchReport, "utf8").catch(() => undefined)
188
+ const coverage = await readJson<{
189
+ claims?: number
190
+ grounded?: number
191
+ untagged?: number
192
+ ungrounded?: number
193
+ malformed?: number
194
+ sources?: number
195
+ passed?: boolean
196
+ }>(path.join(layout.research, "coverage.json")).catch(() => undefined)
197
+ const sources = (await readdir(researchSourcesDir(runtime.root)).catch(() => [])).filter((file) =>
198
+ file.endsWith(".md"),
199
+ )
200
+ const lines = [
201
+ report === undefined
202
+ ? "No research report yet (.factory/research/REPORT.md)."
203
+ : `Report: ${report.length} characters in .factory/research/REPORT.md.`,
204
+ coverage
205
+ ? `Audit: ${coverage.grounded ?? 0}/${coverage.claims ?? 0} claims grounded (untagged ${coverage.untagged ?? 0}, ungrounded ${coverage.ungrounded ?? 0}, malformed ${coverage.malformed ?? 0}); cited sources ${coverage.sources ?? 0}; passed ${coverage.passed ? "yes" : "no"}.`
206
+ : "No coverage.json yet (es_research_complete writes it).",
207
+ `Cached sources: ${sources.length}.`,
208
+ ]
209
+ return say(lines.join("\n"))
210
+ }
211
+ const parsed = parseDeepResearchArgs(text)
212
+ if ("error" in parsed && parsed.error === "usage") {
213
+ return say("Usage: /research deep <question> --max-usd N")
214
+ }
215
+ if ("error" in parsed) {
216
+ return say(
217
+ "Deep research needs a spend ceiling you set: /research deep <question> --max-usd N. Seats halt when the spend reaches it.",
218
+ )
219
+ }
220
+ const { query, ceiling } = parsed
221
+ const existing = await runtime.factory.read().catch(() => undefined)
222
+ if (existing && existing.mode !== "research") {
223
+ return say(
224
+ "This project holds a build run. Start deep research in a directory without one (`es research deep <question> --output <dir> --max-usd N` at a terminal).",
225
+ )
226
+ }
227
+ if (existing?.stage === "HALTED") {
228
+ return say(
229
+ "This research run is halted; resume it first (`es factory resume` at a terminal, or /es-resume to preview).",
230
+ )
231
+ }
232
+ if (existing?.stage === "DONE") {
233
+ return say(
234
+ "This research run is done. For a new question, start one in a fresh directory (`es research deep <question> --output <dir> --max-usd N` at a terminal).",
235
+ )
236
+ }
237
+ if (!existing) {
238
+ try {
239
+ // The human typed /research deep: beginning the run is their action.
240
+ await runtime.factory.beginResearchRun({ objective: query, ceilingUSD: Number(ceiling) }, "human:tui")
241
+ } catch (error) {
242
+ return say(`Could not start the research run: ${error instanceof Error ? error.message : String(error)}`)
243
+ }
244
+ }
245
+ await io.switchAgent(sessionID, "deep-researcher")
246
+ await io.sendPrompt(
247
+ sessionID,
248
+ prompt,
249
+ `${await runtime.factory.summary()}\nDeep research runs adversarially, with no human answering questions mid-flow; follow your Flow through es_research_complete.`,
250
+ delivery,
251
+ )
252
+ },
253
+ })
254
+ specs.push({
255
+ name: "audit",
256
+ description: "Open a code audit of the active phase or a path and run the auditor pair (Epistemic Swarm)",
257
+ execute: async ({ sessionID, prompt, delivery }) => {
258
+ const target = prompt.text?.trim()
259
+ const say = async (text: string) => {
260
+ await io.synthetic(sessionID, text)
261
+ }
262
+ if (!target) return say("Usage: /audit <active phase id | path in the project>")
263
+ const state = await runtime.factory.read().catch(() => undefined)
264
+ if (state?.spendCeilingUSD === undefined)
265
+ return say(
266
+ "Audits run inside a factory run with a spend ceiling. Start one at a terminal with `es audit <target> --max-usd N`, or run /grill first.",
267
+ )
268
+ let opened: Awaited<ReturnType<typeof runtime.factory.openAudit>>
269
+ try {
270
+ // The human typed /audit: opening it is their action.
271
+ opened = await runtime.factory.openAudit(
272
+ "human:tui",
273
+ parseAuditTarget(
274
+ target,
275
+ state.phases.map((phase) => phase.id),
276
+ ),
277
+ )
278
+ } catch (error) {
279
+ return say(`Could not open the audit: ${error instanceof Error ? error.message : String(error)}`)
280
+ }
281
+ const { audit } = opened
282
+ await io.switchAgent(sessionID, "factory")
283
+ await io.sendPrompt(
284
+ sessionID,
285
+ prompt,
286
+ `${await runtime.factory.summary()}\nThe human opened code audit ${audit.id} (round ${audit.round}) on ${describeTarget(audit.target)}. Launch es-auditor-thesis and es-auditor-antithesis in parallel with the audit id and target; if they split, have es-manager break the tie (es_tiebreak with audit). Report the verdicts and the report path when the audit settles.`,
287
+ delivery,
288
+ )
289
+ },
290
+ })
291
+ specs.push({
292
+ name: "factory",
293
+ description: "Drive the build factory from its current stage (Epistemic Swarm)",
294
+ execute: async ({ sessionID, prompt, delivery }) => {
295
+ const state = await runtime.factory.read()
296
+ if (!state) {
297
+ await io.synthetic(sessionID, "No factory run here yet — run /grill first.")
298
+ return
299
+ }
300
+ await io.switchAgent(sessionID, "factory")
301
+ await io.sendPrompt(
302
+ sessionID,
303
+ prompt,
304
+ `${await runtime.factory.summary(state)}\nContinue the factory run from its current stage.${prompt.text?.trim() ? `\nHuman note: ${prompt.text}` : ""}`,
305
+ delivery,
306
+ )
307
+ },
308
+ })
309
+ specs.push({
310
+ name: "gates",
311
+ description: "Run the mechanical gates on changed files (Epistemic Swarm)",
312
+ execute: async ({ sessionID }) => {
313
+ const changed = (await gitStatus(runtime.root, ["status", "--porcelain=v1", "-uall"], { allowFail: true })).stdout
314
+ .split("\n")
315
+ .filter(Boolean)
316
+ .map((line) => line.slice(3).split(" -> ").at(-1)!)
317
+ const report = await gates({
318
+ root: runtime.root,
319
+ scope: changed.length ? "touched" : "full",
320
+ touched: changed,
321
+ stateDir: runtime.stateDir,
322
+ })
323
+ await io.synthetic(sessionID, formatReport(report))
324
+ },
325
+ })
326
+ specs.push({
327
+ name: "lsp",
328
+ description: "Language servers: configured, available and running (Epistemic Swarm)",
329
+ execute: async ({ sessionID }) => {
330
+ const status = await runtime.lsp.status()
331
+ const settings = await runtime.lsp.settings()
332
+ const lines = [status.enabled ? "LSP is on." : "LSP is disabled (lsp: false)."]
333
+ for (const server of settings.servers) {
334
+ const command = await runtime.lsp.command(server, runtime.root)
335
+ const running = status.running.filter((item) => item.id === server.id)
336
+ lines.push(
337
+ `${server.id} [${server.extensions.join(" ")}]: ${Array.isArray(command) ? `available (${command.join(" ")})` : command.unavailable}${running.length ? ` · running for ${running.length} root(s)` : ""}`,
338
+ )
339
+ }
340
+ lines.push(...status.diagnostics.map((item) => `! ${item}`))
341
+ await io.synthetic(sessionID, lines.join("\n"))
342
+ },
343
+ })
344
+ specs.push({
345
+ name: "hooks",
346
+ description: "Show the hook bridge: sources, handlers, trust and diagnostics (Epistemic Swarm)",
347
+ execute: async ({ sessionID }) => {
348
+ await bridge.engine.refresh()
349
+ const status = bridge.engine.status()
350
+ const trust =
351
+ status.projectHandlers === 0
352
+ ? "nothing to trust"
353
+ : status.trusted
354
+ ? "trusted"
355
+ : "NOT trusted: run /es-trust or `es trust`"
356
+ const lines = [
357
+ `Hook bridge: ${status.handlers} handler(s), ${status.projectHandlers} from the project (${trust}).`,
358
+ ...status.projectLines,
359
+ ...status.diagnostics.map((item) => `! ${item}`),
360
+ ...bridge.engine.recent.slice(-10).map((item) => `recent error: ${item}`),
361
+ "Stop hooks are emulated after the turn ends (advisory for plain chat).",
362
+ ]
363
+ await io.synthetic(sessionID, lines.join("\n"))
364
+ },
365
+ })
366
+ specs.push({
367
+ name: "status",
368
+ description: "Show the factory state (Epistemic Swarm)",
369
+ execute: async ({ sessionID }) => {
370
+ const state = await runtime.factory.read()
371
+ const liveness = await readLiveness(runtime.root, state)
372
+ const text = [await runtime.factory.summary(state), ...livenessLines(state, liveness)].join("\n")
373
+ await io.synthetic(sessionID, text)
374
+ },
375
+ })
376
+ specs.push({
377
+ name: "config",
378
+ description: "Show the effective Epistemic Swarm config and where it came from (Epistemic Swarm)",
379
+ execute: async ({ sessionID }) => {
380
+ const lines = [
381
+ runtime.configSources.length
382
+ ? `Layers: ${runtime.configSources.join(" → ")} → plugin options`
383
+ : "Layers: defaults only (no config files)",
384
+ ...runtime.warnings.map((warning) => `Warning: ${warning}`),
385
+ JSON.stringify(runtime.config, null, 2),
386
+ ]
387
+ await io.synthetic(sessionID, lines.join("\n"))
388
+ },
389
+ })
390
+ return specs
391
+ }
392
+
35
393
  export default Plugin.define({
36
394
  id: PLUGIN_ID,
37
395
  async setup(ctx) {
@@ -47,6 +405,18 @@ export default Plugin.define({
47
405
  () => [] as string[],
48
406
  )
49
407
  const agents = await compileAgents(runtime.options, servers)
408
+ // Fail-closed models (#96): every configured ref is checked against the
409
+ // host model list here, once. A missing model refuses every tool call by
410
+ // that seat (the policy before-hook) and shows in es_status; healthy
411
+ // seats are unaffected.
412
+ await ctx.model.transform((editor) => {
413
+ for (const problem of modelProblems(
414
+ runtime.options,
415
+ agents.map((agent) => agent.spec),
416
+ (providerID, modelID) => editor.get(providerID, modelID) !== undefined,
417
+ ))
418
+ runtime.modelProblems.set(problem.agent, problem)
419
+ })
50
420
  await ctx.agent.transform((editor) => {
51
421
  for (const compiled of agents)
52
422
  editor.update(compiled.spec.id, (agent) => {
@@ -156,248 +526,13 @@ export default Plugin.define({
156
526
  }) as any)
157
527
 
158
528
  await ctx.command.transform((editor) => {
159
- editor.add({
160
- name: "grill",
161
- description: "Grill an idea until the design and spend ceiling are settled (Epistemic Swarm)",
162
- execute: async ({ sessionID, prompt, delivery }) => {
163
- await ctx.session.switchAgent({ sessionID, agent: "grill" } as any)
164
- await ctx.session.prompt({
165
- ...prompt,
166
- sessionID,
167
- text: prompt.text?.trim() ? `Grill me on: ${prompt.text}` : "Start grilling me on my idea.",
168
- delivery,
169
- } as any)
170
- },
171
- })
172
- editor.add({
173
- name: "brainstorm",
174
- description: "Fan out divergent lenses on an idea and return a diversified shortlist (Epistemic Swarm)",
175
- execute: async ({ sessionID, prompt, delivery }) => {
176
- await ctx.session.switchAgent({ sessionID, agent: "brainstormer" } as any)
177
- await ctx.session.prompt({
178
- ...prompt,
179
- sessionID,
180
- text: prompt.text?.trim()
181
- ? `Brainstorm: ${prompt.text}`
182
- : "Ask me for the idea to brainstorm (one sentence), then run the lens fan-out.",
183
- delivery,
184
- } as any)
185
- },
186
- })
187
- editor.add({
188
- name: "harvest",
189
- description: "Tear down competitor projects: licences, feature matrix, clean-room specs (Epistemic Swarm)",
190
- execute: async ({ sessionID, prompt, delivery }) => {
191
- await ctx.session.switchAgent({ sessionID, agent: "harvester" } as any)
192
- await ctx.session.prompt({
193
- ...prompt,
194
- sessionID,
195
- text: prompt.text?.trim()
196
- ? `Darkharvest: ${prompt.text}`
197
- : "Ask me for the teardown objective and the candidate projects, then run the harvest.",
198
- delivery,
199
- } as any)
200
- },
201
- })
202
- editor.add({
203
- name: "design",
204
- description: "Interview me into a design system: tokens, contrast gates, style guide (Epistemic Swarm)",
205
- execute: async ({ sessionID, prompt, delivery }) => {
206
- await ctx.session.switchAgent({ sessionID, agent: "designer" } as any)
207
- await ctx.session.prompt({
208
- ...prompt,
209
- sessionID,
210
- text: prompt.text?.trim()
211
- ? `Run the design interview. Product: ${prompt.text}`
212
- : "Ask me for the product name and start the design interview, one question at a time.",
213
- delivery,
214
- } as any)
215
- },
216
- })
217
- editor.add({
218
- name: "scout",
219
- description: "Find and vet open-source candidates for a feature: licenses, maintenance, CVEs (Epistemic Swarm)",
220
- execute: async ({ sessionID, prompt, delivery }) => {
221
- await ctx.session.switchAgent({ sessionID, agent: "scout" } as any)
222
- await ctx.session.prompt({
223
- ...prompt,
224
- sessionID,
225
- text: prompt.text?.trim()
226
- ? `Scout: ${prompt.text}`
227
- : "Ask me for the feature to scout and our license posture, then plan the candidates.",
228
- delivery,
229
- } as any)
230
- },
231
- })
232
- editor.add({
233
- name: "audit",
234
- description: "Open a code audit of the active phase or a path and run the auditor pair (Epistemic Swarm)",
235
- execute: async ({ sessionID, prompt, delivery }) => {
236
- const target = prompt.text?.trim()
237
- const say = async (text: string) => {
238
- await ctx.session.synthetic({ sessionID, text } as any)
239
- }
240
- if (!target) return say("Usage: /audit <active phase id | path in the project>")
241
- const state = await runtime.factory.read().catch(() => undefined)
242
- if (state?.spendCeilingUSD === undefined)
243
- return say(
244
- "Audits run inside a factory run with a spend ceiling. Start one at a terminal with `es audit <target> --max-usd N`, or run /grill first.",
245
- )
246
- let opened: Awaited<ReturnType<typeof runtime.factory.openAudit>>
247
- try {
248
- // The human typed /audit: opening it is their action.
249
- opened = await runtime.factory.openAudit(
250
- "human:tui",
251
- parseAuditTarget(
252
- target,
253
- state.phases.map((phase) => phase.id),
254
- ),
255
- )
256
- } catch (error) {
257
- return say(`Could not open the audit: ${error instanceof Error ? error.message : String(error)}`)
258
- }
259
- const { audit } = opened
260
- await ctx.session.switchAgent({ sessionID, agent: "factory" } as any)
261
- await ctx.session.prompt({
262
- ...prompt,
263
- sessionID,
264
- text: `${await runtime.factory.summary()}\nThe human opened code audit ${audit.id} (round ${audit.round}) on ${describeTarget(audit.target)}. Launch es-auditor-thesis and es-auditor-antithesis in parallel with the audit id and target; if they split, have es-manager break the tie (es_tiebreak with audit). Report the verdicts and the report path when the audit settles.`,
265
- delivery,
266
- } as any)
267
- },
268
- })
269
- editor.add({
270
- name: "factory",
271
- description: "Drive the build factory from its current stage (Epistemic Swarm)",
272
- execute: async ({ sessionID, prompt, delivery }) => {
273
- const state = await runtime.factory.read()
274
- if (!state) {
275
- await ctx.session.synthetic({
276
- sessionID,
277
- text: "No factory run here yet — run /grill first.",
278
- } as any)
279
- return
280
- }
281
- await ctx.session.switchAgent({ sessionID, agent: "factory" } as any)
282
- await ctx.session.prompt({
283
- ...prompt,
284
- sessionID,
285
- text: `${await runtime.factory.summary(state)}\nContinue the factory run from its current stage.${prompt.text?.trim() ? `\nHuman note: ${prompt.text}` : ""}`,
286
- delivery,
287
- } as any)
288
- },
289
- })
290
- editor.add({
291
- name: "gates",
292
- description: "Run the mechanical gates on changed files (Epistemic Swarm)",
293
- execute: async ({ sessionID }) => {
294
- const changed = (await git(runtime.root, ["status", "--porcelain=v1", "-uall"], { allowFail: true })).stdout
295
- .split("\n")
296
- .filter(Boolean)
297
- .map((line) => line.slice(3).split(" -> ").at(-1)!)
298
- const report = await runGates({
299
- root: runtime.root,
300
- scope: changed.length ? "touched" : "full",
301
- touched: changed,
302
- stateDir: runtime.stateDir,
303
- })
304
- await ctx.session.synthetic({ sessionID, text: formatReport(report) } as any)
305
- },
306
- })
307
- editor.add({
308
- name: "lsp",
309
- description: "Language servers: configured, available and running (Epistemic Swarm)",
310
- execute: async ({ sessionID }) => {
311
- const status = await runtime.lsp.status()
312
- const settings = await runtime.lsp.settings()
313
- const lines = [status.enabled ? "LSP is on." : "LSP is disabled (lsp: false)."]
314
- for (const server of settings.servers) {
315
- const command = await runtime.lsp.command(server, runtime.root)
316
- const running = status.running.filter((item) => item.id === server.id)
317
- lines.push(
318
- `${server.id} [${server.extensions.join(" ")}]: ${Array.isArray(command) ? `available (${command.join(" ")})` : command.unavailable}${running.length ? ` · running for ${running.length} root(s)` : ""}`,
319
- )
320
- }
321
- lines.push(...status.diagnostics.map((item) => `! ${item}`))
322
- await ctx.session.synthetic({ sessionID, text: lines.join("\n") } as any)
323
- },
324
- })
325
- editor.add({
326
- name: "hooks",
327
- description: "Show the hook bridge: sources, handlers, trust and diagnostics (Epistemic Swarm)",
328
- execute: async ({ sessionID }) => {
329
- await bridge.engine.refresh()
330
- const status = bridge.engine.status()
331
- const trust =
332
- status.projectHandlers === 0
333
- ? "nothing to trust"
334
- : status.trusted
335
- ? "trusted"
336
- : "NOT trusted: run /es-trust or `es trust`"
337
- const lines = [
338
- `Hook bridge: ${status.handlers} handler(s), ${status.projectHandlers} from the project (${trust}).`,
339
- ...status.projectLines,
340
- ...status.diagnostics.map((item) => `! ${item}`),
341
- ...bridge.engine.recent.slice(-10).map((item) => `recent error: ${item}`),
342
- "Stop hooks are emulated after the turn ends (advisory for plain chat).",
343
- ]
344
- await ctx.session.synthetic({ sessionID, text: lines.join("\n") } as any)
345
- },
346
- })
347
- editor.add({
348
- name: "status",
349
- description: "Show the factory state (Epistemic Swarm)",
350
- execute: async ({ sessionID }) => {
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)
355
- },
356
- })
357
- editor.add({
358
- name: "config",
359
- description: "Show the effective Epistemic Swarm config and where it came from (Epistemic Swarm)",
360
- execute: async ({ sessionID }) => {
361
- const lines = [
362
- runtime.configSources.length
363
- ? `Layers: ${runtime.configSources.join(" → ")} → plugin options`
364
- : "Layers: defaults only (no config files)",
365
- ...runtime.warnings.map((warning) => `Warning: ${warning}`),
366
- JSON.stringify(runtime.config, null, 2),
367
- ]
368
- await ctx.session.synthetic({ sessionID, text: lines.join("\n") } as any)
369
- },
370
- })
371
- editor.add({
372
- name: "research",
373
- description: "Research stage: report, audit coverage and cached sources (Epistemic Swarm)",
374
- execute: async ({ sessionID }) => {
375
- const layout = factoryLayout(runtime.root)
376
- const report = await readFile(layout.researchReport, "utf8").catch(() => undefined)
377
- const coverage = await readJson<{
378
- claims?: number
379
- grounded?: number
380
- untagged?: number
381
- ungrounded?: number
382
- malformed?: number
383
- sources?: number
384
- passed?: boolean
385
- }>(path.join(layout.research, "coverage.json")).catch(() => undefined)
386
- const sources = (await readdir(researchSourcesDir(runtime.root)).catch(() => [])).filter((file) =>
387
- file.endsWith(".md"),
388
- )
389
- const lines = [
390
- report === undefined
391
- ? "No research report yet (.factory/research/REPORT.md)."
392
- : `Report: ${report.length} characters in .factory/research/REPORT.md.`,
393
- coverage
394
- ? `Audit: ${coverage.grounded ?? 0}/${coverage.claims ?? 0} claims grounded (untagged ${coverage.untagged ?? 0}, ungrounded ${coverage.ungrounded ?? 0}, malformed ${coverage.malformed ?? 0}); cited sources ${coverage.sources ?? 0}; passed ${coverage.passed ? "yes" : "no"}.`
395
- : "No coverage.json yet (es_research_complete writes it).",
396
- `Cached sources: ${sources.length}.`,
397
- ]
398
- await ctx.session.synthetic({ sessionID, text: lines.join("\n") } as any)
399
- },
400
- })
529
+ const io: CommandSession = {
530
+ switchAgent: (sessionID, agent) => ctx.session.switchAgent({ sessionID, agent } as any),
531
+ sendPrompt: (sessionID, prompt, text, delivery) =>
532
+ ctx.session.prompt({ ...prompt, sessionID, text, delivery } as any),
533
+ synthetic: (sessionID, text) => ctx.session.synthetic({ sessionID, text } as any),
534
+ }
535
+ for (const spec of esCommandSpecs(io, runtime, bridge as unknown as CommandBridge)) editor.add(spec as any)
401
536
  })
402
537
 
403
538
  // Spend: per-step host cost for factory-agent sessions (estimated from tokens when cost is 0).
@@ -415,6 +550,10 @@ export default Plugin.define({
415
550
  // stream closed on unload
416
551
  }
417
552
  })()
553
+ // Language-server lifecycle (#275.1): servers stop here on plugin unload;
554
+ // between sessions only the manager's idle sweeper (600 s default) reaps
555
+ // them, so a long-lived server outlives its session by design. The running
556
+ // set stays visible through `/lsp` and the LSP panel.
418
557
  return async () => {
419
558
  abort.abort()
420
559
  if (queued) clearTimeout(queued)