@heretek-ai/epistemic-swarm 1.6.0 → 2.5.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/dist/tui.js CHANGED
@@ -22,16 +22,29 @@ var TERMINAL_FALLBACK = (stage) => `Run \`es approve ${stage}\` at a terminal wi
22
22
  var APPROVAL_PREVIEW_TTL_MS = 120000;
23
23
  async function approveInTui(input) {
24
24
  const dir = input.stateDir ?? defaultStateDir();
25
+ const clock = input.now ?? Date.now;
26
+ const locked = (retryAfterSec) => ({
27
+ ok: false,
28
+ locked: true,
29
+ retryAfterSec,
30
+ retryAt: new Date(clock() + retryAfterSec * 1000).toISOString()
31
+ });
25
32
  if (!await exists(factoryLayout(input.root).dir) || !await hasHumanKey(dir))
26
33
  return { ok: false, fallback: true, reason: TERMINAL_FALLBACK(input.stage) };
27
34
  const gate = await checkApprovalAttempts(dir);
28
35
  if (!gate.allowed)
29
- return { ok: false, locked: true, retryAfterSec: gate.retryAfterSec ?? 0 };
30
- const title = `Approve ${input.stage} as ${input.approvedBy ?? userInfo().username}`;
36
+ return locked(gate.retryAfterSec ?? 0);
37
+ const base = `Approve ${input.stage} as ${input.approvedBy ?? userInfo().username}`;
38
+ let feedback;
31
39
  for (;; ) {
40
+ const title = feedback ? `${base} \u2014 ${feedback}` : base;
32
41
  const codepoints = await input.readPassphrase(title);
33
42
  if (codepoints === undefined)
34
43
  return { ok: false, cancelled: true };
44
+ if (codepoints.length === 0) {
45
+ zeroCodepoints(codepoints);
46
+ return { ok: false, cancelled: true };
47
+ }
35
48
  const passphrase = codepoints.join("");
36
49
  zeroCodepoints(codepoints);
37
50
  try {
@@ -59,7 +72,8 @@ async function approveInTui(input) {
59
72
  if (error instanceof HumanKeyError && error.message === "wrong passphrase") {
60
73
  const check = await noteWrongPassphrase({ dir, root: input.root, stage: input.stage, channel: "tui" });
61
74
  if (!check.allowed)
62
- return { ok: false, locked: true, retryAfterSec: check.retryAfterSec ?? 0 };
75
+ return locked(check.retryAfterSec ?? 0);
76
+ feedback = `Wrong passphrase. ${check.remaining ?? 0} attempts left.`;
63
77
  continue;
64
78
  }
65
79
  throw error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretek-ai/epistemic-swarm",
3
- "version": "1.6.0",
3
+ "version": "2.5.0",
4
4
  "esRelease": {
5
5
  "order": 3
6
6
  },
@@ -44,7 +44,7 @@
44
44
  "test": "bun test"
45
45
  },
46
46
  "dependencies": {
47
- "@heretek-ai/es-core": "1.6.0",
47
+ "@heretek-ai/es-core": "2.5.0",
48
48
  "@opencode/plugin": "2.0.24"
49
49
  },
50
50
  "devDependencies": {
package/src/agents.ts CHANGED
@@ -48,11 +48,32 @@ export function permissionRules(spec: AgentSpec, mcpServers: readonly string[]):
48
48
  }
49
49
 
50
50
  export function modelFor(spec: AgentSpec, options: PluginOptions) {
51
- const ref = options.models?.agents?.[spec.id] ?? options.models?.[spec.tier]
51
+ const ref = options.models?.agents?.[spec.id] ?? roleModel(spec.id, options) ?? options.models?.[spec.tier]
52
52
  if (!ref) return undefined
53
53
  return parseModelRef(ref)
54
54
  }
55
55
 
56
+ /** Heterogeneous-model role keys (#170): the seat's models.<role> key, so enforcement and reality agree. */
57
+ function roleModel(agentId: string, options: PluginOptions): string | undefined {
58
+ const role = ROLE_MODEL_KEY[agentId]
59
+ return role ? options.models?.[role] : undefined
60
+ }
61
+
62
+ const ROLE_MODEL_KEY: Record<string, "programmer" | "qa" | "auditor"> = {
63
+ "es-programmer": "programmer",
64
+ "es-qa-functional": "qa",
65
+ "es-qa-adversarial": "qa",
66
+ "es-auditor-thesis": "auditor",
67
+ "es-auditor-antithesis": "auditor",
68
+ }
69
+
70
+ /** The config key a seat's model resolved from (for remediation text). */
71
+ function modelKey(spec: AgentSpec, options: PluginOptions): string {
72
+ if (options.models?.agents?.[spec.id] !== undefined) return `models.agents.${spec.id}`
73
+ if (roleModel(spec.id, options) !== undefined) return `models.${ROLE_MODEL_KEY[spec.id]!}`
74
+ return `models.${spec.tier}`
75
+ }
76
+
56
77
  /** A configured model ref the host does not have: the seat is refused at launch (#96). */
57
78
  export interface ModelProblem {
58
79
  readonly agent: string
@@ -80,9 +101,9 @@ export function modelProblems(
80
101
  ): ModelProblem[] {
81
102
  const problems: ModelProblem[] = []
82
103
  for (const spec of specs) {
83
- const ref = options.models?.agents?.[spec.id] ?? options.models?.[spec.tier]
104
+ const ref = options.models?.agents?.[spec.id] ?? roleModel(spec.id, options) ?? options.models?.[spec.tier]
84
105
  if (ref === undefined) continue
85
- const key = options.models?.agents?.[spec.id] !== undefined ? `models.agents.${spec.id}` : `models.${spec.tier}`
106
+ const key = modelKey(spec, options)
86
107
  const parsed = parseModelRef(ref)
87
108
  if (parsed === undefined || !exists(parsed.providerID, parsed.id))
88
109
  problems.push({
@@ -51,7 +51,7 @@ export interface ApproveTuiInput {
51
51
  export type ApproveTuiOutcome =
52
52
  | { readonly ok: true; readonly record: Approval }
53
53
  | { readonly ok: false; readonly fallback: true; readonly reason: string }
54
- | { readonly ok: false; readonly locked: true; readonly retryAfterSec: number }
54
+ | { readonly ok: false; readonly locked: true; readonly retryAfterSec: number; readonly retryAt: string }
55
55
  | { readonly ok: false; readonly cancelled: true }
56
56
 
57
57
  const TERMINAL_FALLBACK = (stage: ApprovalStage) =>
@@ -71,6 +71,13 @@ export const APPROVAL_PREVIEW_TTL_MS = 120_000
71
71
  */
72
72
  export async function approveInTui(input: ApproveTuiInput): Promise<ApproveTuiOutcome> {
73
73
  const dir = input.stateDir ?? defaultStateDir()
74
+ const clock = input.now ?? Date.now
75
+ const locked = (retryAfterSec: number): ApproveTuiOutcome => ({
76
+ ok: false,
77
+ locked: true,
78
+ retryAfterSec,
79
+ retryAt: new Date(clock() + retryAfterSec * 1000).toISOString(),
80
+ })
74
81
  // A TUI that cannot see the project root and the sealed key (a remote
75
82
  // attach) keeps the "approve in a terminal" alert instead of signing
76
83
  // anywhere else.
@@ -78,12 +85,22 @@ export async function approveInTui(input: ApproveTuiInput): Promise<ApproveTuiOu
78
85
  return { ok: false, fallback: true, reason: TERMINAL_FALLBACK(input.stage) }
79
86
 
80
87
  const gate = await checkApprovalAttempts(dir)
81
- if (!gate.allowed) return { ok: false, locked: true, retryAfterSec: gate.retryAfterSec ?? 0 }
88
+ if (!gate.allowed) return locked(gate.retryAfterSec ?? 0)
82
89
 
83
- const title = `Approve ${input.stage} as ${input.approvedBy ?? userInfo().username}`
90
+ const base = `Approve ${input.stage} as ${input.approvedBy ?? userInfo().username}`
91
+ // #353: the reopened dialog names the failure reason and the attempts
92
+ // left, so a wrong passphrase is visible instead of silent.
93
+ let feedback: string | undefined
84
94
  for (;;) {
95
+ const title = feedback ? `${base} — ${feedback}` : base
85
96
  const codepoints = await input.readPassphrase(title)
86
97
  if (codepoints === undefined) return { ok: false, cancelled: true }
98
+ // #353: an empty Enter cancels like the CLI ("empty cancels"), and
99
+ // records no failure.
100
+ if (codepoints.length === 0) {
101
+ zeroCodepoints(codepoints)
102
+ return { ok: false, cancelled: true }
103
+ }
87
104
  // The string cannot be zeroed in JS (same as the CLI's echo-off input):
88
105
  // it lives in this local only, and the array is zeroed at once.
89
106
  const passphrase = codepoints.join("")
@@ -115,7 +132,8 @@ export async function approveInTui(input: ApproveTuiInput): Promise<ApproveTuiOu
115
132
  } catch (error) {
116
133
  if (error instanceof HumanKeyError && error.message === "wrong passphrase") {
117
134
  const check = await noteWrongPassphrase({ dir, root: input.root, stage: input.stage, channel: "tui" })
118
- if (!check.allowed) return { ok: false, locked: true, retryAfterSec: check.retryAfterSec ?? 0 }
135
+ if (!check.allowed) return locked(check.retryAfterSec ?? 0)
136
+ feedback = `Wrong passphrase. ${check.remaining ?? 0} attempts left.`
119
137
  continue
120
138
  }
121
139
  throw error
package/src/hooks.ts CHANGED
@@ -5,7 +5,10 @@
5
5
  // "ask" defers to the host permission prompt)
6
6
  // PostToolUse(Failure)← tool.execute.after (feedback/context appended, output replaced)
7
7
  // UserPromptSubmit ← session prompt admission (block rejects, context appended)
8
- // SessionStart ← first prompt of a session (startup) and after compaction
8
+ // SessionStart ← first prompt of a session (startup), a forked session
9
+ // (fork), and after compaction (compact). Resume/clear
10
+ // have no host bus events, so they stay intentionally
11
+ // unsupported (the engine notices unmatched sources).
9
12
  // PermissionRequest ← permission.evaluate when the host would ask
10
13
  // x-es.ShellPreExec ← shell.create.before
11
14
  // Stop (emulated) ← session.execution.succeeded: a block re-prompts the
@@ -252,6 +255,15 @@ export async function createHookBridge(
252
255
  )
253
256
  return
254
257
  }
258
+ if (event.type === "session.forked" && engine.has("SessionStart")) {
259
+ // A forked session is a new session inheriting its parent's history
260
+ // (#289): run the SessionStart hooks filtered to "fork" now, and mark
261
+ // the fork started so its first prompt does not refire "startup".
262
+ const agent = await agentOf(sessionID)
263
+ stash(sessionContext, sessionID, (await engine.sessionStart(base(sessionID, agent), "fork")).additionalContext)
264
+ startedSessions.add(sessionID)
265
+ return
266
+ }
255
267
  if (event.type !== "session.execution.succeeded" || !engine.has("Stop")) return
256
268
  const count = continuations.get(sessionID) ?? 0
257
269
  if (count >= STOP_CAP) {
package/src/policy.ts CHANGED
@@ -7,6 +7,7 @@ import path from "node:path"
7
7
  import {
8
8
  agentSpec,
9
9
  bwrapAvailable,
10
+ bwrapOverlay,
10
11
  checkStopFile,
11
12
  evaluateRead,
12
13
  evaluateShell,
@@ -22,7 +23,7 @@ import {
22
23
  import { Error as ToolError } from "@opencode/plugin/promise/tool"
23
24
  import { HOST_WEB_TOOLS } from "./agents.ts"
24
25
  import type { Runtime } from "./runtime.ts"
25
- import type { SeatTracker } from "./seats.ts"
26
+ import { type SeatTracker, seatModelRefusal } from "./seats.ts"
26
27
 
27
28
  const WRITE_TOOLS = new Set(["write", "edit"])
28
29
 
@@ -39,6 +40,28 @@ export function patchTargets(patchText: string): string[] {
39
40
  return targets
40
41
  }
41
42
 
43
+ /**
44
+ * The seat-shell sandbox mode for `cwd` (#326): "sandboxed" when bubblewrap
45
+ * can build the seat sandbox including the overlay mounts the shell backstop
46
+ * needs, "host-fallback" when bwrap is present but the overlay is unavailable
47
+ * for `cwd` (e.g. a btrfs mount without userxattr — the shell then runs
48
+ * without the read-only backstop, the #294 fallback), "unavailable" when
49
+ * there is no bwrap at all (seat shells are refused outright).
50
+ *
51
+ * Naming only: enforcement is unchanged. Whether the fallback stays or the
52
+ * shell fails closed on such hosts is the human's product decision on #326;
53
+ * this names the mode so the control matrix asserts the designed behaviour
54
+ * for the mode the host is in, with the mode in the assertion output.
55
+ */
56
+ export function seatShellSandboxMode(cwd: string): "sandboxed" | "host-fallback" | "unavailable" {
57
+ if (!bwrapAvailable()) return "unavailable"
58
+ try {
59
+ return bwrapOverlay(cwd) ? "sandboxed" : "host-fallback"
60
+ } catch {
61
+ return "host-fallback"
62
+ }
63
+ }
64
+
42
65
  const deny = (message: string): never => {
43
66
  throw new ToolError({ message })
44
67
  }
@@ -85,17 +108,24 @@ export function createPolicyHooks(runtime: Runtime, seats?: SeatTracker) {
85
108
  if (event.tool === "subagent" && typeof input.agent === "string") {
86
109
  const problem = runtime.modelProblems?.get(input.agent)
87
110
  if (problem) deny(problem.message)
111
+ // Heterogeneous models (#170): a programmer/QA seat that would share
112
+ // weights with the other side never launches.
113
+ const refusal = seatModelRefusal(input.agent, runtime.config?.models)
114
+ if (refusal) deny(`Seat "${input.agent}" cannot launch: ${refusal}`)
88
115
  }
89
116
  if (seat && event.tool === "subagent") guardSeatLaunch({ agent: event.agent, input }, seats)
90
117
  if (seat && event.sessionID) await seats?.onTool(event.sessionID, event.agent, event.tool)
91
- if (seat || event.tool.startsWith("es_")) {
118
+ // STOP halts seats, es_* tools, and seat launches whoever fires them (#227):
119
+ // a user agent must not route around the kill-switch via `subagent`.
120
+ if (seat || event.tool.startsWith("es_") || event.tool === "subagent") {
92
121
  const stop = await checkStopFile(runtime.root)
93
122
  if (stop.stopped)
94
123
  deny(`The factory is stopped (.factory/STOP: ${stop.reason}). A human must remove it and resume.`)
95
124
  }
96
125
  // A halted run (spend ceiling, runtime cap, drift, STOP) stops every seat
97
126
  // tool except es_status, so no seat keeps spending through its own es_* tools.
98
- if (seat && event.tool !== "es_status") {
127
+ // Seat launches count as seat tools whoever fires them (#227).
128
+ if ((seat || event.tool === "subagent") && event.tool !== "es_status") {
99
129
  const state = await runtime.factory.read().catch(() => undefined)
100
130
  if (state?.stage === "HALTED")
101
131
  deny(`The factory is halted: ${state.halt?.reason}. Stop and report; a human must resume.`)
@@ -174,7 +204,10 @@ export function createPolicyHooks(runtime: Runtime, seats?: SeatTracker) {
174
204
  ? patchTargets(input.patchText)
175
205
  : []
176
206
  if (!files.length) return
177
- if (runtime.options.lspAfterEdit !== false) {
207
+ // The post-edit diagnostics are a language-server channel (#277.2): seats
208
+ // scoped to `lsp: "none"` never get them (their tool rules already hide
209
+ // `lsp_*`). Navigating seats and the user's own agents keep the channel.
210
+ if (runtime.options.lspAfterEdit !== false && agentSpec(event.agent)?.lsp !== "none") {
178
211
  const lines: string[] = []
179
212
  for (const file of files) {
180
213
  const absolute = path.resolve(runtime.root, file)
@@ -222,6 +255,8 @@ export function createPolicyHooks(runtime: Runtime, seats?: SeatTracker) {
222
255
  }) => {
223
256
  const seat = seatOf(event.agent)
224
257
  const context = await runtime.policy()
258
+ // The host's patch tool asks for `edit` too, once, with every target of
259
+ // the patch as a resource: there is no `patch` action to branch on (#220).
225
260
  if (event.action === "edit" || event.action === "read") {
226
261
  const decisions = event.resources.map((resource) =>
227
262
  event.action === "edit"
package/src/pr.ts CHANGED
@@ -1,6 +1,10 @@
1
- // Release PR opener backed by the GitHub CLI. It pushes only the run's
2
- // integration branch (never the base branch) and opens a draft PR for a human
3
- // to review and merge.
1
+ // Factory release PR opener backed by the GitHub CLI. Every factory push
2
+ // path goes through here: it pushes only the run's `factory/` integration
3
+ // branch — `branch === base` or a non-`factory/` branch is refused before
4
+ // any push — and opens a draft PR for a human to review and merge. (Other
5
+ // push paths exist outside the factory: `es improve --open-pr` pushes
6
+ // `improve/*` branches on its own human-run path; phase fast-forwards and
7
+ // fleet landings never touch the base branch; only a human merges.)
4
8
  import type { PrOpener } from "@heretek-ai/es-core"
5
9
  import { buildPrArgs, git, run } from "@heretek-ai/es-core"
6
10
 
package/src/rpc.ts CHANGED
@@ -70,6 +70,37 @@ export function createRpcHandlers(
70
70
  reason: error instanceof Error ? error.message : String(error),
71
71
  })
72
72
 
73
+ // #355: an unreadable run state (missing/forged seal) is a status value,
74
+ // not a throw — otherwise the 5 s footer poll logs `rpc handler failed`
75
+ // every poll. The problem is logged once per distinct problem.
76
+ let lastSealProblem: string | undefined
77
+ const isSealProblem = (error: unknown): string | undefined => {
78
+ const message = error instanceof Error ? error.message : String(error)
79
+ return /Cannot verify \.factory\/runtime\/state\.json|\.factory\/runtime\/state\.json has a|is a version .* run/i.test(
80
+ message,
81
+ )
82
+ ? message
83
+ : undefined
84
+ }
85
+ const noteSealProblem = (message: string) => {
86
+ if (lastSealProblem !== message) {
87
+ lastSealProblem = message
88
+ console.warn(`[epistemic-swarm] run state unreadable: ${message}`)
89
+ }
90
+ }
91
+ const clearSealProblem = () => {
92
+ if (lastSealProblem !== undefined) {
93
+ lastSealProblem = undefined
94
+ console.warn("[epistemic-swarm] run state readable again")
95
+ }
96
+ }
97
+ const sealFix = (message: string): string => {
98
+ if (message.includes("es reseal --sign")) return "es reseal --sign"
99
+ if (message.includes("ES_STATE_DIR")) return "ES_STATE_DIR"
100
+ if (message.includes("/grill")) return "/grill"
101
+ return "see status"
102
+ }
103
+
73
104
  /** Everything a trust approval covers: gate commands and project hooks. */
