@heretek-ai/epistemic-swarm 1.1.4 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tui.js +493 -247
- package/package.json +7 -4
- package/src/agents.ts +77 -6
- package/src/approve-tui.ts +124 -0
- package/src/hooks.ts +15 -3
- package/src/masked-input.tsx +123 -0
- package/src/panels.tsx +7 -2
- package/src/policy.ts +28 -4
- package/src/pr.ts +17 -13
- package/src/research.ts +9 -2
- package/src/rpc-def.ts +30 -9
- package/src/rpc.ts +40 -12
- package/src/runtime.ts +22 -5
- package/src/seats.ts +28 -1
- package/src/server.ts +384 -245
- package/src/tools.ts +5 -1
- package/src/tui.tsx +61 -8
package/package.json
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heretek-ai/epistemic-swarm",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.3.0",
|
|
4
|
+
"esRelease": {
|
|
5
|
+
"order": 3
|
|
6
|
+
},
|
|
4
7
|
"description": "Epistemic Swarm: an AI build factory for OpenCode v2 — grill, research, spec, build, QA and release, gated mechanically.",
|
|
5
8
|
"type": "module",
|
|
6
9
|
"license": "Apache-2.0",
|
|
@@ -41,7 +44,7 @@
|
|
|
41
44
|
"test": "bun test"
|
|
42
45
|
},
|
|
43
46
|
"dependencies": {
|
|
44
|
-
"@heretek-ai/es-core": "
|
|
47
|
+
"@heretek-ai/es-core": "2.3.0",
|
|
45
48
|
"@opencode/plugin": "2.0.24"
|
|
46
49
|
},
|
|
47
50
|
"devDependencies": {
|
|
@@ -49,8 +52,8 @@
|
|
|
49
52
|
"@babel/preset-typescript": "7.27.1",
|
|
50
53
|
"@heretek-ai/es-testkit": "1.0.0",
|
|
51
54
|
"@opencode/sdk": "2.0.24",
|
|
52
|
-
"@opentui/core": "0.5.
|
|
53
|
-
"@opentui/solid": "0.5.
|
|
55
|
+
"@opentui/core": "0.5.17",
|
|
56
|
+
"@opentui/solid": "0.5.17",
|
|
54
57
|
"babel-preset-solid": "1.9.12",
|
|
55
58
|
"solid-js": "1.9.15"
|
|
56
59
|
},
|
package/src/agents.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// we add our own agents and never edit built-ins, default_agent or models.
|
|
3
3
|
// Per-agent tool/skill/MCP scoping is expressed as wildcard-deny permission
|
|
4
4
|
// rules (proven in M0: denied tools, MCP tools and skills are not advertised).
|
|
5
|
-
import { AGENTS, type AgentSpec, ALL_ES_TOOLS, loadPrompt } from "@heretek-ai/es-core"
|
|
5
|
+
import { AGENTS, type AgentSpec, ALL_ES_TOOLS, loadPrompt, parseModelRef } from "@heretek-ai/es-core"
|
|
6
6
|
import type { PluginOptions } from "./runtime.ts"
|
|
7
7
|
|
|
8
8
|
export interface CompiledAgent {
|
|
@@ -47,11 +47,82 @@ export function permissionRules(spec: AgentSpec, mcpServers: readonly string[]):
|
|
|
47
47
|
return rules
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
function modelFor(spec: AgentSpec, options: PluginOptions) {
|
|
51
|
-
const ref = options.models?.agents?.[spec.id] ?? options.models?.[spec.tier]
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
export function modelFor(spec: AgentSpec, options: PluginOptions) {
|
|
51
|
+
const ref = options.models?.agents?.[spec.id] ?? roleModel(spec.id, options) ?? options.models?.[spec.tier]
|
|
52
|
+
if (!ref) return undefined
|
|
53
|
+
return parseModelRef(ref)
|
|
54
|
+
}
|
|
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
|
+
|
|
77
|
+
/** A configured model ref the host does not have: the seat is refused at launch (#96). */
|
|
78
|
+
export interface ModelProblem {
|
|
79
|
+
readonly agent: string
|
|
80
|
+
/** The config key to fix: models.<tier> or models.agents.<id>. */
|
|
81
|
+
readonly key: string
|
|
82
|
+
readonly ref: string
|
|
83
|
+
/** Remediation for the user (es config set is human-only). */
|
|
84
|
+
readonly message: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Remediation text for an unusable seat model (es config set is human-only). */
|
|
88
|
+
export function modelProblemMessage(agent: string, key: string, ref: string, wellFormed: boolean): string {
|
|
89
|
+
return (
|
|
90
|
+
`Seat "${agent}" cannot launch: its model "${ref}" (${key}) is ` +
|
|
91
|
+
(wellFormed ? "not on this host." : 'malformed (want "provider/model").') +
|
|
92
|
+
` Set ${key} to "provider/model" from the host model list with \`es config set\`, or unset it to use the session default.`
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Every configured ref, per seat, that `exists` cannot find on the host. */
|
|
97
|
+
export function modelProblems(
|
|
98
|
+
options: PluginOptions,
|
|
99
|
+
specs: readonly AgentSpec[],
|
|
100
|
+
exists: (providerID: string, modelID: string) => boolean,
|
|
101
|
+
): ModelProblem[] {
|
|
102
|
+
const problems: ModelProblem[] = []
|
|
103
|
+
for (const spec of specs) {
|
|
104
|
+
const ref = options.models?.agents?.[spec.id] ?? roleModel(spec.id, options) ?? options.models?.[spec.tier]
|
|
105
|
+
if (ref === undefined) continue
|
|
106
|
+
const key = modelKey(spec, options)
|
|
107
|
+
const parsed = parseModelRef(ref)
|
|
108
|
+
if (parsed === undefined || !exists(parsed.providerID, parsed.id))
|
|
109
|
+
problems.push({
|
|
110
|
+
agent: spec.id,
|
|
111
|
+
key,
|
|
112
|
+
ref,
|
|
113
|
+
message: modelProblemMessage(spec.id, key, ref, parsed !== undefined),
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
return problems
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** es_status lines for the runtime's model problems (empty when healthy). */
|
|
120
|
+
export function modelProblemLines(problems: ReadonlyMap<string, ModelProblem>): string[] {
|
|
121
|
+
if (problems.size === 0) return []
|
|
122
|
+
return [
|
|
123
|
+
`Model problems (${problems.size}): configured models missing on this host — those seats refuse to launch.`,
|
|
124
|
+
...[...problems.values()].map((problem) => `- ${problem.message}`),
|
|
125
|
+
]
|
|
55
126
|
}
|
|
56
127
|
|
|
57
128
|
export async function compileAgents(options: PluginOptions, mcpServers: readonly string[]): Promise<CompiledAgent[]> {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// In-TUI approval signing (#119, ADR 0002). The TUI process runs on the
|
|
2
|
+
// human's machine, so it unlocks the sealed key and signs in-process through
|
|
3
|
+
// the core approveStage service — no passphrase and no approval ever cross
|
|
4
|
+
// RPC (the #49 property still holds: there is no approve RPC). Trust, waive
|
|
5
|
+
// and resume keep routing to the terminal (ADR 0002 I7).
|
|
6
|
+
import { userInfo } from "node:os"
|
|
7
|
+
import {
|
|
8
|
+
type Approval,
|
|
9
|
+
type ApprovalStage,
|
|
10
|
+
approveStage,
|
|
11
|
+
checkApprovalAttempts,
|
|
12
|
+
stateDir as defaultStateDir,
|
|
13
|
+
exists,
|
|
14
|
+
factoryLayout,
|
|
15
|
+
HumanKeyError,
|
|
16
|
+
hasHumanKey,
|
|
17
|
+
noteApprovalSuccess,
|
|
18
|
+
noteWrongPassphrase,
|
|
19
|
+
unlockHumanKey,
|
|
20
|
+
zeroCodepoints,
|
|
21
|
+
} from "@heretek-ai/es-core"
|
|
22
|
+
|
|
23
|
+
export interface ApproveTuiInput {
|
|
24
|
+
readonly root: string
|
|
25
|
+
readonly stage: ApprovalStage
|
|
26
|
+
/** ADR 0002 I3: hashJson of the previewed subject, from previewApproval. */
|
|
27
|
+
readonly expectedSubjectHash: string
|
|
28
|
+
readonly approvedBy?: string
|
|
29
|
+
readonly stateDir?: string
|
|
30
|
+
/**
|
|
31
|
+
* ADR 0002 I3: the preview ticket redeemed exactly once before signing
|
|
32
|
+
* (single-use + 120 s TTL). Production passes the token from
|
|
33
|
+
* previewApproval and a redeem that calls the validate-only
|
|
34
|
+
* `redeemApproval` RPC (it signs nothing); tests script the ticket map.
|
|
35
|
+
*/
|
|
36
|
+
readonly previewToken?: string
|
|
37
|
+
/** `issuedAt` from previewApproval; a preview older than 120 s is refused. */
|
|
38
|
+
readonly previewIssuedAt?: number
|
|
39
|
+
/** Validate-only redeem of the preview token (signs nothing). */
|
|
40
|
+
readonly redeemPreview?: (token: string) => Promise<void>
|
|
41
|
+
/** Clock override (tests). */
|
|
42
|
+
readonly now?: () => number
|
|
43
|
+
/**
|
|
44
|
+
* The masked passphrase dialog (production: readMaskedPassphrase over
|
|
45
|
+
* dialog.show; tests: a scripted function). Resolves to the entered code
|
|
46
|
+
* points, or undefined on cancel. The array is zeroed by the caller.
|
|
47
|
+
*/
|
|
48
|
+
readonly readPassphrase: (title: string) => Promise<string[] | undefined>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type ApproveTuiOutcome =
|
|
52
|
+
| { readonly ok: true; readonly record: Approval }
|
|
53
|
+
| { readonly ok: false; readonly fallback: true; readonly reason: string }
|
|
54
|
+
| { readonly ok: false; readonly locked: true; readonly retryAfterSec: number }
|
|
55
|
+
| { readonly ok: false; readonly cancelled: true }
|
|
56
|
+
|
|
57
|
+
const TERMINAL_FALLBACK = (stage: ApprovalStage) =>
|
|
58
|
+
`Run \`es approve ${stage}\` at a terminal with your passphrase (approvals need the sealed human key).`
|
|
59
|
+
|
|
60
|
+
/** ADR 0002 I3: a preview older than this is refused instead of signed. */
|
|
61
|
+
export const APPROVAL_PREVIEW_TTL_MS = 120_000
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Complete a frontier/spec approval inside the TUI: throttle check, masked
|
|
65
|
+
* passphrase loop, unlock, preview-ticket redeem, approveStage with channel
|
|
66
|
+
* "tui" and the preview's subject hash, key destruction. Returns a fallback when this TUI cannot
|
|
67
|
+
* sign (remote attach without the project root or sealed key): the caller
|
|
68
|
+
* shows the terminal alert. ApprovalError (subject changed since preview),
|
|
69
|
+
* an expired or already-used preview, and unexpected failures propagate to
|
|
70
|
+
* the caller's guard.
|
|
71
|
+
*/
|
|
72
|
+
export async function approveInTui(input: ApproveTuiInput): Promise<ApproveTuiOutcome> {
|
|
73
|
+
const dir = input.stateDir ?? defaultStateDir()
|
|
74
|
+
// A TUI that cannot see the project root and the sealed key (a remote
|
|
75
|
+
// attach) keeps the "approve in a terminal" alert instead of signing
|
|
76
|
+
// anywhere else.
|
|
77
|
+
if (!(await exists(factoryLayout(input.root).dir)) || !(await hasHumanKey(dir)))
|
|
78
|
+
return { ok: false, fallback: true, reason: TERMINAL_FALLBACK(input.stage) }
|
|
79
|
+
|
|
80
|
+
const gate = await checkApprovalAttempts(dir)
|
|
81
|
+
if (!gate.allowed) return { ok: false, locked: true, retryAfterSec: gate.retryAfterSec ?? 0 }
|
|
82
|
+
|
|
83
|
+
const title = `Approve ${input.stage} as ${input.approvedBy ?? userInfo().username}`
|
|
84
|
+
for (;;) {
|
|
85
|
+
const codepoints = await input.readPassphrase(title)
|
|
86
|
+
if (codepoints === undefined) return { ok: false, cancelled: true }
|
|
87
|
+
// The string cannot be zeroed in JS (same as the CLI's echo-off input):
|
|
88
|
+
// it lives in this local only, and the array is zeroed at once.
|
|
89
|
+
const passphrase = codepoints.join("")
|
|
90
|
+
zeroCodepoints(codepoints)
|
|
91
|
+
try {
|
|
92
|
+
const signer = await unlockHumanKey(passphrase, dir)
|
|
93
|
+
try {
|
|
94
|
+
// ADR 0002 I3: the preview ticket is redeemed exactly once, after
|
|
95
|
+
// the unlock (a typo must not consume it) and before anything is
|
|
96
|
+
// signed. An expired or already-used preview refuses here.
|
|
97
|
+
const now = input.now ?? Date.now
|
|
98
|
+
if (input.previewIssuedAt !== undefined && now() - input.previewIssuedAt > APPROVAL_PREVIEW_TTL_MS)
|
|
99
|
+
throw new Error("The approval preview expired (> 120 s old); preview again.")
|
|
100
|
+
if (input.previewToken !== undefined && input.redeemPreview !== undefined)
|
|
101
|
+
await input.redeemPreview(input.previewToken)
|
|
102
|
+
await noteApprovalSuccess(dir)
|
|
103
|
+
const { record } = await approveStage(input.root, {
|
|
104
|
+
stage: input.stage,
|
|
105
|
+
channel: "tui",
|
|
106
|
+
signer,
|
|
107
|
+
...(input.approvedBy ? { approvedBy: input.approvedBy } : {}),
|
|
108
|
+
stateDir: dir,
|
|
109
|
+
expectedSubjectHash: input.expectedSubjectHash,
|
|
110
|
+
})
|
|
111
|
+
return { ok: true, record }
|
|
112
|
+
} finally {
|
|
113
|
+
signer.destroy()
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error instanceof HumanKeyError && error.message === "wrong passphrase") {
|
|
117
|
+
const check = await noteWrongPassphrase({ dir, root: input.root, stage: input.stage, channel: "tui" })
|
|
118
|
+
if (!check.allowed) return { ok: false, locked: true, retryAfterSec: check.retryAfterSec ?? 0 }
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
throw error
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
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
|
|
@@ -15,6 +18,7 @@ import {
|
|
|
15
18
|
bwrapAvailable,
|
|
16
19
|
fromOpenCode,
|
|
17
20
|
HookEngine,
|
|
21
|
+
parseModelRef,
|
|
18
22
|
permissionActionTool,
|
|
19
23
|
shellQuote,
|
|
20
24
|
toOpenCodeInput,
|
|
@@ -100,8 +104,7 @@ export async function createHookBridge(
|
|
|
100
104
|
root: runtime.root,
|
|
101
105
|
stateDir: runtime.stateDir,
|
|
102
106
|
generate: async (prompt, model) => {
|
|
103
|
-
const
|
|
104
|
-
const ref = model && slash > 0 ? { providerID: model.slice(0, slash), id: model.slice(slash + 1) } : undefined
|
|
107
|
+
const ref = model ? parseModelRef(model) : undefined
|
|
105
108
|
return (await ctx.generate.text({ prompt, ...(ref ? { model: ref } : {}) })).text
|
|
106
109
|
},
|
|
107
110
|
})
|
|
@@ -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) {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Masked passphrase input for the OpenCode TUI (#119, from the #118 spike
|
|
2
|
+
// verdict GO). A custom dialog.show component: keystrokes are captured with
|
|
3
|
+
// no echo and plaintext lives only in a closure-scoped array (never in
|
|
4
|
+
// reactive state), zeroed on submit and on cancel. Only the bullet count is
|
|
5
|
+
// reactive (screen-equivalent: the bullets are visible anyway).
|
|
6
|
+
|
|
7
|
+
import { zeroCodepoints } from "@heretek-ai/es-core"
|
|
8
|
+
import { useKeyboard, usePaste } from "@opentui/solid"
|
|
9
|
+
import { createSignal, onCleanup } from "solid-js"
|
|
10
|
+
|
|
11
|
+
export interface MaskedInputOptions {
|
|
12
|
+
readonly title: string
|
|
13
|
+
readonly onSubmit: (codepoints: string[]) => void
|
|
14
|
+
readonly onCancel: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function MaskedInput(props: MaskedInputOptions) {
|
|
18
|
+
// The ONLY place plaintext lives: a plain closure array, not reactive state.
|
|
19
|
+
const codepoints: string[] = []
|
|
20
|
+
const [count, setCount] = createSignal(0)
|
|
21
|
+
const [cancelled, setCancelled] = createSignal(false)
|
|
22
|
+
let done = false
|
|
23
|
+
|
|
24
|
+
const finish = (submit: boolean) => {
|
|
25
|
+
if (done) return
|
|
26
|
+
done = true
|
|
27
|
+
try {
|
|
28
|
+
if (submit) props.onSubmit(codepoints)
|
|
29
|
+
else props.onCancel()
|
|
30
|
+
} finally {
|
|
31
|
+
zeroCodepoints(codepoints)
|
|
32
|
+
setCount(0)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
onCleanup(() => {
|
|
36
|
+
zeroCodepoints(codepoints)
|
|
37
|
+
setCount(0)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
useKeyboard((key) => {
|
|
41
|
+
if (done) return
|
|
42
|
+
if (key.name === "return" || key.name === "enter") {
|
|
43
|
+
key.preventDefault()
|
|
44
|
+
key.stopPropagation()
|
|
45
|
+
finish(true)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (key.name === "escape" || (key.name === "c" && key.ctrl)) {
|
|
49
|
+
key.preventDefault()
|
|
50
|
+
key.stopPropagation()
|
|
51
|
+
setCancelled(true)
|
|
52
|
+
finish(false)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
if (key.name === "backspace") {
|
|
56
|
+
key.preventDefault()
|
|
57
|
+
key.stopPropagation()
|
|
58
|
+
if (codepoints.length > 0) {
|
|
59
|
+
codepoints.pop()
|
|
60
|
+
setCount(codepoints.length)
|
|
61
|
+
}
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
// Printable characters only; everything else is consumed so it never
|
|
65
|
+
// reaches the TUI keymap layer underneath the dialog.
|
|
66
|
+
if (!key.ctrl && !key.meta && key.sequence && [...key.sequence].length === 1) {
|
|
67
|
+
key.preventDefault()
|
|
68
|
+
key.stopPropagation()
|
|
69
|
+
for (const point of key.sequence) codepoints.push(point)
|
|
70
|
+
setCount(codepoints.length)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
key.preventDefault()
|
|
74
|
+
key.stopPropagation()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
usePaste((event) => {
|
|
78
|
+
if (done) return
|
|
79
|
+
event.preventDefault()
|
|
80
|
+
event.stopPropagation()
|
|
81
|
+
// Bracketed paste arrives as one event: accept it atomically, strip newlines.
|
|
82
|
+
const text = new TextDecoder().decode(event.bytes).replace(/[\r\n]+/g, "")
|
|
83
|
+
if (text) {
|
|
84
|
+
for (const point of text) codepoints.push(point)
|
|
85
|
+
setCount(codepoints.length)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<box flexDirection="column" padding={1} gap={1}>
|
|
91
|
+
<text>{props.title}</text>
|
|
92
|
+
<text>{cancelled() ? "Cancelled." : `Passphrase: ${"•".repeat(count())}`}</text>
|
|
93
|
+
<text>Enter submits · Esc cancels · paste accepted as one unit</text>
|
|
94
|
+
</box>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The dialog surface the masked input needs (structural: scriptable in tests). */
|
|
99
|
+
export interface MaskedDialog {
|
|
100
|
+
show(render: () => unknown, onClose?: () => void): void
|
|
101
|
+
clear(): void
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Open the masked dialog and resolve to the entered code points, or
|
|
106
|
+
* undefined on cancel. The caller copies, uses and zeroes them; the
|
|
107
|
+
* component zeroes its own buffer on submit/cancel/unmount.
|
|
108
|
+
*/
|
|
109
|
+
export function readMaskedPassphrase(dialog: MaskedDialog, title: string): Promise<string[] | undefined> {
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
let settled = false
|
|
112
|
+
const done = (codepoints: string[] | undefined) => {
|
|
113
|
+
if (settled) return
|
|
114
|
+
settled = true
|
|
115
|
+
dialog.clear()
|
|
116
|
+
resolve(codepoints === undefined ? undefined : [...codepoints])
|
|
117
|
+
}
|
|
118
|
+
dialog.show(
|
|
119
|
+
() => <MaskedInput title={title} onSubmit={(codepoints) => done(codepoints)} onCancel={() => done(undefined)} />,
|
|
120
|
+
() => done(undefined),
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
}
|
package/src/panels.tsx
CHANGED
|
@@ -127,8 +127,8 @@ const FactoryPanel = (props: PanelProps) => {
|
|
|
127
127
|
</Show>
|
|
128
128
|
<Show when={data().pending.length}>
|
|
129
129
|
<text>
|
|
130
|
-
Waiting on you: approve {data().pending.join(", ")}
|
|
131
|
-
|
|
130
|
+
Waiting on you: approve {data().pending.join(", ")} with /es-approve (or `es approve {data().pending[0]}
|
|
131
|
+
`)
|
|
132
132
|
</text>
|
|
133
133
|
</Show>
|
|
134
134
|
<Show when={data().seats.length}>
|
|
@@ -255,6 +255,11 @@ const BrainstormPanel = (props: PanelProps) => {
|
|
|
255
255
|
{(data: () => any) => (
|
|
256
256
|
<Show when={data().active} fallback={<text>No brainstorm in .factory/brainstorm yet. Run /brainstorm.</text>}>
|
|
257
257
|
<text>Brief: {data().brief}</text>
|
|
258
|
+
<Show when={data().runs?.length > 1}>
|
|
259
|
+
<text>
|
|
260
|
+
Run {data().run} ({data().runs.length} runs: {data().runs.join(", ")} — `es brainstorm show [run]`)
|
|
261
|
+
</text>
|
|
262
|
+
</Show>
|
|
258
263
|
<text>
|
|
259
264
|
{data().ideas} idea(s) · {data().duplicates} duplicate(s) · {data().scored} scored ·{" "}
|
|
260
265
|
{data().complete ? "complete" : "in progress"}
|
package/src/policy.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
import { Error as ToolError } from "@opencode/plugin/promise/tool"
|
|
23
23
|
import { HOST_WEB_TOOLS } from "./agents.ts"
|
|
24
24
|
import type { Runtime } from "./runtime.ts"
|
|
25
|
-
import type
|
|
25
|
+
import { type SeatTracker, seatModelRefusal } from "./seats.ts"
|
|
26
26
|
|
|
27
27
|
const WRITE_TOOLS = new Set(["write", "edit"])
|
|
28
28
|
|
|
@@ -74,16 +74,35 @@ export function createPolicyHooks(runtime: Runtime, seats?: SeatTracker) {
|
|
|
74
74
|
const before = async (event: { tool: string; agent: string; id: string; sessionID?: string; input: unknown }) => {
|
|
75
75
|
const seat = seatOf(event.agent)
|
|
76
76
|
const input = (event.input ?? {}) as Record<string, any>
|
|
77
|
+
// Fail-closed models (#96): a seat whose own configured model is missing
|
|
78
|
+
// on the host is refused on every tool call, with remediation. Primary
|
|
79
|
+
// seats are never launched through `subagent`, so the launch guard below
|
|
80
|
+
// never sees them; without this they would silently run on the default.
|
|
81
|
+
const ownProblem = runtime.modelProblems?.get(event.agent)
|
|
82
|
+
if (ownProblem) deny(ownProblem.message)
|
|
83
|
+
// Fail-closed models (#96): whoever launches a seat with an unusable
|
|
84
|
+
// model is refused, seat or user agent alike.
|
|
85
|
+
if (event.tool === "subagent" && typeof input.agent === "string") {
|
|
86
|
+
const problem = runtime.modelProblems?.get(input.agent)
|
|
87
|
+
if (problem) deny(problem.message)
|
|
88
|
+
// Heterogeneous models (#170): a programmer/QA seat that would share
|
|
89
|
+
// weights with the other side never launches.
|
|
90
|
+
const refusal = seatModelRefusal(input.agent, runtime.config?.models)
|
|
91
|
+
if (refusal) deny(`Seat "${input.agent}" cannot launch: ${refusal}`)
|
|
92
|
+
}
|
|
77
93
|
if (seat && event.tool === "subagent") guardSeatLaunch({ agent: event.agent, input }, seats)
|
|
78
94
|
if (seat && event.sessionID) await seats?.onTool(event.sessionID, event.agent, event.tool)
|
|
79
|
-
|
|
95
|
+
// STOP halts seats, es_* tools, and seat launches whoever fires them (#227):
|
|
96
|
+
// a user agent must not route around the kill-switch via `subagent`.
|
|
97
|
+
if (seat || event.tool.startsWith("es_") || event.tool === "subagent") {
|
|
80
98
|
const stop = await checkStopFile(runtime.root)
|
|
81
99
|
if (stop.stopped)
|
|
82
100
|
deny(`The factory is stopped (.factory/STOP: ${stop.reason}). A human must remove it and resume.`)
|
|
83
101
|
}
|
|
84
102
|
// A halted run (spend ceiling, runtime cap, drift, STOP) stops every seat
|
|
85
103
|
// tool except es_status, so no seat keeps spending through its own es_* tools.
|
|
86
|
-
|
|
104
|
+
// Seat launches count as seat tools whoever fires them (#227).
|
|
105
|
+
if ((seat || event.tool === "subagent") && event.tool !== "es_status") {
|
|
87
106
|
const state = await runtime.factory.read().catch(() => undefined)
|
|
88
107
|
if (state?.stage === "HALTED")
|
|
89
108
|
deny(`The factory is halted: ${state.halt?.reason}. Stop and report; a human must resume.`)
|
|
@@ -162,7 +181,10 @@ export function createPolicyHooks(runtime: Runtime, seats?: SeatTracker) {
|
|
|
162
181
|
? patchTargets(input.patchText)
|
|
163
182
|
: []
|
|
164
183
|
if (!files.length) return
|
|
165
|
-
|
|
184
|
+
// The post-edit diagnostics are a language-server channel (#277.2): seats
|
|
185
|
+
// scoped to `lsp: "none"` never get them (their tool rules already hide
|
|
186
|
+
// `lsp_*`). Navigating seats and the user's own agents keep the channel.
|
|
187
|
+
if (runtime.options.lspAfterEdit !== false && agentSpec(event.agent)?.lsp !== "none") {
|
|
166
188
|
const lines: string[] = []
|
|
167
189
|
for (const file of files) {
|
|
168
190
|
const absolute = path.resolve(runtime.root, file)
|
|
@@ -210,6 +232,8 @@ export function createPolicyHooks(runtime: Runtime, seats?: SeatTracker) {
|
|
|
210
232
|
}) => {
|
|
211
233
|
const seat = seatOf(event.agent)
|
|
212
234
|
const context = await runtime.policy()
|
|
235
|
+
// The host's patch tool asks for `edit` too, once, with every target of
|
|
236
|
+
// the patch as a resource: there is no `patch` action to branch on (#220).
|
|
213
237
|
if (event.action === "edit" || event.action === "read") {
|
|
214
238
|
const decisions = event.resources.map((resource) =>
|
|
215
239
|
event.action === "edit"
|
package/src/pr.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
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
|
-
import { git, run } from "@heretek-ai/es-core"
|
|
9
|
+
import { buildPrArgs, git, run } from "@heretek-ai/es-core"
|
|
6
10
|
|
|
7
11
|
export const ghPrOpener: PrOpener = {
|
|
8
12
|
id: "gh",
|
|
@@ -16,17 +20,17 @@ export const ghPrOpener: PrOpener = {
|
|
|
16
20
|
const remote = await git(root, ["remote", "get-url", "origin"], { allowFail: true })
|
|
17
21
|
return remote.code === 0
|
|
18
22
|
},
|
|
19
|
-
async open({ root, branch, base, title, body }) {
|
|
23
|
+
async open({ root, branch, base, title, body, preset }) {
|
|
20
24
|
if (branch === base || !branch.startsWith("factory/")) throw new Error(`refusing to push "${branch}"`)
|
|
25
|
+
// One argv builder for every opener; it refuses an off-base preset run
|
|
26
|
+
// (e.g. --base main) before anything is pushed.
|
|
27
|
+
const argv = buildPrArgs({ branch, base, title, body, preset })
|
|
21
28
|
await git(root, ["push", "--set-upstream", "origin", `${branch}:${branch}`])
|
|
22
|
-
const created = await run(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
passEnv: ["GH_TOKEN", "GITHUB_TOKEN", "GH_HOST"],
|
|
28
|
-
},
|
|
29
|
-
)
|
|
29
|
+
const created = await run(argv, {
|
|
30
|
+
cwd: root,
|
|
31
|
+
timeoutMs: 60_000,
|
|
32
|
+
passEnv: ["GH_TOKEN", "GITHUB_TOKEN", "GH_HOST"],
|
|
33
|
+
})
|
|
30
34
|
const url = created.stdout
|
|
31
35
|
.trim()
|
|
32
36
|
.split("\n")
|
package/src/research.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// .factory/ directory: the user's own agents get plain results, and no
|
|
5
5
|
// project gains a .factory/ because someone searched the web.
|
|
6
6
|
import { stat } from "node:fs/promises"
|
|
7
|
-
import { factoryLayout, researchCache, seatOf, selectProvider } from "@heretek-ai/es-core"
|
|
7
|
+
import { factoryLayout, researchCache, SEARCH_PROVIDERS, seatOf, selectProvider } from "@heretek-ai/es-core"
|
|
8
8
|
import type { Runtime } from "./runtime.ts"
|
|
9
9
|
|
|
10
10
|
interface SearchHit {
|
|
@@ -52,7 +52,14 @@ export function registerWebsearch(
|
|
|
52
52
|
) {
|
|
53
53
|
const searxng = runtime.config.research?.searxngUrl
|
|
54
54
|
const env = searxng ? { ...process.env, SEARXNG_URL: searxng } : process.env
|
|
55
|
-
|
|
55
|
+
// A legacy searchProvider pin keeps its exact behaviour; otherwise the
|
|
56
|
+
// first configured backend with a host search provider wins (#106).
|
|
57
|
+
const ordered = runtime.config.research?.backends
|
|
58
|
+
?.map((id) => SEARCH_PROVIDERS.find((provider) => provider.id === id))
|
|
59
|
+
.find((provider) => provider?.available(env))
|
|
60
|
+
const provider = runtime.options.searchProvider
|
|
61
|
+
? selectProvider(runtime.options.searchProvider, env)
|
|
62
|
+
: (ordered ?? selectProvider(undefined, env))
|
|
56
63
|
if (!provider) return
|
|
57
64
|
editor.add({
|
|
58
65
|
id: "epistemic-swarm",
|
package/src/rpc-def.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// RPC contract between the server plugin and the TUI. Previews are shown in
|
|
2
|
-
// the TUI;
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// the TUI; approvals complete there through the masked dialog and in-process
|
|
3
|
+
// signing (ADR 0002), while trust and resume sign at a terminal with the
|
|
4
|
+
// passphrase (`es trust`, `es factory resume`). No approve, trust or resume
|
|
5
|
+
// call ever crosses RPC: there is no mutating approval RPC for an agent tool
|
|
6
|
+
// or a stolen server password to call. Only lspInstall still mutates via RPC.
|
|
5
7
|
import { Rpc } from "@opencode/plugin/rpc"
|
|
6
8
|
|
|
7
9
|
const obj = (properties: Record<string, unknown>, required: string[] = []) => ({
|
|
@@ -12,13 +14,22 @@ const obj = (properties: Record<string, unknown>, required: string[] = []) => ({
|
|
|
12
14
|
})
|
|
13
15
|
const str = { type: "string" }
|
|
14
16
|
const strArray = { type: "array", items: str }
|
|
17
|
+
const num = { type: "number" }
|
|
15
18
|
|
|
16
|
-
const Preview = obj(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
const Preview = obj(
|
|
20
|
+
{
|
|
21
|
+
ok: { type: "boolean" },
|
|
22
|
+
title: str,
|
|
23
|
+
lines: strArray,
|
|
24
|
+
problems: strArray,
|
|
25
|
+
token: str,
|
|
26
|
+
/** ADR 0002 I3: hashJson of the previewed approval subject (present on approval previews). */
|
|
27
|
+
subjectHash: str,
|
|
28
|
+
/** ADR 0002 I3: when the preview was issued (present on approval previews). */
|
|
29
|
+
issuedAt: num,
|
|
30
|
+
},
|
|
31
|
+
["ok", "title", "lines", "problems"],
|
|
32
|
+
)
|
|
22
33
|
|
|
23
34
|
export const EsRpc = Rpc.define({
|
|
24
35
|
id: "epistemic-swarm",
|
|
@@ -42,6 +53,16 @@ export const EsRpc = Rpc.define({
|
|
|
42
53
|
input: obj({ stage: { type: "string", enum: ["frontier", "spec"] } }, ["stage"]),
|
|
43
54
|
output: Preview,
|
|
44
55
|
},
|
|
56
|
+
/** Validate-only redeem of an approval preview ticket: single-use + TTL, signs nothing. */
|
|
57
|
+
redeemApproval: {
|
|
58
|
+
input: obj({ stage: { type: "string", enum: ["frontier", "spec"] }, token: str, subjectHash: str }, [
|
|
59
|
+
"stage",
|
|
60
|
+
"token",
|
|
61
|
+
"subjectHash",
|
|
62
|
+
]),
|
|
63
|
+
output: obj({ ok: { type: "boolean" } }, ["ok"]),
|
|
64
|
+
errors: { refused: obj({ reason: str }, ["reason"]) },
|
|
65
|
+
},
|
|
45
66
|
previewTrust: { input: obj({}), output: Preview },
|
|
46
67
|
previewResume: { input: obj({}), output: Preview },
|
|
47
68
|
previewLspInstall: { input: obj({ id: str }, ["id"]), output: Preview },
|