@heretek-ai/epistemic-swarm 1.1.0 → 1.1.2

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
@@ -517,25 +517,15 @@ var EsRpc = define({
517
517
  input: obj({}),
518
518
  output: obj({ summary: str, stage: str, pending: strArray }, ["summary", "stage", "pending"])
519
519
  },
520
+ configState: {
521
+ input: obj({}),
522
+ output: obj({ config: str, sources: strArray, warnings: strArray }, ["config", "sources", "warnings"])
523
+ },
520
524
  previewApproval: {
521
525
  input: obj({ stage: { type: "string", enum: ["frontier", "spec"] } }, ["stage"]),
522
526
  output: Preview
523
527
  },
524
- approve: {
525
- input: obj({ stage: { type: "string", enum: ["frontier", "spec"] }, user: str, token: str }, [
526
- "stage",
527
- "user",
528
- "token"
529
- ]),
530
- output: obj({ message: str }, ["message"]),
531
- errors: { refused: obj({ reason: str }, ["reason"]) }
532
- },
533
528
  previewTrust: { input: obj({}), output: Preview },
534
- trust: {
535
- input: obj({ user: str, token: str }, ["user", "token"]),
536
- output: obj({ message: str }, ["message"]),
537
- errors: { refused: obj({ reason: str }, ["reason"]) }
538
- },
539
529
  previewResume: { input: obj({}), output: Preview },
540
530
  previewLspInstall: { input: obj({ id: str }, ["id"]), output: Preview },
541
531
  lspInstall: {
@@ -543,17 +533,6 @@ var EsRpc = define({
543
533
  output: obj({ message: str }, ["message"]),
544
534
  errors: { refused: obj({ reason: str }, ["reason"]) }
545
535
  },
546
- resume: {
547
- input: obj({
548
- user: str,
549
- token: str,
550
- acceptControlDrift: { type: "boolean" },
551
- raiseCeilingUSD: { type: "number" },
552
- extendRuntime: { type: "boolean" }
553
- }, ["user", "token"]),
554
- output: obj({ message: str }, ["message"]),
555
- errors: { refused: obj({ reason: str }, ["reason"]) }
556
- },
557
536
  factoryState: {
558
537
  input: obj({}),
559
538
  output: obj({
@@ -632,6 +611,12 @@ var tui_default = Plugin.define({
632
611
  message,
633
612
  variant
634
613
  });
614
+ call("configState").then((view) => {
615
+ for (const warning of view.warnings)
616
+ toast(warning, "error");
617
+ }).catch(() => {
618
+ return;
619
+ });
635
620
  const confirm = async (preview, label) => {
636
621
  if (!preview.ok || !preview.token) {
637
622
  await context.ui.dialog.alert({
@@ -673,55 +658,59 @@ var tui_default = Plugin.define({
673
658
  }));
674
659
  if (stage !== "frontier" && stage !== "spec")
675
660
  return;
676
- const token = await confirm(await call("previewApproval", {
661
+ const preview = await call("previewApproval", {
677
662
  stage
678
- }), "Approve");
663
+ });
664
+ const token = await confirm(preview, "Approve");
679
665
  if (!token)
680
666
  return;
681
- toast((await call("approve", {
682
- stage,
683
- user,
684
- token
685
- })).message, "success");
667
+ await context.ui.dialog.alert({
668
+ title: preview.title,
669
+ message: `${preview.lines.join(`
670
+ `)}
671
+
672
+ Run \`es approve ${stage}\` at a terminal with your passphrase (1.1.1: approvals need the sealed human key).`
673
+ });
686
674
  });
687
675
  const trust = guarded(async () => {
688
- const token = await confirm(await call("previewTrust"), "Trust");
676
+ const preview = await call("previewTrust");
677
+ const token = await confirm(preview, "Trust");
689
678
  if (!token)
690
679
  return;
691
- toast((await call("trust", {
692
- user,
693
- token
694
- })).message, "success");
680
+ await context.ui.dialog.alert({
681
+ title: preview.title,
682
+ message: `${preview.lines.join(`
683
+ `)}
684
+
685
+ Run \`es trust\` at a terminal with your passphrase to sign these.`
686
+ });
695
687
  });
696
688
  const resume = guarded(async () => {
697
689
  const preview = await call("previewResume");
698
690
  const token = await confirm(preview, "Resume");
699
691
  if (!token)
700
692
  return;
693
+ const lines = preview.lines.join(`
694
+ `);
701
695
  const drift = preview.lines.some((line) => line.startsWith("Control-file drift"));
702
- let raiseCeilingUSD;
696
+ let raise = "";
703
697
  if (/spend ceiling/i.test(preview.lines[0] ?? "")) {
704
698
  const raised = await context.ui.dialog.prompt({
705
699
  title: "New spend ceiling (USD)",
706
700
  placeholder: "e.g. 50"
707
701
  });
708
- raiseCeilingUSD = raised ? Number(raised) : undefined;
702
+ if (raised)
703
+ raise = ` --raise-ceiling ${raised}`;
709
704
  }
710
- const extendRuntime = /runtime cap/i.test(preview.lines[0] ?? "");
711
- const result = await call("resume", {
712
- user,
713
- token,
714
- ...drift ? {
715
- acceptControlDrift: true
716
- } : {},
717
- ...raiseCeilingUSD ? {
718
- raiseCeilingUSD
719
- } : {},
720
- ...extendRuntime ? {
721
- extendRuntime: true
722
- } : {}
705
+ const extend = /runtime cap/i.test(lines) ? " --extend-runtime" : "";
706
+ const flags = `${drift ? " --accept-drift" : ""}${raise}${extend}`;
707
+ await context.ui.dialog.alert({
708
+ title: preview.title,
709
+ message: `${preview.lines.join(`
710
+ `)}
711
+
712
+ Run \`es factory resume${flags}\` at a terminal with your passphrase.`
723
713
  });
724
- toast(result.message, "success");
725
714
  });
726
715
  const lspInstall = guarded(async (input) => {
727
716
  const id = input?.trim() || await context.ui.dialog.prompt({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heretek-ai/epistemic-swarm",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Epistemic Swarm: an AI build factory for OpenCode v2 — grill, research, spec, build, QA and release, gated mechanically.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -41,7 +41,7 @@
41
41
  "test": "bun test"
42
42
  },
43
43
  "dependencies": {
44
- "@heretek-ai/es-core": "1.1.0",
44
+ "@heretek-ai/es-core": "1.1.2",
45
45
  "@opencode/plugin": "2.0.24"
46
46
  },
47
47
  "devDependencies": {
package/src/agents.ts CHANGED
@@ -23,6 +23,9 @@ export function permissionRules(spec: AgentSpec, mcpServers: readonly string[]):
23
23
  ...spec.spawns.map((agent) => ({ action: "subagent", resource: agent, effect: "allow" as const })),
24
24
  { action: "skill", resource: "*", effect: "deny" },
25
25
  ...spec.skills.map((skill) => ({ action: "skill", resource: skill, effect: "allow" as const })),
26
+ // Code Mode (`execute`) reaches host tools no seat needs, including MCP
27
+ // resources outside the seat's MCP scope.
28
+ { action: "execute", resource: "*", effect: "deny" },
26
29
  ]
27
30
  for (const server of mcpServers)
28
31
  if (!spec.mcp.includes(server))
package/src/policy.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // Mechanical enforcement in the host: every write, edit, patch and shell call
2
2
  // from any agent passes through core's policy (canonical paths, control files,
3
- // seat scopes); factory seats additionally stop on STOP or a halted run, run
4
- // read-only shells under bubblewrap, and get fast gate feedback after edits.
3
+ // seat scopes), and every shell command runs inside the bubblewrap sandbox its
4
+ // decision names (trust/sandbox.ts). Factory seats additionally stop on STOP
5
+ // or a halted run and get fast gate feedback after edits.
5
6
  import path from "node:path"
6
7
  import {
7
8
  agentSpec,
@@ -13,11 +14,10 @@ import {
13
14
  formatDiagnostics,
14
15
  formatReport,
15
16
  loadGatesConfig,
16
- readonlyWrap,
17
17
  runGates,
18
+ sandboxArgv,
18
19
  seatOf,
19
20
  shellQuote,
20
- treeFingerprint,
21
21
  } from "@heretek-ai/es-core"
22
22
  import { Error as ToolError } from "@opencode/plugin/promise/tool"
23
23
  import { HOST_WEB_TOOLS } from "./agents.ts"
@@ -50,8 +50,6 @@ const appendContent = (content: unknown, text: string) =>
50
50
  : text
51
51
 
52
52
  export function createPolicyHooks(runtime: Runtime) {
53
- const fingerprints = new Map<string, { dir: string; print: string }>()
54
-
55
53
  const before = async (event: { tool: string; agent: string; id: string; input: unknown }) => {
56
54
  const seat = seatOf(event.agent)
57
55
  const input = (event.input ?? {}) as Record<string, any>
@@ -88,14 +86,37 @@ export function createPolicyHooks(runtime: Runtime) {
88
86
  if (event.tool === "shell" && typeof input.command === "string") {
89
87
  const decision = evaluateShell(context, event.agent, input.command, { sandboxAvailable: bwrapAvailable() })
90
88
  if (decision.effect === "deny") deny(decision.reason)
91
- const cwd =
92
- typeof input.workdir === "string"
93
- ? path.resolve(runtime.root, input.workdir)
94
- : (context.worktree ?? runtime.root)
95
- if (decision.effect === "allow" && decision.mode === "readonly-sandbox") {
96
- event.input = { ...input, command: readonlyWrap(["sh", "-c", input.command], cwd).map(shellQuote).join(" ") }
97
- } else if (decision.effect === "allow" && decision.mode === "readonly-checked") {
98
- fingerprints.set(event.id, { dir: cwd, print: await treeFingerprint(cwd) })
89
+ }
90
+ }
91
+
92
+ /**
93
+ * The last execute.before hook: re-evaluates the final shell command (a
94
+ * PreToolUse hook may have rewritten it) and wraps it in its sandbox, so no
95
+ * later rewrite can run unsandboxed and hook matchers see the plain command.
96
+ */
97
+ const sandbox = async (event: { tool: string; agent: string; id: string; input: unknown }) => {
98
+ const input = (event.input ?? {}) as Record<string, any>
99
+ if (event.tool === "shell" && typeof input.command === "string") {
100
+ const context = await runtime.policy()
101
+ const decision = evaluateShell(context, event.agent, input.command, { sandboxAvailable: bwrapAvailable() })
102
+ if (decision.effect === "deny") deny(decision.reason)
103
+ if (decision.effect === "allow" && decision.mode === "sandbox") {
104
+ const cwd =
105
+ typeof input.workdir === "string"
106
+ ? path.resolve(runtime.root, input.workdir)
107
+ : (context.worktree ?? runtime.root)
108
+ const argv = sandboxArgv(
109
+ {
110
+ kind: decision.kind,
111
+ root: runtime.root,
112
+ cwd,
113
+ stateDir: runtime.stateDir,
114
+ offline: decision.offline,
115
+ ...(decision.kind === "programmer" && context.worktree ? { writable: context.worktree } : {}),
116
+ },
117
+ ["sh", "-c", input.command],
118
+ )
119
+ event.input = { ...input, command: argv.map(shellQuote).join(" ") }
99
120
  }
100
121
  }
101
122
  }
@@ -109,23 +130,6 @@ export function createPolicyHooks(runtime: Runtime) {
109
130
  result?: any
110
131
  }) => {
111
132
  const seat = seatOf(event.agent)
112
- const checked = fingerprints.get(event.id)
113
- if (checked) {
114
- fingerprints.delete(event.id)
115
- if ((await treeFingerprint(checked.dir)) !== checked.print) {
116
- await runtime.factory
117
- .halt(`agent:${event.agent}`, `read-only seat ${event.agent} changed files via the shell`)
118
- .catch(() => undefined)
119
- if (event.status === "completed")
120
- event.result = {
121
- ...event.result,
122
- content: appendContent(
123
- event.result?.content,
124
- "VIOLATION: this read-only seat modified the checkout. The factory has been halted.",
125
- ),
126
- }
127
- }
128
- }
129
133
  if (event.status !== "completed") return
130
134
  const input = (event.input ?? {}) as Record<string, any>
131
135
  const files =
@@ -165,6 +169,7 @@ export function createPolicyHooks(runtime: Runtime) {
165
169
  commands: config.commands.filter((check) => check.kind === "format" || check.kind === "lint"),
166
170
  },
167
171
  stateDir: runtime.stateDir,
172
+ sandbox: "required",
168
173
  })
169
174
  if (report.findings.length || !report.passed)
170
175
  event.result = {
@@ -225,5 +230,5 @@ export function createPolicyHooks(runtime: Runtime) {
225
230
  for (const key of Object.keys(event.env)) if (/^OPENCODE_(SERVER_)?PASSWORD$/.test(key)) delete event.env[key]
226
231
  }
227
232
 
228
- return { before, after, evaluate, shellEnv }
233
+ return { before, sandbox, after, evaluate, shellEnv }
229
234
  }
package/src/research.ts CHANGED
@@ -1,65 +1,127 @@
1
1
  // Research integration: the configured search provider becomes the host's
2
- // websearch (results cached content-addressed, so they can be cited), and the
3
- // host's own webfetch results are cached too.
4
- import { researchCache, selectProvider } from "@heretek-ai/es-core"
2
+ // websearch, and host web results are cached content-addressed so they can be
3
+ // cited. Caching is for Epistemic Swarm seats in a project that already has a
4
+ // .factory/ directory: the user's own agents get plain results, and no
5
+ // project gains a .factory/ because someone searched the web.
6
+ import { stat } from "node:fs/promises"
7
+ import { factoryLayout, researchCache, seatOf, selectProvider } from "@heretek-ai/es-core"
5
8
  import type { Runtime } from "./runtime.ts"
6
9
 
10
+ interface SearchHit {
11
+ readonly url: string
12
+ readonly title?: string
13
+ readonly text: string
14
+ }
15
+
16
+ /** Results the provider returned, waiting for the tool hook (which knows the agent) to cache them. */
17
+ export class PendingSearches {
18
+ private readonly entries: Array<{ query: string; provider: string; hits: readonly SearchHit[]; at: number }> = []
19
+ private static readonly TTL_MS = 5 * 60_000
20
+
21
+ add(query: string, provider: string, hits: readonly SearchHit[]) {
22
+ this.prune()
23
+ this.entries.push({ query, provider, hits, at: Date.now() })
24
+ }
25
+
26
+ /** The oldest unclaimed results for this query (each search is claimed once). */
27
+ take(query: string) {
28
+ this.prune()
29
+ const index = this.entries.findIndex((entry) => entry.query === query)
30
+ return index < 0 ? undefined : this.entries.splice(index, 1)[0]
31
+ }
32
+
33
+ private prune() {
34
+ const cutoff = Date.now() - PendingSearches.TTL_MS
35
+ while (this.entries.length && this.entries[0]!.at < cutoff) this.entries.shift()
36
+ }
37
+ }
38
+
39
+ /** Host web results are cached only for an ES seat, in a project that already has .factory/. */
40
+ async function cacheable(runtime: Runtime, agent: string | undefined): Promise<boolean> {
41
+ if (!seatOf(agent)) return false
42
+ return stat(factoryLayout(runtime.root).dir).then(
43
+ (info) => info.isDirectory(),
44
+ () => false,
45
+ )
46
+ }
47
+
7
48
  export function registerWebsearch(
8
49
  editor: { add(definition: any): void; default: { get(): unknown; set(id: string): void } },
9
50
  runtime: Runtime,
51
+ pending: PendingSearches,
10
52
  ) {
11
53
  const searxng = runtime.config.research?.searxngUrl
12
54
  const env = searxng ? { ...process.env, SEARXNG_URL: searxng } : process.env
13
55
  const provider = selectProvider(runtime.options.searchProvider, env)
14
56
  if (!provider) return
15
- const cache = researchCache(runtime.root)
16
57
  editor.add({
17
58
  id: "epistemic-swarm",
18
- name: `Epistemic Swarm (${provider.id}, cached)`,
59
+ name: `Epistemic Swarm (${provider.id})`,
19
60
  execute: async (input: { query: string; limit?: number }, context: { signal: AbortSignal }) => {
20
61
  const results = await provider.search(input.query, { limit: input.limit ?? 8, signal: context.signal, env })
21
- const out = []
22
- for (const result of results) {
23
- const cached = await cache.put({
24
- url: result.url,
25
- title: result.title,
26
- text: result.content ?? result.snippet,
27
- provider: provider.id,
28
- query: input.query,
29
- })
30
- out.push({
31
- url: result.url,
32
- title: result.title,
33
- content: `[cached sha256:${cached.meta.sha256}]\n${result.content ?? result.snippet}`,
34
- time: {},
35
- })
36
- }
37
- return out
62
+ const hits = results.map((result) => ({
63
+ url: result.url,
64
+ title: result.title,
65
+ text: result.content ?? result.snippet,
66
+ }))
67
+ pending.add(input.query, provider.id, hits)
68
+ return hits.map((hit) => ({ url: hit.url, title: hit.title, content: hit.text, time: {} }))
38
69
  },
39
70
  })
40
71
  // Additive: become the default only when nothing else is selected.
41
72
  if (editor.default.get() === undefined) editor.default.set("epistemic-swarm")
42
73
  }
43
74
 
44
- /** Cache host webfetch results so they can be cited as [VERIFIED: sha256:… "quote"]. */
45
- export function createWebfetchCache(runtime: Runtime) {
46
- const cache = researchCache(runtime.root)
47
- return async (event: { tool: string; status: string; input: unknown; result?: any }) => {
75
+ const contentText = (content: unknown): string =>
76
+ typeof content === "string"
77
+ ? content
78
+ : Array.isArray(content)
79
+ ? content.map((part: any) => part?.text ?? "").join("\n")
80
+ : ""
81
+
82
+ const prepend = (result: any, note: string) => {
83
+ const content = result?.content
84
+ return {
85
+ ...result,
86
+ content: typeof content === "string" ? `${note}\n${content}` : [{ type: "text", text: note }, ...(content ?? [])],
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Cache a seat's host webfetch and websearch results so they can be cited as
92
+ * [VERIFIED: sha256:… "quote"]. Other agents' results pass through untouched.
93
+ */
94
+ export function createWebCache(runtime: Runtime, pending: PendingSearches) {
95
+ const cache = researchCache(runtime.root, runtime.stateDir)
96
+ return async (event: { tool: string; status: string; agent?: string; input: unknown; result?: any }) => {
97
+ if (event.tool === "websearch") {
98
+ const query = (event.input as { query?: string } | undefined)?.query
99
+ const search = typeof query === "string" ? pending.take(query) : undefined
100
+ if (!search || event.status !== "completed" || !(await cacheable(runtime, event.agent))) return
101
+ const lines: string[] = []
102
+ for (const hit of search.hits) {
103
+ if (!hit.text?.trim()) continue
104
+ const cached = await cache.put({
105
+ url: hit.url,
106
+ title: hit.title,
107
+ text: hit.text,
108
+ provider: search.provider,
109
+ query: search.query,
110
+ })
111
+ lines.push(`[cached sha256:${cached.meta.sha256}] ${hit.url}`)
112
+ }
113
+ if (lines.length)
114
+ event.result = prepend(event.result, `${lines.join("\n")}\n(cite with [VERIFIED: sha256:<hash> "exact words"])`)
115
+ return
116
+ }
48
117
  if (event.tool !== "webfetch" || event.status !== "completed") return
49
118
  const url = (event.input as { url?: string } | undefined)?.url
50
- const content = event.result?.content
51
- const text =
52
- typeof content === "string"
53
- ? content
54
- : Array.isArray(content)
55
- ? content.map((part: any) => part?.text ?? "").join("\n")
56
- : ""
57
- if (!url || text.trim().length < 40) return
119
+ const text = contentText(event.result?.content)
120
+ if (!url || text.trim().length < 40 || !(await cacheable(runtime, event.agent))) return
58
121
  const cached = await cache.put({ url, text, provider: "webfetch" })
59
- const note = `[cached as sha256:${cached.meta.sha256}; cite with [VERIFIED: sha256:${cached.meta.sha256} "exact words"]]`
60
- event.result = {
61
- ...event.result,
62
- content: typeof content === "string" ? `${note}\n${content}` : [{ type: "text", text: note }, ...(content ?? [])],
63
- }
122
+ event.result = prepend(
123
+ event.result,
124
+ `[cached as sha256:${cached.meta.sha256}; cite with [VERIFIED: sha256:${cached.meta.sha256} "exact words"]]`,
125
+ )
64
126
  }
65
127
  }
package/src/rpc-def.ts CHANGED
@@ -1,6 +1,7 @@
1
- // RPC contract between the server plugin and the TUI. These methods are the
2
- // TUI half of the human-only channel: no agent tool reaches them, and the TUI
3
- // only calls them after a human confirms a dialog showing what is approved.
1
+ // RPC contract between the server plugin and the TUI. Previews are shown in
2
+ // the TUI; human-only mutations (approve, trust, resume) happen at a terminal
3
+ // with the passphrase (`es approve`, `es trust`, `es factory resume`), never
4
+ // through an agent tool or RPC. Only lspInstall still mutates via RPC.
4
5
  import { Rpc } from "@opencode/plugin/rpc"
5
6
 
6
7
  const obj = (properties: Record<string, unknown>, required: string[] = []) => ({
@@ -26,25 +27,16 @@ export const EsRpc = Rpc.define({
26
27
  input: obj({}),
27
28
  output: obj({ summary: str, stage: str, pending: strArray }, ["summary", "stage", "pending"]),
28
29
  },
30
+ /** The effective config (JSON), the files it came from, and load warnings to show once. */
31
+ configState: {
32
+ input: obj({}),
33
+ output: obj({ config: str, sources: strArray, warnings: strArray }, ["config", "sources", "warnings"]),
34
+ },
29
35
  previewApproval: {
30
36
  input: obj({ stage: { type: "string", enum: ["frontier", "spec"] } }, ["stage"]),
31
37
  output: Preview,
32
38
  },
33
- approve: {
34
- input: obj({ stage: { type: "string", enum: ["frontier", "spec"] }, user: str, token: str }, [
35
- "stage",
36
- "user",
37
- "token",
38
- ]),
39
- output: obj({ message: str }, ["message"]),
40
- errors: { refused: obj({ reason: str }, ["reason"]) },
41
- },
42
39
  previewTrust: { input: obj({}), output: Preview },
43
- trust: {
44
- input: obj({ user: str, token: str }, ["user", "token"]),
45
- output: obj({ message: str }, ["message"]),
46
- errors: { refused: obj({ reason: str }, ["reason"]) },
47
- },
48
40
  previewResume: { input: obj({}), output: Preview },
49
41
  previewLspInstall: { input: obj({ id: str }, ["id"]), output: Preview },
50
42
  lspInstall: {
@@ -52,20 +44,6 @@ export const EsRpc = Rpc.define({
52
44
  output: obj({ message: str }, ["message"]),
53
45
  errors: { refused: obj({ reason: str }, ["reason"]) },
54
46
  },
55
- resume: {
56
- input: obj(
57
- {
58
- user: str,
59
- token: str,
60
- acceptControlDrift: { type: "boolean" },
61
- raiseCeilingUSD: { type: "number" },
62
- extendRuntime: { type: "boolean" },
63
- },
64
- ["user", "token"],
65
- ),
66
- output: obj({ message: str }, ["message"]),
67
- errors: { refused: obj({ reason: str }, ["reason"]) },
68
- },
69
47
  factoryState: {
70
48
  input: obj({}),
71
49
  output: obj(
package/src/rpc.ts CHANGED
@@ -1,7 +1,7 @@
1
- // Server side of the human-only channel. Each mutating method needs a token
2
- // from a preview made in the last two minutes over the same subject, so the
3
- // human confirms exactly what the dialog showed (no swap between preview and
4
- // confirm).
1
+ // Server side of the TUI previews. Human-only mutations (approve, trust,
2
+ // resume) happen at a terminal with the passphrase-sealed key, never via RPC:
3
+ // the TUI previews, then points at `es approve`, `es trust`, `es factory
4
+ // resume`. Only lspInstall still mutates via a preview token.
5
5
  import { randomBytes } from "node:crypto"
6
6
  import {
7
7
  type ApprovalStage,
@@ -10,7 +10,6 @@ import {
10
10
  capabilityLoss,
11
11
  commandSetHash,
12
12
  describeTarget,
13
- factoryLayout,
14
13
  factorySummary,
15
14
  type HookEngine,
16
15
  hashJson,
@@ -22,12 +21,9 @@ import {
22
21
  readPlan,
23
22
  readResult,
24
23
  readScores,
25
- recordApproval,
26
24
  recordConsent,
27
25
  treeCounts,
28
- trustProject,
29
26
  verifyControl,
30
- writeJson,
31
27
  } from "@heretek-ai/es-core"
32
28
  import type { Runtime } from "./runtime.ts"
33
29
  import { pendingApprovals } from "./tools.ts"
@@ -40,7 +36,7 @@ interface Ticket {
40
36
 
41
37
  const TTL_MS = 120_000
42
38
 
43
- export function createRpcHandlers(runtime: Runtime, notify: () => Promise<void>, hooks?: HookEngine) {
39
+ export function createRpcHandlers(runtime: Runtime, _notify: () => Promise<void>, hooks?: HookEngine) {
44
40
  const tickets = new Map<string, Ticket>()
45
41
  const issue = (kind: string, subject: unknown) => {
46
42
  const token = randomBytes(12).toString("hex")
@@ -84,6 +80,11 @@ export function createRpcHandlers(runtime: Runtime, notify: () => Promise<void>,
84
80
  pending: (await pendingApprovals(runtime.root)).map((item) => item.stage),
85
81
  }
86
82
  },
83
+ configState: async () => ({
84
+ config: JSON.stringify(runtime.config),
85
+ sources: [...runtime.configSources],
86
+ warnings: [...runtime.warnings],
87
+ }),
87
88
  factoryState: async () => {
88
89
  const state = await runtime.factory.read()
89
90
  const frontier = await readFrontier(runtime.root).catch(() => undefined)
@@ -204,31 +205,6 @@ export function createRpcHandlers(runtime: Runtime, notify: () => Promise<void>,
204
205
  }
205
206
  }
206
207
  },
207
- approve: async (input: unknown, context: any) => {
208
- const { stage, user, token } = input as { stage: ApprovalStage; user: string; token: string }
209
- try {
210
- redeem(token, `approve:${stage}`, (await approvalSubject(runtime.root, stage)).subject)
211
- const record = await recordApproval(runtime.root, {
212
- stage,
213
- channel: "tui",
214
- approvedBy: user,
215
- stateDir: runtime.stateDir,
216
- })
217
- await writeJson(
218
- factoryLayout(runtime.root).pending,
219
- (await pendingApprovals(runtime.root)).filter((item) => item.stage !== stage),
220
- )
221
- if (stage === "frontier") {
222
- const state = await runtime.factory.read()
223
- if (!state) await runtime.factory.begin(`human:${user}`)
224
- if ((await runtime.factory.read())?.stage === "GRILL") await runtime.factory.beginResearch(`human:${user}`)
225
- }
226
- await notify()
227
- return { message: `Approved ${stage} as ${record.approvedBy}.` }
228
- } catch (error) {
229
- return refused(context, error)
230
- }
231
- },
232
208
  previewTrust: async () => {
233
209
  const subject = await trustSubject()
234
210
  const trusted =
@@ -242,27 +218,6 @@ export function createRpcHandlers(runtime: Runtime, notify: () => Promise<void>,
242
218
  token: issue("trust", subject.key),
243
219
  }
244
220
  },
245
- trust: async (input: unknown, context: any) => {
246
- const { user, token } = input as { user: string; token: string }
247
- try {
248
- const subject = await trustSubject()
249
- redeem(token, "trust", subject.key)
250
- await trustProject(runtime.root, subject.gates.hash, subject.gates.lines, {
251
- approvedBy: user,
252
- stateDir: runtime.stateDir,
253
- })
254
- if (subject.hooks.hash)
255
- await trustProject(runtime.root, subject.hooks.hash, subject.hooks.lines, {
256
- approvedBy: user,
257
- stateDir: runtime.stateDir,
258
- kind: "hooks",
259
- })
260
- await hooks?.refresh()
261
- return { message: `Trusted ${subject.lines.length} command/hook line(s).` }
262
- } catch (error) {
263
- return refused(context, error)
264
- }
265
- },
266
221
  previewLspInstall: async (input: unknown) => {
267
222
  const { id } = input as { id: string }
268
223
  const server = (await runtime.lsp.settings()).servers.find((item) => item.id === id)
@@ -321,26 +276,5 @@ export function createRpcHandlers(runtime: Runtime, notify: () => Promise<void>,
321
276
  token: issue("resume", state.halt),
322
277
  }
323
278
  },
324
- resume: async (input: unknown, context: any) => {
325
- const { user, token, acceptControlDrift, raiseCeilingUSD, extendRuntime } = input as {
326
- user: string
327
- token: string
328
- acceptControlDrift?: boolean
329
- raiseCeilingUSD?: number
330
- extendRuntime?: boolean
331
- }
332
- try {
333
- redeem(token, "resume", (await runtime.factory.read())?.halt)
334
- const state = await runtime.factory.resume(user, {
335
- ...(acceptControlDrift ? { acceptControlDrift } : {}),
336
- ...(raiseCeilingUSD ? { raiseCeilingUSD } : {}),
337
- ...(extendRuntime ? { extendRuntime } : {}),
338
- })
339
- await notify()
340
- return { message: `Resumed at ${state.stage}.` }
341
- } catch (error) {
342
- return refused(context, error)
343
- }
344
- },
345
279
  }
346
280
  }
package/src/runtime.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  // Per-location runtime shared by the plugin's hooks, tools, commands and RPC.
2
2
  // Options are layered: the canonical config (global file → project file) with
3
- // plugin options as the most specific override.
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.
4
6
  import {
5
7
  stateDir as defaultStateDir,
6
8
  type EsConfig,
9
+ EsConfigSchema,
7
10
  Factory,
8
11
  gateRunner,
9
12
  LspManager,
@@ -12,22 +15,14 @@ import {
12
15
  } from "@heretek-ai/es-core"
13
16
  import { ghPrOpener } from "./pr.ts"
14
17
 
15
- /** Raw plugin options from opencode.json; every field is optional. */
16
- export interface RawPluginOptions {
18
+ /** Plugin options from opencode.json, split into config keys and the rest. */
19
+ export interface ParsedOptions {
17
20
  /** Override the user-global state dir (signing key, trust store). Tests only. */
18
21
  readonly stateDir?: string
19
- /** Model per tier, "provider/model". */
20
- readonly models?: {
21
- readonly fast?: string
22
- readonly balanced?: string
23
- readonly deep?: string
24
- readonly agents?: Readonly<Record<string, string>>
25
- }
26
- readonly afterEdit?: "fast" | "off"
27
- readonly estimate?: { readonly inputPerM?: number; readonly outputPerM?: number }
28
- readonly pr?: "gh" | "off"
29
- readonly lspAfterEdit?: boolean
30
- readonly searchProvider?: "brave" | "firecrawl" | "searxng"
22
+ /** Config keys (docs/CONFIG.md), layered over the config files and validated with them. */
23
+ readonly overrides: Readonly<Record<string, unknown>>
24
+ /** Keys that are not config keys, sorted; ignored with a warning. */
25
+ readonly unknown: readonly string[]
31
26
  }
32
27
 
33
28
  /** Resolved options: config defaults applied; consumers read this shape. */
@@ -47,16 +42,18 @@ export interface Runtime {
47
42
  readonly config: EsConfig
48
43
  /** Config files that contributed values, in layer order. */
49
44
  readonly configSources: readonly string[]
45
+ /** Human-facing load warnings (e.g. ignored plugin options), shown once by the TUI. */
46
+ readonly warnings: readonly string[]
50
47
  readonly stateDir: string
51
48
  readonly factory: Factory
52
49
  readonly lsp: LspManager
53
50
  policy(): Promise<PolicyContext>
54
51
  }
55
52
 
56
- export async function createRuntime(root: string, raw: RawPluginOptions): Promise<Runtime> {
53
+ export async function createRuntime(root: string, parsed: ParsedOptions): Promise<Runtime> {
57
54
  // stateDir is runtime-only; everything else can come from config files.
58
- const { stateDir: rawStateDir, ...overrides } = raw
59
- const loaded = await loadEsConfig(root, { overrides })
55
+ const rawStateDir = parsed.stateDir
56
+ const loaded = await loadEsConfig(root, { overrides: { ...parsed.overrides } })
60
57
  const config = loaded.config
61
58
  const options: PluginOptions = {
62
59
  ...(rawStateDir ? { stateDir: rawStateDir } : {}),
@@ -75,12 +72,14 @@ export async function createRuntime(root: string, raw: RawPluginOptions): Promis
75
72
  stateDir,
76
73
  researchDepth: config.research.depth,
77
74
  ...(config.domainPack ? { domainPack: config.domainPack } : {}),
75
+ ...(config.audit.phase === "required" ? { auditPhase: "required" as const } : {}),
78
76
  })
79
77
  return {
80
78
  root,
81
79
  options,
82
80
  config,
83
81
  configSources: loaded.sources,
82
+ warnings: parsed.unknown.length ? [unknownOptionsWarning(parsed.unknown)] : [],
84
83
  stateDir,
85
84
  factory,
86
85
  lsp,
@@ -91,24 +90,30 @@ export async function createRuntime(root: string, raw: RawPluginOptions): Promis
91
90
  }
92
91
  }
93
92
 
94
- /** Keep only the keys the user actually set, so config files are not clobbered. */
95
- export function parseOptions(raw: Record<string, unknown> | undefined): RawPluginOptions {
96
- const options = raw ?? {}
97
- const models =
98
- typeof options.models === "object" && options.models ? (options.models as RawPluginOptions["models"]) : undefined
99
- const estimate =
100
- typeof options.estimate === "object" && options.estimate
101
- ? (options.estimate as RawPluginOptions["estimate"])
102
- : undefined
103
- return {
104
- ...(typeof options.stateDir === "string" ? { stateDir: options.stateDir } : {}),
105
- ...(models ? { models } : {}),
106
- ...(options.afterEdit === "fast" || options.afterEdit === "off" ? { afterEdit: options.afterEdit } : {}),
107
- ...(estimate ? { estimate } : {}),
108
- ...(options.pr === "gh" || options.pr === "off" ? { pr: options.pr } : {}),
109
- ...(typeof options.lspAfterEdit === "boolean" ? { lspAfterEdit: options.lspAfterEdit } : {}),
110
- ...(typeof options.searchProvider === "string"
111
- ? { searchProvider: options.searchProvider as RawPluginOptions["searchProvider"] }
112
- : {}),
93
+ const CONFIG_KEYS: ReadonlySet<string> = new Set(Object.keys(EsConfigSchema.shape))
94
+
95
+ /**
96
+ * Split raw plugin options into config keys and the rest. Only keys the user
97
+ * set are passed on, so config files are not clobbered; their values are
98
+ * validated by the strict config schema (an invalid value fails the load, as
99
+ * it would in a config file).
100
+ */
101
+ export function parseOptions(raw: Record<string, unknown> | undefined): ParsedOptions {
102
+ const overrides: Record<string, unknown> = {}
103
+ const unknown: string[] = []
104
+ let stateDir: string | undefined
105
+ for (const [key, value] of Object.entries(raw ?? {})) {
106
+ if (key === "stateDir" && typeof value === "string") stateDir = value
107
+ else if (CONFIG_KEYS.has(key)) overrides[key] = value
108
+ else unknown.push(key)
113
109
  }
110
+ return { ...(stateDir ? { stateDir } : {}), overrides, unknown: unknown.sort() }
111
+ }
112
+
113
+ export function unknownOptionsWarning(keys: readonly string[]): string {
114
+ return (
115
+ `Ignored unknown plugin option${keys.length === 1 ? "" : "s"}: ${keys.join(", ")}. ` +
116
+ "Plugin options take the config keys in docs/CONFIG.md (es config show); " +
117
+ "0.7-era options such as search_engine, max_iterations and mode were removed in 1.0."
118
+ )
114
119
  }
package/src/server.ts CHANGED
@@ -5,6 +5,7 @@ import { readdir, readFile } from "node:fs/promises"
5
5
  import path from "node:path"
6
6
  import {
7
7
  describeTarget,
8
+ ensureEngineKey,
8
9
  factoryLayout,
9
10
  formatReport,
10
11
  git,
@@ -19,7 +20,7 @@ import { compileAgents } from "./agents.ts"
19
20
  import { createFactoryContinuation } from "./continue.ts"
20
21
  import { createHookBridge } from "./hooks.ts"
21
22
  import { createPolicyHooks } from "./policy.ts"
22
- import { createWebfetchCache, registerWebsearch } from "./research.ts"
23
+ import { createWebCache, PendingSearches, registerWebsearch } from "./research.ts"
23
24
  import { createRpcHandlers } from "./rpc.ts"
24
25
  import { EsRpc } from "./rpc-def.ts"
25
26
  import { createRuntime, parseOptions } from "./runtime.ts"
@@ -33,6 +34,9 @@ export default Plugin.define({
33
34
  async setup(ctx) {
34
35
  const options = parseOptions(ctx.options as Record<string, unknown>)
35
36
  const runtime = await createRuntime(ctx.location.directory, options)
37
+ // The engine key seals cached sources (#52); create it at setup so the
38
+ // masked state dir holds the key before any seat runs.
39
+ await ensureEngineKey(runtime.stateDir)
36
40
 
37
41
  // Agents: canonical registry → v2 agents with wildcard-deny scoping.
38
42
  const servers = await ctx.mcp.list().then(
@@ -66,7 +70,9 @@ export default Plugin.define({
66
70
  })
67
71
 
68
72
  await ctx.tool.transform((editor) => registerTools(editor, runtime))
69
- await ctx.websearch.transform((editor) => registerWebsearch(editor as any, runtime))
73
+ // Search results wait here until the tool hook, which knows the agent, decides whether to cache them.
74
+ const searches = new PendingSearches()
75
+ await ctx.websearch.transform((editor) => registerWebsearch(editor as any, runtime, searches))
70
76
 
71
77
  // Our policy runs first, so its denials win over any user hook.
72
78
  const policy = createPolicyHooks(runtime)
@@ -82,8 +88,10 @@ export default Plugin.define({
82
88
  }
83
89
  await ctx.tool.hook("execute.before", policy.before as any)
84
90
  await ctx.tool.hook("execute.before", bridge.before as any)
91
+ // Last: the final (possibly hook-rewritten) shell command is re-checked and sandboxed.
92
+ await ctx.tool.hook("execute.before", policy.sandbox as any)
85
93
  await ctx.tool.hook("execute.after", policy.after as any)
86
- await ctx.tool.hook("execute.after", createWebfetchCache(runtime) as any)
94
+ await ctx.tool.hook("execute.after", createWebCache(runtime, searches) as any)
87
95
  await ctx.tool.hook("execute.after", bridge.after as any)
88
96
  await ctx.permission.hook("evaluate", policy.evaluate as any)
89
97
  await ctx.permission.hook("evaluate", bridge.evaluate as any)
@@ -312,6 +320,7 @@ export default Plugin.define({
312
320
  runtime.configSources.length
313
321
  ? `Layers: ${runtime.configSources.join(" → ")} → plugin options`
314
322
  : "Layers: defaults only (no config files)",
323
+ ...runtime.warnings.map((warning) => `Warning: ${warning}`),
315
324
  JSON.stringify(runtime.config, null, 2),
316
325
  ]
317
326
  await ctx.session.synthetic({ sessionID, text: lines.join("\n") } as any)
package/src/tools.ts CHANGED
@@ -21,6 +21,7 @@ export function registerTools(editor: { add(tool: any): void }, runtime: Runtime
21
21
  const research = researchTools({
22
22
  root: runtime.root,
23
23
  policy: () => runtime.policy(),
24
+ stateDir: runtime.stateDir,
24
25
  ...researchOptions(runtime.config),
25
26
  })
26
27
  const brainstorm = brainstormTools({
package/src/tui.tsx CHANGED
@@ -1,7 +1,7 @@
1
- // Epistemic Swarm TUI plugin: the human-only dialogs. Approvals, trust and
2
- // resume happen here (or in the `es` CLI), never through an agent tool. Each
3
- // dialog shows a server-computed preview; confirming redeems that preview's
4
- // token, so what the human saw is exactly what gets signed.
1
+ // Epistemic Swarm TUI plugin: previews for the human-only actions. Approvals,
2
+ // trust and resume are previewed here, then recorded at a terminal with the
3
+ // passphrase-sealed human key (`es approve`, `es trust`, `es factory resume`),
4
+ // never through an agent tool or RPC. Only lspInstall still mutates via RPC.
5
5
  import { userInfo } from "node:os"
6
6
  import { Plugin } from "@opencode/plugin/tui"
7
7
  import { registerPanels } from "./panels.tsx"
@@ -19,6 +19,12 @@ export default Plugin.define({
19
19
  const user = userInfo().username
20
20
  const toast = (message: string, variant: "success" | "error" | "info" = "info") =>
21
21
  context.ui.toast.show({ title: "Epistemic Swarm", message, variant })
22
+ // Load warnings (e.g. ignored plugin options) are shown once per TUI start.
23
+ void call("configState")
24
+ .then((view: { warnings: string[] }) => {
25
+ for (const warning of view.warnings) toast(warning, "error")
26
+ })
27
+ .catch(() => undefined)
22
28
 
23
29
  const confirm = async (preview: Preview, label: string) => {
24
30
  if (!preview.ok || !preview.token) {
@@ -58,36 +64,44 @@ export default Plugin.define({
58
64
  ],
59
65
  }))
60
66
  if (stage !== "frontier" && stage !== "spec") return
61
- const token = await confirm(await call("previewApproval", { stage }), "Approve")
67
+ const preview: Preview = await call("previewApproval", { stage })
68
+ const token = await confirm(preview, "Approve")
62
69
  if (!token) return
63
- toast((await call("approve", { stage, user, token })).message, "success")
70
+ await context.ui.dialog.alert({
71
+ title: preview.title,
72
+ message: `${preview.lines.join("\n")}\n\nRun \`es approve ${stage}\` at a terminal with your passphrase (1.1.1: approvals need the sealed human key).`,
73
+ })
64
74
  })
65
75
 
66
76
  const trust = guarded(async () => {
67
- const token = await confirm(await call("previewTrust"), "Trust")
77
+ const preview: Preview = await call("previewTrust")
78
+ const token = await confirm(preview, "Trust")
68
79
  if (!token) return
69
- toast((await call("trust", { user, token })).message, "success")
80
+ await context.ui.dialog.alert({
81
+ title: preview.title,
82
+ message: `${preview.lines.join("\n")}\n\nRun \`es trust\` at a terminal with your passphrase to sign these.`,
83
+ })
70
84
  })
71
85
 
72
86
  const resume = guarded(async () => {
73
87
  const preview: Preview = await call("previewResume")
74
88
  const token = await confirm(preview, "Resume")
75
89
  if (!token) return
90
+ const lines = preview.lines.join("\n")
76
91
  const drift = preview.lines.some((line) => line.startsWith("Control-file drift"))
77
- let raiseCeilingUSD: number | undefined
92
+ // The halt reason names the remedy: a ceiling halt takes a new ceiling,
93
+ // a runtime halt restarts the cap. The values still come from the human.
94
+ let raise = ""
78
95
  if (/spend ceiling/i.test(preview.lines[0] ?? "")) {
79
96
  const raised = await context.ui.dialog.prompt({ title: "New spend ceiling (USD)", placeholder: "e.g. 50" })
80
- raiseCeilingUSD = raised ? Number(raised) : undefined
97
+ if (raised) raise = ` --raise-ceiling ${raised}`
81
98
  }
82
- const extendRuntime = /runtime cap/i.test(preview.lines[0] ?? "")
83
- const result = await call("resume", {
84
- user,
85
- token,
86
- ...(drift ? { acceptControlDrift: true } : {}),
87
- ...(raiseCeilingUSD ? { raiseCeilingUSD } : {}),
88
- ...(extendRuntime ? { extendRuntime: true } : {}),
99
+ const extend = /runtime cap/i.test(lines) ? " --extend-runtime" : ""
100
+ const flags = `${drift ? " --accept-drift" : ""}${raise}${extend}`
101
+ await context.ui.dialog.alert({
102
+ title: preview.title,
103
+ message: `${preview.lines.join("\n")}\n\nRun \`es factory resume${flags}\` at a terminal with your passphrase.`,
89
104
  })
90
- toast(result.message, "success")
91
105
  })
92
106
 
93
107
  const lspInstall = guarded(async (input) => {