74
105
  const trustSubject = async () => {
75
106
  const { config } = await loadGatesConfig(runtime.root)
@@ -87,7 +118,24 @@ export function createRpcHandlers(
87
118
 
88
119
  return {
89
120
  status: async () => {
90
- const state = await runtime.factory.read()
121
+ let state: Awaited<ReturnType<Runtime["factory"]["read"]>>
122
+ try {
123
+ state = await runtime.factory.read()
124
+ } catch (error) {
125
+ const problem = isSealProblem(error)
126
+ if (problem === undefined) throw error
127
+ noteSealProblem(problem)
128
+ const fix = sealFix(problem)
129
+ return {
130
+ summary: `<factory-state>State unreadable: ${problem}</factory-state>`,
131
+ stage: "ERROR",
132
+ pending: [],
133
+ headline: `State unreadable: ${problem}`,
134
+ footer: `ES · state unreadable · ${problem.slice(0, 100)} · fix: ${fix}`,
135
+ sealProblem: problem,
136
+ }
137
+ }
138
+ clearSealProblem()
91
139
  const liveness = await readLiveness(runtime.root, state)
92
140
  const paused = live.paused?.()
93
141
  const footer = footerLine(state, liveness, paused)
@@ -106,7 +154,26 @@ export function createRpcHandlers(
106
154
  warnings: [...runtime.warnings],
107
155
  }),
108
156
  factoryState: async () => {
109
- const state = await runtime.factory.read()
157
+ let state: Awaited<ReturnType<Runtime["factory"]["read"]>>
158
+ try {
159
+ state = await runtime.factory.read()
160
+ } catch (error) {
161
+ const problem = isSealProblem(error)
162
+ if (problem === undefined) throw error
163
+ noteSealProblem(problem)
164
+ return {
165
+ headline: `State unreadable: ${problem}`,
166
+ seats: [],
167
+ events: [],
168
+ phases: [],
169
+ audits: [],
170
+ pending: [],
171
+ spend: { usd: 0, estimated: false },
172
+ stage: "ERROR",
173
+ sealProblem: problem,
174
+ }
175
+ }
176
+ clearSealProblem()
110
177
  const frontier = await readFrontier(runtime.root).catch(() => undefined)
111
178
  const liveness = await readLiveness(runtime.root, state)
112
179
  const research = researchLine(liveness)
package/src/runtime.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  // Per-location runtime shared by the plugin's hooks, tools, commands and RPC.
2
- // Options are layered: the canonical config (global file → project file) with
3
- // plugin options as the most specific override. Plugin options are config
4
- // keys validated by the same strict schema; unknown keys are ignored with a
5
- // warning the TUI shows once.
2
+ // The server only wires option layering (parseOptions → createRuntime); the
3
+ // merge itself lives in core (loadEsConfig/mergeConfig): the canonical config
4
+ // (global file → project file) with plugin options as the most specific
5
+ // override. Plugin options are config keys validated by the same strict
6
+ // schema; unknown keys are ignored with a warning the TUI shows once.
6
7
  import {
7
8
  stateDir as defaultStateDir,
8
9
  type EsConfig,
@@ -68,6 +69,9 @@ export async function createRuntime(root: string, parsed: ParsedOptions): Promis
68
69
  ...(config.searchProvider ? { searchProvider: config.searchProvider } : {}),
69
70
  }
70
71
  const stateDir = options.stateDir ?? defaultStateDir()
72
+ // One manager per project (#275.1): servers start lazily per (server, root)
73
+ // and are reaped by the idle sweeper between sessions, stopping only on
74
+ // plugin unload (see server.ts) — long-lived servers outlive sessions.
71
75
  const lsp = new LspManager({ root, stateDir })
72
76
  const factory = new Factory(root, {
73
77
  gates: gateRunner({ stateDir }),
@@ -76,6 +80,7 @@ export async function createRuntime(root: string, parsed: ParsedOptions): Promis
76
80
  researchDepth: config.research.depth,
77
81
  ...(config.domainPack ? { domainPack: config.domainPack } : {}),
78
82
  ...(config.audit.phase === "required" ? { auditPhase: "required" as const } : {}),
83
+ ...(config.models ? { models: config.models } : {}),
79
84
  })
80
85
  return {
81
86
  root,
package/src/seats.ts CHANGED
@@ -3,7 +3,14 @@
3
3
  // tool and last activity in .factory/runtime/seats.json. es_status, `es
4
4
  // status`, the dashboard and the headless driver read it; the foreground guard
5
5
  // and the continuation ask it what is running right now.
6
- import { agentSpec, type SeatRecord, updateSeats } from "@heretek-ai/es-core"
6
+ import {
7
+ agentSpec,
8
+ assertModelDiversity,
9
+ ModelDiversityError,
10
+ type SeatModelMap,
11
+ type SeatRecord,
12
+ updateSeats,
13
+ } from "@heretek-ai/es-core"
7
14
  import type { Runtime } from "./runtime.ts"
8
15
 
9
16
  /** Activity-only updates (tool calls, steps) are persisted at most this often per seat. */
@@ -29,6 +36,26 @@ export interface SeatTracker {
29
36
  hasRunningChildren(parentID: string): boolean
30
37
  }
31
38
 
39
+ /** Seats covered by the heterogeneous-model policy (#170): the programmer and both QA seats. */
40
+ const DIVERSE_SEATS: ReadonlySet<string> = new Set(["es-programmer", "es-qa-functional", "es-qa-adversarial"])
41
+
42
+ /**
43
+ * Refusal text when launching `target` would run the programmer and QA on the
44
+ * same model provider (shared weights share blind spots); undefined when the
45
+ * target is not a build seat or diversity holds. Unverified configs (unset
46
+ * seats) launch unless models.requireDiversity is set.
47
+ */
48
+ export function seatModelRefusal(target: string, models: SeatModelMap | undefined): string | undefined {
49
+ if (!DIVERSE_SEATS.has(target)) return undefined
50
+ try {
51
+ assertModelDiversity(models)
52
+ return undefined
53
+ } catch (error) {
54
+ if (error instanceof ModelDiversityError) return error.message
55
+ throw error
56
+ }
57
+ }
58
+
32
59
  /** Factory seats are the registry's subagents; primary agents (factory, grill) are drivers, not seats. */
33
60
  const isSeat = (agent: string | undefined): agent is string => agentSpec(agent)?.mode === "subagent"
34
61
 
package/src/server.ts CHANGED
@@ -1,6 +1,9 @@
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 {
@@ -27,7 +30,7 @@ import { createPolicyHooks } from "./policy.ts"
27
30
  import { createWebCache, PendingSearches, registerWebsearch } from "./research.ts"
28
31
  import { createRpcHandlers } from "./rpc.ts"
29
32
  import { EsRpc } from "./rpc-def.ts"
30
- import { createRuntime, parseOptions } from "./runtime.ts"
33
+ import { createRuntime, parseOptions, type Runtime } from "./runtime.ts"
31
34
  import { createSeatTracker } from "./seats.ts"
32
35
  import { createSessionHooks, createSpendTracker } from "./session.ts"
33
36
  import { registerTools } from "./tools.ts"
@@ -47,6 +50,346 @@ export function parseDeepResearchArgs(text: string): DeepResearchArgs {
47
50
  return { query, ceiling }
48
51
  }
49
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
+
50
393
  export default Plugin.define({
51
394
  id: PLUGIN_ID,
52
395
  async setup(ctx) {
@@ -183,301 +526,13 @@ export default Plugin.define({
183
526
  }) as any)
184
527
 
185
528
  await ctx.command.transform((editor) => {
186
- editor.add({
187
- name: "grill",
188
- description: "Grill an idea until the design and spend ceiling are settled (Epistemic Swarm)",
189
- execute: async ({ sessionID, prompt, delivery }) => {
190
- await ctx.session.switchAgent({ sessionID, agent: "grill" } as any)
191
- await ctx.session.prompt({
192
- ...prompt,
193
- sessionID,
194
- text: prompt.text?.trim() ? `Grill me on: ${prompt.text}` : "Start grilling me on my idea.",
195
- delivery,
196
- } as any)
197
- },
198
- })
199
- editor.add({
200
- name: "brainstorm",
201
- description: "Fan out divergent lenses on an idea and return a diversified shortlist (Epistemic Swarm)",
202
- execute: async ({ sessionID, prompt, delivery }) => {
203
- await ctx.session.switchAgent({ sessionID, agent: "brainstormer" } as any)
204
- await ctx.session.prompt({
205
- ...prompt,
206
- sessionID,
207
- text: prompt.text?.trim()
208
- ? `Brainstorm: ${prompt.text}`
209
- : "Ask me for the idea to brainstorm (one sentence), then run the lens fan-out.",
210
- delivery,
211
- } as any)
212
- },
213
- })
214
- editor.add({
215
- name: "harvest",
216
- description: "Tear down competitor projects: licences, feature matrix, clean-room specs (Epistemic Swarm)",
217
- execute: async ({ sessionID, prompt, delivery }) => {
218
- await ctx.session.switchAgent({ sessionID, agent: "harvester" } as any)
219
- await ctx.session.prompt({
220
- ...prompt,
221
- sessionID,
222
- text: prompt.text?.trim()
223
- ? `Darkharvest: ${prompt.text}`
224
- : "Ask me for the teardown objective and the candidate projects, then run the harvest.",
225
- delivery,
226
- } as any)
227
- },
228
- })
229
- editor.add({
230
- name: "design",
231
- description: "Interview me into a design system: tokens, contrast gates, style guide (Epistemic Swarm)",
232
- execute: async ({ sessionID, prompt, delivery }) => {
233
- await ctx.session.switchAgent({ sessionID, agent: "designer" } as any)
234
- await ctx.session.prompt({
235
- ...prompt,
236
- sessionID,
237
- text: prompt.text?.trim()
238
- ? `Run the design interview. Product: ${prompt.text}`
239
- : "Ask me for the product name and start the design interview, one question at a time.",
240
- delivery,
241
- } as any)
242
- },
243
- })
244
- editor.add({
245
- name: "scout",
246
- description: "Find and vet open-source candidates for a feature: licenses, maintenance, CVEs (Epistemic Swarm)",
247
- execute: async ({ sessionID, prompt, delivery }) => {
248
- await ctx.session.switchAgent({ sessionID, agent: "scout" } as any)
249
- await ctx.session.prompt({
250
- ...prompt,
251
- sessionID,
252
- text: prompt.text?.trim()
253
- ? `Scout: ${prompt.text}`
254
- : "Ask me for the feature to scout and our license posture, then plan the candidates.",
255
- delivery,
256
- } as any)
257
- },
258
- })
259
- editor.add({
260
- name: "research",
261
- description: "Adversarial deep research to a grounded report: thesis, antithesis, synthesis (Epistemic Swarm)",
262
- execute: async ({ sessionID, prompt, delivery }) => {
263
- const say = async (text: string) => {
264
- await ctx.session.synthetic({ sessionID, text } as any)
265
- }
266
- const text = prompt.text?.trim() ?? ""
267
- const parsed = parseDeepResearchArgs(text)
268
- if ("error" in parsed && parsed.error === "usage") {
269
- return say("Usage: /research deep <question> --max-usd N")
270
- }
271
- if ("error" in parsed) {
272
- return say(
273
- "Deep research needs a spend ceiling you set: /research deep <question> --max-usd N. Seats halt when the spend reaches it.",
274
- )
275
- }
276
- const { query, ceiling } = parsed
277
- const existing = await runtime.factory.read().catch(() => undefined)
278
- if (existing && existing.mode !== "research") {
279
- return say(
280
- "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).",
281
- )
282
- }
283
- if (existing?.stage === "HALTED") {
284
- return say(
285
- "This research run is halted; resume it first (`es factory resume` at a terminal, or /es-resume to preview).",
286
- )
287
- }
288
- if (existing?.stage === "DONE") {
289
- return say(
290
- "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).",
291
- )
292
- }
293
- if (!existing) {
294
- try {
295
- // The human typed /research deep: beginning the run is their action.
296
- await runtime.factory.beginResearchRun({ objective: query, ceilingUSD: Number(ceiling) }, "human:tui")
297
- } catch (error) {
298
- return say(`Could not start the research run: ${error instanceof Error ? error.message : String(error)}`)
299
- }
300
- }
301
- await ctx.session.switchAgent({ sessionID, agent: "deep-researcher" } as any)
302
- await ctx.session.prompt({
303
- ...prompt,
304
- sessionID,
305
- // The coordinator flow is the deep-researcher seat's system prompt
306
- // (single-sourced in core); this only adds the run state.
307
- text: `${await runtime.factory.summary()}\nDeep research runs adversarially, with no human answering questions mid-flow; follow your Flow through es_research_complete.`,
308
- delivery,
309
- } as any)
310
- },
311
- })
312
- editor.add({
313
- name: "audit",
314
- description: "Open a code audit of the active phase or a path and run the auditor pair (Epistemic Swarm)",
315
- execute: async ({ sessionID, prompt, delivery }) => {
316
- const target = prompt.text?.trim()
317
- const say = async (text: string) => {
318
- await ctx.session.synthetic({ sessionID, text } as any)
319
- }
320
- if (!target) return say("Usage: /audit <active phase id | path in the project>")
321
- const state = await runtime.factory.read().catch(() => undefined)
322
- if (state?.spendCeilingUSD === undefined)
323
- return say(
324
- "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.",
325
- )
326
- let opened: Awaited<ReturnType<typeof runtime.factory.openAudit>>
327
- try {
328
- // The human typed /audit: opening it is their action.
329
- opened = await runtime.factory.openAudit(
330
- "human:tui",
331
- parseAuditTarget(
332
- target,
333
- state.phases.map((phase) => phase.id),
334
- ),
335
- )
336
- } catch (error) {
337
- return say(`Could not open the audit: ${error instanceof Error ? error.message : String(error)}`)
338
- }
339
- const { audit } = opened
340
- await ctx.session.switchAgent({ sessionID, agent: "factory" } as any)
341
- await ctx.session.prompt({
342
- ...prompt,
343
- sessionID,
344
- 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.`,
345
- delivery,
346
- } as any)
347
- },
348
- })
349
- editor.add({
350
- name: "factory",
351
- description: "Drive the build factory from its current stage (Epistemic Swarm)",
352
- execute: async ({ sessionID, prompt, delivery }) => {
353
- const state = await runtime.factory.read()
354
- if (!state) {
355
- await ctx.session.synthetic({
356
- sessionID,
357
- text: "No factory run here yet — run /grill first.",
358
- } as any)
359
- return
360
- }
361
- await ctx.session.switchAgent({ sessionID, agent: "factory" } as any)
362
- await ctx.session.prompt({
363
- ...prompt,
364
- sessionID,
365
- text: `${await runtime.factory.summary(state)}\nContinue the factory run from its current stage.${prompt.text?.trim() ? `\nHuman note: ${prompt.text}` : ""}`,
366
- delivery,
367
- } as any)
368
- },
369
- })
370
- editor.add({
371
- name: "gates",
372
- description: "Run the mechanical gates on changed files (Epistemic Swarm)",
373
- execute: async ({ sessionID }) => {
374
- const changed = (await git(runtime.root, ["status", "--porcelain=v1", "-uall"], { allowFail: true })).stdout
375
- .split("\n")
376
- .filter(Boolean)
377
- .map((line) => line.slice(3).split(" -> ").at(-1)!)
378
- const report = await runGates({
379
- root: runtime.root,
380
- scope: changed.length ? "touched" : "full",
381
- touched: changed,
382
- stateDir: runtime.stateDir,
383
- })
384
- await ctx.session.synthetic({ sessionID, text: formatReport(report) } as any)
385
- },
386
- })
387
- editor.add({
388
- name: "lsp",
389
- description: "Language servers: configured, available and running (Epistemic Swarm)",
390
- execute: async ({ sessionID }) => {
391
- const status = await runtime.lsp.status()
392
- const settings = await runtime.lsp.settings()
393
- const lines = [status.enabled ? "LSP is on." : "LSP is disabled (lsp: false)."]
394
- for (const server of settings.servers) {
395
- const command = await runtime.lsp.command(server, runtime.root)
396
- const running = status.running.filter((item) => item.id === server.id)
397
- lines.push(
398
- `${server.id} [${server.extensions.join(" ")}]: ${Array.isArray(command) ? `available (${command.join(" ")})` : command.unavailable}${running.length ? ` · running for ${running.length} root(s)` : ""}`,
399
- )
400
- }
401
- lines.push(...status.diagnostics.map((item) => `! ${item}`))
402
- await ctx.session.synthetic({ sessionID, text: lines.join("\n") } as any)
403
- },
404
- })
405
- editor.add({
406
- name: "hooks",
407
- description: "Show the hook bridge: sources, handlers, trust and diagnostics (Epistemic Swarm)",
408
- execute: async ({ sessionID }) => {
409
- await bridge.engine.refresh()
410
- const status = bridge.engine.status()
411
- const trust =
412
- status.projectHandlers === 0
413
- ? "nothing to trust"
414
- : status.trusted
415
- ? "trusted"
416
- : "NOT trusted: run /es-trust or `es trust`"
417
- const lines = [
418
- `Hook bridge: ${status.handlers} handler(s), ${status.projectHandlers} from the project (${trust}).`,
419
- ...status.projectLines,
420
- ...status.diagnostics.map((item) => `! ${item}`),
421
- ...bridge.engine.recent.slice(-10).map((item) => `recent error: ${item}`),
422
- "Stop hooks are emulated after the turn ends (advisory for plain chat).",
423
- ]
424
- await ctx.session.synthetic({ sessionID, text: lines.join("\n") } as any)
425
- },
426
- })
427
- editor.add({
428
- name: "status",
429
- description: "Show the factory state (Epistemic Swarm)",
430
- execute: async ({ sessionID }) => {
431
- const state = await runtime.factory.read()
432
- const liveness = await readLiveness(runtime.root, state)
433
- const text = [await runtime.factory.summary(state), ...livenessLines(state, liveness)].join("\n")
434
- await ctx.session.synthetic({ sessionID, text } as any)
435
- },
436
- })
437
- editor.add({
438
- name: "config",
439
- description: "Show the effective Epistemic Swarm config and where it came from (Epistemic Swarm)",
440
- execute: async ({ sessionID }) => {
441
- const lines = [
442
- runtime.configSources.length
443
- ? `Layers: ${runtime.configSources.join(" → ")} → plugin options`
444
- : "Layers: defaults only (no config files)",
445
- ...runtime.warnings.map((warning) => `Warning: ${warning}`),
446
- JSON.stringify(runtime.config, null, 2),
447
- ]
448
- await ctx.session.synthetic({ sessionID, text: lines.join("\n") } as any)
449
- },
450
- })
451
- editor.add({
452
- name: "research",
453
- description: "Research stage: report, audit coverage and cached sources (Epistemic Swarm)",
454
- execute: async ({ sessionID }) => {
455
- const layout = factoryLayout(runtime.root)
456
- const report = await readFile(layout.researchReport, "utf8").catch(() => undefined)
457
- const coverage = await readJson<{
458
- claims?: number
459
- grounded?: number
460
- untagged?: number
461
- ungrounded?: number
462
- malformed?: number
463
- sources?: number
464
- passed?: boolean
465
- }>(path.join(layout.research, "coverage.json")).catch(() => undefined)
466
- const sources = (await readdir(researchSourcesDir(runtime.root)).catch(() => [])).filter((file) =>
467
- file.endsWith(".md"),
468
- )
469
- const lines = [
470
- report === undefined
471
- ? "No research report yet (.factory/research/REPORT.md)."
472
- : `Report: ${report.length} characters in .factory/research/REPORT.md.`,
473
- coverage
474
- ? `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"}.`
475
- : "No coverage.json yet (es_research_complete writes it).",
476
- `Cached sources: ${sources.length}.`,
477
- ]
478
- await ctx.session.synthetic({ sessionID, text: lines.join("\n") } as any)
479
- },
480
- })
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)
481
536
  })
482
537
 
483
538
  // Spend: per-step host cost for factory-agent sessions (estimated from tokens when cost is 0).
@@ -495,6 +550,10 @@ export default Plugin.define({
495
550
  // stream closed on unload
496
551
  }
497
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.
498
557
  return async () => {
499
558
  abort.abort()
500
559
  if (queued) clearTimeout(queued)