@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 +42 -53
- package/package.json +2 -2
- package/src/agents.ts +3 -0
- package/src/policy.ts +37 -32
- package/src/research.ts +101 -39
- package/src/rpc-def.ts +9 -31
- package/src/rpc.ts +10 -76
- package/src/runtime.ts +42 -37
- package/src/server.ts +12 -3
- package/src/tools.ts +1 -0
- package/src/tui.tsx +32 -18
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
|
|
661
|
+
const preview = await call("previewApproval", {
|
|
677
662
|
stage
|
|
678
|
-
})
|
|
663
|
+
});
|
|
664
|
+
const token = await confirm(preview, "Approve");
|
|
679
665
|
if (!token)
|
|
680
666
|
return;
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
|
676
|
+
const preview = await call("previewTrust");
|
|
677
|
+
const token = await confirm(preview, "Trust");
|
|
689
678
|
if (!token)
|
|
690
679
|
return;
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
|
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
|
-
|
|
702
|
+
if (raised)
|
|
703
|
+
raise = ` --raise-ceiling ${raised}`;
|
|
709
704
|
}
|
|
710
|
-
const
|
|
711
|
-
const
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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.
|
|
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.
|
|
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)
|
|
4
|
-
//
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
3
|
-
//
|
|
4
|
-
|
|
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}
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
51
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
2
|
-
// TUI
|
|
3
|
-
//
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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,
|
|
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
|
-
/**
|
|
16
|
-
export interface
|
|
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
|
-
/**
|
|
20
|
-
readonly
|
|
21
|
-
|
|
22
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 {
|
|
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
|
-
|
|
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",
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
67
|
+
const preview: Preview = await call("previewApproval", { stage })
|
|
68
|
+
const token = await confirm(preview, "Approve")
|
|
62
69
|
if (!token) return
|
|
63
|
-
|
|
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
|
|
77
|
+
const preview: Preview = await call("previewTrust")
|
|
78
|
+
const token = await confirm(preview, "Trust")
|
|
68
79
|
if (!token) return
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
+
if (raised) raise = ` --raise-ceiling ${raised}`
|
|
81
98
|
}
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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) => {
|