@heretek-ai/epistemic-swarm 1.1.3 → 1.6.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 +8 -5
- package/src/agents.ts +55 -5
- package/src/approve-tui.ts +124 -0
- package/src/hooks.ts +2 -2
- package/src/masked-input.tsx +123 -0
- package/src/panels.tsx +7 -2
- package/src/policy.ts +12 -0
- package/src/pr.ts +10 -10
- package/src/research.ts +9 -2
- package/src/rpc-def.ts +30 -9
- package/src/rpc.ts +40 -12
- package/src/runtime.ts +13 -1
- package/src/server.ts +81 -1
- 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": "1.
|
|
3
|
+
"version": "1.6.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,16 +44,16 @@
|
|
|
41
44
|
"test": "bun test"
|
|
42
45
|
},
|
|
43
46
|
"dependencies": {
|
|
44
|
-
"@heretek-ai/es-core": "1.
|
|
47
|
+
"@heretek-ai/es-core": "1.6.0",
|
|
45
48
|
"@opencode/plugin": "2.0.24"
|
|
46
49
|
},
|
|
47
50
|
"devDependencies": {
|
|
48
|
-
"@babel/core": "7.
|
|
51
|
+
"@babel/core": "7.29.6",
|
|
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,61 @@ export function permissionRules(spec: AgentSpec, mcpServers: readonly string[]):
|
|
|
47
47
|
return rules
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
function modelFor(spec: AgentSpec, options: PluginOptions) {
|
|
50
|
+
export function modelFor(spec: AgentSpec, options: PluginOptions) {
|
|
51
51
|
const ref = options.models?.agents?.[spec.id] ?? options.models?.[spec.tier]
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
if (!ref) return undefined
|
|
53
|
+
return parseModelRef(ref)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A configured model ref the host does not have: the seat is refused at launch (#96). */
|
|
57
|
+
export interface ModelProblem {
|
|
58
|
+
readonly agent: string
|
|
59
|
+
/** The config key to fix: models.<tier> or models.agents.<id>. */
|
|
60
|
+
readonly key: string
|
|
61
|
+
readonly ref: string
|
|
62
|
+
/** Remediation for the user (es config set is human-only). */
|
|
63
|
+
readonly message: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Remediation text for an unusable seat model (es config set is human-only). */
|
|
67
|
+
export function modelProblemMessage(agent: string, key: string, ref: string, wellFormed: boolean): string {
|
|
68
|
+
return (
|
|
69
|
+
`Seat "${agent}" cannot launch: its model "${ref}" (${key}) is ` +
|
|
70
|
+
(wellFormed ? "not on this host." : 'malformed (want "provider/model").') +
|
|
71
|
+
` Set ${key} to "provider/model" from the host model list with \`es config set\`, or unset it to use the session default.`
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every configured ref, per seat, that `exists` cannot find on the host. */
|
|
76
|
+
export function modelProblems(
|
|
77
|
+
options: PluginOptions,
|
|
78
|
+
specs: readonly AgentSpec[],
|
|
79
|
+
exists: (providerID: string, modelID: string) => boolean,
|
|
80
|
+
): ModelProblem[] {
|
|
81
|
+
const problems: ModelProblem[] = []
|
|
82
|
+
for (const spec of specs) {
|
|
83
|
+
const ref = options.models?.agents?.[spec.id] ?? options.models?.[spec.tier]
|
|
84
|
+
if (ref === undefined) continue
|
|
85
|
+
const key = options.models?.agents?.[spec.id] !== undefined ? `models.agents.${spec.id}` : `models.${spec.tier}`
|
|
86
|
+
const parsed = parseModelRef(ref)
|
|
87
|
+
if (parsed === undefined || !exists(parsed.providerID, parsed.id))
|
|
88
|
+
problems.push({
|
|
89
|
+
agent: spec.id,
|
|
90
|
+
key,
|
|
91
|
+
ref,
|
|
92
|
+
message: modelProblemMessage(spec.id, key, ref, parsed !== undefined),
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
return problems
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** es_status lines for the runtime's model problems (empty when healthy). */
|
|
99
|
+
export function modelProblemLines(problems: ReadonlyMap<string, ModelProblem>): string[] {
|
|
100
|
+
if (problems.size === 0) return []
|
|
101
|
+
return [
|
|
102
|
+
`Model problems (${problems.size}): configured models missing on this host — those seats refuse to launch.`,
|
|
103
|
+
...[...problems.values()].map((problem) => `- ${problem.message}`),
|
|
104
|
+
]
|
|
55
105
|
}
|
|
56
106
|
|
|
57
107
|
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
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
bwrapAvailable,
|
|
16
16
|
fromOpenCode,
|
|
17
17
|
HookEngine,
|
|
18
|
+
parseModelRef,
|
|
18
19
|
permissionActionTool,
|
|
19
20
|
shellQuote,
|
|
20
21
|
toOpenCodeInput,
|
|
@@ -100,8 +101,7 @@ export async function createHookBridge(
|
|
|
100
101
|
root: runtime.root,
|
|
101
102
|
stateDir: runtime.stateDir,
|
|
102
103
|
generate: async (prompt, model) => {
|
|
103
|
-
const
|
|
104
|
-
const ref = model && slash > 0 ? { providerID: model.slice(0, slash), id: model.slice(slash + 1) } : undefined
|
|
104
|
+
const ref = model ? parseModelRef(model) : undefined
|
|
105
105
|
return (await ctx.generate.text({ prompt, ...(ref ? { model: ref } : {}) })).text
|
|
106
106
|
},
|
|
107
107
|
})
|
|
@@ -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
|
@@ -74,6 +74,18 @@ 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
|
+
}
|
|
77
89
|
if (seat && event.tool === "subagent") guardSeatLaunch({ agent: event.agent, input }, seats)
|
|
78
90
|
if (seat && event.sessionID) await seats?.onTool(event.sessionID, event.agent, event.tool)
|
|
79
91
|
if (seat || event.tool.startsWith("es_")) {
|
package/src/pr.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// integration branch (never the base branch) and opens a draft PR for a human
|
|
3
3
|
// to review and merge.
|
|
4
4
|
import type { PrOpener } from "@heretek-ai/es-core"
|
|
5
|
-
import { git, run } from "@heretek-ai/es-core"
|
|
5
|
+
import { buildPrArgs, git, run } from "@heretek-ai/es-core"
|
|
6
6
|
|
|
7
7
|
export const ghPrOpener: PrOpener = {
|
|
8
8
|
id: "gh",
|
|
@@ -16,17 +16,17 @@ export const ghPrOpener: PrOpener = {
|
|
|
16
16
|
const remote = await git(root, ["remote", "get-url", "origin"], { allowFail: true })
|
|
17
17
|
return remote.code === 0
|
|
18
18
|
},
|
|
19
|
-
async open({ root, branch, base, title, body }) {
|
|
19
|
+
async open({ root, branch, base, title, body, preset }) {
|
|
20
20
|
if (branch === base || !branch.startsWith("factory/")) throw new Error(`refusing to push "${branch}"`)
|
|
21
|
+
// One argv builder for every opener; it refuses an off-base preset run
|
|
22
|
+
// (e.g. --base main) before anything is pushed.
|
|
23
|
+
const argv = buildPrArgs({ branch, base, title, body, preset })
|
|
21
24
|
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
|
-
)
|
|
25
|
+
const created = await run(argv, {
|
|
26
|
+
cwd: root,
|
|
27
|
+
timeoutMs: 60_000,
|
|
28
|
+
passEnv: ["GH_TOKEN", "GITHUB_TOKEN", "GH_HOST"],
|
|
29
|
+
})
|
|
30
30
|
const url = created.stdout
|
|
31
31
|
.trim()
|
|
32
32
|
.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 },
|
package/src/rpc.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// Server side of the TUI previews.
|
|
2
|
-
//
|
|
3
|
-
// the TUI previews, then
|
|
4
|
-
//
|
|
1
|
+
// Server side of the TUI previews. Approvals complete in the TUI process
|
|
2
|
+
// (masked dialog, in-process signing, ADR 0002); trust and resume sign at a
|
|
3
|
+
// terminal with the passphrase-sealed key. The TUI previews, then either
|
|
4
|
+
// signs itself or points at `es trust` / `es factory resume`. No approve,
|
|
5
|
+
// trust or resume call ever crosses RPC: only lspInstall still mutates via
|
|
6
|
+
// a preview token.
|
|
5
7
|
import { randomBytes } from "node:crypto"
|
|
6
8
|
import {
|
|
7
9
|
type ApprovalStage,
|
|
@@ -19,6 +21,7 @@ import {
|
|
|
19
21
|
headline,
|
|
20
22
|
installServer,
|
|
21
23
|
isTrusted,
|
|
24
|
+
listBrainstormRuns,
|
|
22
25
|
loadGatesConfig,
|
|
23
26
|
readFrontier,
|
|
24
27
|
readIdeas,
|
|
@@ -54,14 +57,14 @@ export function createRpcHandlers(
|
|
|
54
57
|
tickets.set(token, { kind, subject: hashJson(subject), expires: Date.now() + TTL_MS })
|
|
55
58
|
return token
|
|
56
59
|
}
|
|
57
|
-
const
|
|
60
|
+
const redeemHash = (token: string, kind: string, subjectHash: string) => {
|
|
58
61
|
const ticket = tickets.get(token)
|
|
59
62
|
tickets.delete(token)
|
|
60
63
|
if (!ticket || ticket.kind !== kind || ticket.expires < Date.now())
|
|
61
64
|
throw new Error("The confirmation expired or does not match; preview again.")
|
|
62
|
-
if (ticket.subject !==
|
|
63
|
-
throw new Error("What you confirmed changed since the preview; preview again.")
|
|
65
|
+
if (ticket.subject !== subjectHash) throw new Error("What you confirmed changed since the preview; preview again.")
|
|
64
66
|
}
|
|
67
|
+
const redeem = (token: string, kind: string, subject: unknown) => redeemHash(token, kind, hashJson(subject))
|
|
65
68
|
const refused = (context: any, error: unknown) =>
|
|
66
69
|
context.error("refused", error instanceof Error ? error.message : String(error), {
|
|
67
70
|
reason: error instanceof Error ? error.message : String(error),
|
|
@@ -181,14 +184,19 @@ export function createRpcHandlers(
|
|
|
181
184
|
}
|
|
182
185
|
},
|
|
183
186
|
brainstormState: async () => {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
|
|
187
|
+
const runs = await listBrainstormRuns(runtime.root).catch(() => [] as string[])
|
|
188
|
+
// The panel shows the default run, else the latest; every run stays one CLI call away.
|
|
189
|
+
const run = runs.includes("default") ? "default" : runs[runs.length - 1]
|
|
190
|
+
const plan = run ? await readPlan(runtime.root, run).catch(() => undefined) : undefined
|
|
191
|
+
if (!plan || !run) return { active: false, runs }
|
|
192
|
+
const ideas = await readIdeas(runtime.root, run).catch(() => [])
|
|
193
|
+
const scores = await readScores(runtime.root, run).catch(() => [])
|
|
194
|
+
const result = await readResult(runtime.root, run).catch(() => undefined)
|
|
189
195
|
const survivors = ideas.filter((idea) => !idea.duplicateOf)
|
|
190
196
|
return {
|
|
191
197
|
active: true,
|
|
198
|
+
run,
|
|
199
|
+
runs,
|
|
192
200
|
brief: plan.brief.idea,
|
|
193
201
|
lenses: plan.lenses,
|
|
194
202
|
ideas: survivors.length,
|
|
@@ -221,6 +229,9 @@ export function createRpcHandlers(
|
|
|
221
229
|
],
|
|
222
230
|
problems: [],
|
|
223
231
|
token: issue(`approve:${stage}`, subject.subject),
|
|
232
|
+
// ADR 0002 I3: the TUI signs only this subject; approveStage refuses a mismatch.
|
|
233
|
+
subjectHash: hashJson(subject.subject),
|
|
234
|
+
issuedAt: Date.now(),
|
|
224
235
|
}
|
|
225
236
|
} catch (error) {
|
|
226
237
|
return {
|
|
@@ -231,6 +242,23 @@ export function createRpcHandlers(
|
|
|
231
242
|
}
|
|
232
243
|
}
|
|
233
244
|
},
|
|
245
|
+
/**
|
|
246
|
+
* Validate-only redeem of an approval preview ticket (ADR 0002 I3): the
|
|
247
|
+
* TUI calls this exactly once before signing in-process. It enforces the
|
|
248
|
+
* 120 s TTL and single-use and checks the subject hash, but signs
|
|
249
|
+
* nothing — there is still no approve RPC (#49).
|
|
250
|
+
*/
|
|
251
|
+
redeemApproval: async (input: unknown, context: any) => {
|
|
252
|
+
const { stage, token, subjectHash } = input as { stage: ApprovalStage; token: unknown; subjectHash: unknown }
|
|
253
|
+
try {
|
|
254
|
+
if (typeof token !== "string" || typeof subjectHash !== "string")
|
|
255
|
+
throw new Error("The confirmation expired or does not match; preview again.")
|
|
256
|
+
redeemHash(token, `approve:${stage}`, subjectHash)
|
|
257
|
+
return { ok: true }
|
|
258
|
+
} catch (error) {
|
|
259
|
+
return refused(context, error)
|
|
260
|
+
}
|
|
261
|
+
},
|
|
234
262
|
previewTrust: async () => {
|
|
235
263
|
const subject = await trustSubject()
|
|
236
264
|
const trusted =
|