@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 +17 -3
- package/package.json +2 -2
- package/src/agents.ts +24 -3
- package/src/approve-tui.ts +22 -4
- package/src/hooks.ts +13 -1
- package/src/policy.ts +39 -4
- package/src/pr.ts +7 -3
- package/src/rpc.ts +69 -2
- package/src/runtime.ts +9 -4
- package/src/seats.ts +28 -1
- package/src/server.ts +356 -297
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
|
|
30
|
-
const
|
|
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
|
|
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": "
|
|
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": "
|
|
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 =
|
|
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({
|
package/src/approve-tui.ts
CHANGED
|
@@ -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
|
|
88
|
+
if (!gate.allowed) return locked(gate.retryAfterSec ?? 0)
|
|
82
89
|
|
|
83
|
-
const
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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 {
|
|
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
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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)
|