@elliotsystems/eai 0.1.2 → 0.1.4

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.
@@ -0,0 +1,211 @@
1
+ /**
2
+ * @elliotsystems/opencode-plugin — Elliot AI's OpenCode integration.
3
+ *
4
+ * Phase C2 scope: exactly one job. The Elliot backend serves `knowledge_search`
5
+ * over the native MCP + OAuth boundary (agent-visible as
6
+ * `elliot_knowledge_search`); this plugin injects the session's repository
7
+ * scope into that tool's arguments so retrieval can be repo-focused — while the
8
+ * model itself never names a repository, and the backend's `resolve_repo_scope`
9
+ * remains the only authorizer.
10
+ *
11
+ * What this plugin deliberately does NOT do:
12
+ * - no tokens: OAuth lifecycle (login, storage, refresh) is OpenCode's native
13
+ * MCP client, verified end-to-end against this backend;
14
+ * - no transport: there is no tool-invoke server route at v1.18.19, and
15
+ * reading mcp-auth.json would create a second credential path;
16
+ * - no prompt stuffing: knowledge arrives when the agent elects the tool.
17
+ *
18
+ * Loading (any of): opencode.json `"plugin": [["@elliotsystems/opencode-plugin",
19
+ * {"serverNames": ["elliot"]}]]`, a path spec during development, or a
20
+ * `.opencode/plugin/` file re-export.
21
+ */
22
+ import { isElliotKnowledgeTool, parseOptions, shellVerdict } from "./policy.js"
23
+ import { ELLIOT_PROVIDER_ID, configHook, makeAuthHook } from "./proxy-provider.js"
24
+ import { readElliotConfig } from "./elliot-auth.js"
25
+ import { formatScope, scopeForWorktree } from "./scope.js"
26
+ import { applyElliotIdentity } from "./identity.js"
27
+
28
+ // Structural mirrors of the host types (packages/plugin/src/index.ts @
29
+ // v1.18.19). Declared locally so this package type-checks and unit-tests
30
+ // without the opencode workspace on the module path; the host validates
31
+ // plugins structurally (`readV1Plugin`), so compatibility is by shape, not by
32
+ // nominal import.
33
+ export interface PluginInput {
34
+ directory: string
35
+ worktree: string
36
+ project?: unknown
37
+ client?: {
38
+ auth?: {
39
+ set: (input: { path: { id: string }; body: Record<string, unknown> }) => Promise<unknown>
40
+ }
41
+ [k: string]: unknown
42
+ }
43
+ serverUrl?: URL
44
+ $?: unknown
45
+ }
46
+
47
+ /** The host's Permission payload — only the fields this plugin reads. */
48
+ export interface PermissionInput {
49
+ type: string
50
+ pattern?: string | string[]
51
+ metadata?: Record<string, unknown>
52
+ }
53
+
54
+ /** Structural mirror of the host's `AuthHook` (packages/plugin/src/index.ts). */
55
+ export interface AuthHook {
56
+ provider: string
57
+ methods: unknown[]
58
+ loader?: (auth: () => Promise<unknown>, provider: unknown) => Promise<Record<string, any>>
59
+ }
60
+
61
+ export interface Hooks {
62
+ "tool.execute.before"?: (
63
+ input: { tool: string; sessionID: string; callID: string },
64
+ output: { args: Record<string, unknown> },
65
+ ) => Promise<void>
66
+ "permission.ask"?: (
67
+ input: PermissionInput,
68
+ output: { status: "ask" | "deny" | "allow" },
69
+ ) => Promise<void>
70
+ "experimental.chat.system.transform"?: (
71
+ input: { sessionID?: string; model: unknown },
72
+ output: { system: string[] },
73
+ ) => Promise<void>
74
+ config?: (input: any) => Promise<void>
75
+ auth?: AuthHook
76
+ dispose?: () => Promise<void>
77
+ }
78
+
79
+ export type Plugin = (input: PluginInput, options?: Record<string, unknown>) => Promise<Hooks>
80
+
81
+ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
82
+ // Fail loudly at load on bad options — a plugin that limps with a typo'd
83
+ // config is worse than one that says why it refused to start.
84
+ const options = parseOptions(rawOptions)
85
+
86
+ // Session truth, resolved once. The worktree comes from OpenCode itself —
87
+ // the one source the model cannot influence.
88
+ const worktree = input.worktree || input.directory
89
+
90
+ // Seed a placeholder auth entry for the `elliot` provider so the host's
91
+ // auth loader fires at all (`provider.ts:1584` skips the loader entirely
92
+ // when nothing is stored for its provider id — see Task-1 findings). The
93
+ // value itself is never the credential that reaches the wire; the auth
94
+ // loader's `fetch` attaches the live bearer token per request. Guarded so
95
+ // a client-RPC failure never breaks plugin init, and skipped outright when
96
+ // the user isn't logged into Elliot.
97
+ if (readElliotConfig()) {
98
+ try {
99
+ await input.client?.auth?.set({
100
+ path: { id: ELLIOT_PROVIDER_ID },
101
+ body: { type: "api", key: "elliot-managed" },
102
+ })
103
+ } catch {
104
+ // Never break plugin init on a seeding failure — the auth loader
105
+ // simply won't fire this session, same as the no-login path.
106
+ }
107
+ }
108
+
109
+ return {
110
+ /**
111
+ * One handler, two jobs — a JS object literal may carry only one
112
+ * `tool.execute.before` key, and a second would silently shadow the first.
113
+ * (a) the Elliot knowledge tool -> inject worktree-derived repo scope
114
+ * (b) the shell tool -> enforce org hard-deny / credential policy
115
+ */
116
+ "tool.execute.before": async (hookInput, output) => {
117
+ // (a) Knowledge-tool scope injection. The model never names a repository;
118
+ // this value comes from the session worktree, and the backend authorizes it.
119
+ if (isElliotKnowledgeTool(hookInput.tool, options)) {
120
+ const scope = await scopeForWorktree(worktree)
121
+ if (scope) {
122
+ // Unconditional overwrite: session truth beats anything already in args.
123
+ // The backend schema forbids model-supplied scope, but if one ever slipped
124
+ // through a future contract change, it dies here first.
125
+ output.args["repo_scope_hint"] = formatScope(scope)
126
+ } else {
127
+ // No derivable scope -> guarantee none is smuggled either. Tenant-wide
128
+ // search is the correct degradation (authorized, just unfocused).
129
+ delete output.args["repo_scope_hint"]
130
+ }
131
+ return
132
+ }
133
+
134
+ // (b) Shell policy, enforced HERE rather than in `permission.ask`.
135
+ //
136
+ // Forced by upstream reality, verified by scanning every `plugin.trigger(...)`
137
+ // site in v1.18.19: thirteen hooks are triggered and `permission.ask` is NOT
138
+ // among them. It is declared in the public plugin API
139
+ // (packages/plugin/src/index.ts:261) but never invoked, so a handler there is
140
+ // dead code in this version. `tool.execute.before` IS triggered
141
+ // (session/prompt.ts), and throwing from it aborts the tool call — the only
142
+ // plugin-side hard stop available today.
143
+ //
144
+ // Why bother when the org glob policy already asks/denies? Defence in depth
145
+ // against what globs structurally cannot see: a catastrophic shape hiding
146
+ // behind a safe-looking chain segment (`git status ; rm -rf /`), and a command
147
+ // that both names a secret-shaped path and invokes a network tool. If an org
148
+ // policy is ever loosened by accident, these still do not get through.
149
+ //
150
+ // Deliberately narrow: it only ever BLOCKS, and only on the hard-deny and
151
+ // credential classes. Commands the classifier merely wants confirmed are left
152
+ // to the host's own ask flow, which already handles them well.
153
+ if (hookInput.tool !== "bash" && !hookInput.tool.endsWith("_bash")) return
154
+
155
+ const command = output.args?.["command"]
156
+ if (typeof command !== "string") return
157
+
158
+ const verdict = shellVerdict(command)
159
+ if (verdict?.status !== "deny") return
160
+
161
+ throw new Error(
162
+ `Blocked by Elliot organization policy: ${verdict.reason}. Command: ${command}`,
163
+ )
164
+ },
165
+
166
+ /**
167
+ * Kept deliberately, though currently INERT: `permission.ask` has no trigger
168
+ * site in v1.18.19 (verified across every `plugin.trigger` call). When upstream
169
+ * wires it, policy decisions belong here — a returned status is cleaner than a
170
+ * thrown tool error — so this is the forward-compatible home. It can only ever
171
+ * tighten.
172
+ */
173
+ // The agent introduces itself as OpenCode on a stock runtime, because that
174
+ // is what the compiled system prompt says. Rewritten here so the fix lives
175
+ // in the overlay instead of in the harness. See ./identity.ts.
176
+ "experimental.chat.system.transform": async (_hookInput, output) => {
177
+ applyElliotIdentity(output.system)
178
+ },
179
+
180
+ "permission.ask": async (permInput, output) => {
181
+ if (permInput.type !== "bash") return
182
+ const command = permInput.metadata?.["command"]
183
+ if (typeof command !== "string") return
184
+ const verdict = shellVerdict(command)
185
+ if (!verdict) return
186
+ if (output.status === "deny") return // never loosen
187
+ output.status = verdict.status
188
+ },
189
+
190
+ /**
191
+ * Registers the `elliot` provider (proxy baseURL) and defaults the
192
+ * model to it — no-op when the user isn't logged into Elliot. See
193
+ * `proxy-provider.ts`.
194
+ */
195
+ config: async (c) => {
196
+ configHook(c)
197
+ },
198
+
199
+ /**
200
+ * Self-refreshing auth loader for the `elliot` provider — see
201
+ * `proxy-provider.ts`. Only fires once the seeding above has stored an
202
+ * auth entry for `ELLIOT_PROVIDER_ID`.
203
+ */
204
+ auth: makeAuthHook(),
205
+ }
206
+ }
207
+
208
+ // Both export styles the host loader accepts (`getLegacyPlugins` takes any
209
+ // function export; `PluginModule` prefers `server`).
210
+ export const server = ElliotPlugin
211
+ export default ElliotPlugin
@@ -0,0 +1,30 @@
1
+ /**
2
+ * How OpenCode composes agent-visible MCP tool keys.
3
+ *
4
+ * Its own module, with ZERO imports, because two runtimes need it. `policy.ts`
5
+ * uses it in the server plugin (where zod is available) and `tui/knowledge.ts`
6
+ * uses it in the TUI plugin runtime — which shares only a fixed list of modules
7
+ * with plugins (`solid-js`, `@opentui/*`, `bun`; see `tui/host.d.ts`) and would
8
+ * fail to load a module that reached for `zod` transitively.
9
+ *
10
+ * Duplicating the rule in both places was the alternative. A drifted copy of a
11
+ * key-composition rule fails silently — the tool simply stops being recognised —
12
+ * so one definition, imported twice, is the safer shape.
13
+ */
14
+
15
+ /**
16
+ * Mirror of OpenCode's `McpCatalog.sanitize` (mcp/catalog.ts:117) so composed
17
+ * keys are predicted exactly, including server names carrying characters MCP
18
+ * sanitises away.
19
+ */
20
+ export function sanitizeKey(value: string): string {
21
+ return value.replace(/[^a-zA-Z0-9_-]/g, "_")
22
+ }
23
+
24
+ /** The backend tool behind every Elliot knowledge lookup. */
25
+ export const KNOWLEDGE_TOOL = "knowledge_search"
26
+
27
+ /** The agent-visible key for `KNOWLEDGE_TOOL` on a given MCP server. */
28
+ export function knowledgeToolKey(serverName: string): string {
29
+ return `${sanitizeKey(serverName)}_${KNOWLEDGE_TOOL}`
30
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * C2-minimal policy: validate the plugin's own options, and identify which
3
+ * agent-visible tool keys are Elliot knowledge tools.
4
+ *
5
+ * Deliberately small. The full bash-policy port (command classification,
6
+ * hard-deny set) is a later phase — nothing in C2 executes shell commands, and
7
+ * carrying unused policy code would be scope creep wearing a safety costume.
8
+ *
9
+ * `PluginOptions` is untyped upstream (`Record<string, unknown>`), so a typo'd
10
+ * option would otherwise vanish silently; zod turns that into a load-time
11
+ * message instead.
12
+ */
13
+ import { z } from "zod"
14
+
15
+ import {
16
+ type Category,
17
+ classifyCommand,
18
+ isHardDenied,
19
+ killsAgentRuntime,
20
+ looksLikeCredentialExfiltration,
21
+ } from "./classifier.js"
22
+ import { KNOWLEDGE_TOOL, knowledgeToolKey, sanitizeKey } from "./mcp-keys.js"
23
+
24
+ const OptionsSchema = z
25
+ .object({
26
+ /**
27
+ * MCP server name(s) this plugin should treat as Elliot. Must match the
28
+ * key(s) under `mcp` in opencode.json. OpenCode composes agent-visible MCP
29
+ * tool keys as `<client>_<tool>` (mcp/catalog.ts:119), so these names are
30
+ * what let the hooks recognise Elliot tools.
31
+ */
32
+ serverNames: z.array(z.string().min(1)).min(1).default(["elliot"]),
33
+ })
34
+ .strict()
35
+
36
+ export type ElliotPluginOptions = z.infer<typeof OptionsSchema>
37
+
38
+ /**
39
+ * Parse options, throwing a load-time error on unknown/invalid keys. A plugin
40
+ * that starts with wrong options and limps is worse than one that says why it
41
+ * refused to.
42
+ */
43
+ export function parseOptions(raw: unknown): ElliotPluginOptions {
44
+ return OptionsSchema.parse(raw ?? {})
45
+ }
46
+
47
+ /**
48
+ * Re-exported so existing importers (and the test suite) keep one entry point
49
+ * for the key rule, which now lives in `mcp-keys.ts` — see that module for why
50
+ * it had to become import-free.
51
+ */
52
+ export { KNOWLEDGE_TOOL, sanitizeKey }
53
+
54
+ /**
55
+ * Is this agent-visible tool key Elliot's knowledge tool on one of our
56
+ * configured servers? Exact composed-key match only — a suffix match would let
57
+ * an unrelated MCP server named e.g. "not-elliot" receive our scope hints.
58
+ */
59
+ export function isElliotKnowledgeTool(toolKey: string, options: ElliotPluginOptions): boolean {
60
+ return options.serverNames.some((server) => toolKey === knowledgeToolKey(server))
61
+ }
62
+
63
+ // -- Shell policy verdicts ---------------------------------------------------
64
+
65
+ /**
66
+ * What the plugin does to a shell permission decision, and — just as importantly —
67
+ * what it refuses to do.
68
+ *
69
+ * `undefined` means "leave the host's decision untouched". That is the default for
70
+ * anything read-shaped, and it matters: the org's glob policy already decided those,
71
+ * and a plugin that *widened* a permission it did not grant would be a privilege
72
+ * escalation wearing a helpful face. This function can therefore only ever tighten.
73
+ */
74
+ export function shellVerdict(
75
+ command: string,
76
+ ): { status: "deny" | "ask"; category: Category; reason: string } | undefined {
77
+ if (!command.trim()) {
78
+ // Nothing to inspect: say nothing rather than guess. The host's own policy
79
+ // (`bash *: ask` in the org default) remains in force.
80
+ return undefined
81
+ }
82
+
83
+ if (isHardDenied(command)) {
84
+ return {
85
+ status: "deny",
86
+ category: "DESTRUCTIVE",
87
+ reason:
88
+ "catastrophic command shape (org hard-deny list) — refused outright; " +
89
+ "confirmation adds no safety against a runaway loop",
90
+ }
91
+ }
92
+
93
+ if (killsAgentRuntime(command)) {
94
+ return {
95
+ status: "deny",
96
+ category: "DESTRUCTIVE",
97
+ reason:
98
+ "kills processes by name matching Elliot's own runtime (node/bun/opencode), " +
99
+ "which terminates this session mid-command and leaves the terminal in " +
100
+ "mouse-reporting mode — target a specific PID instead (Stop-Process -Id, " +
101
+ "taskkill /PID), or kill the service by its own process name",
102
+ }
103
+ }
104
+
105
+ if (looksLikeCredentialExfiltration(command)) {
106
+ return {
107
+ status: "deny",
108
+ category: "DESTRUCTIVE",
109
+ reason:
110
+ "names a secret-shaped path AND invokes a network tool — refused as possible " +
111
+ "credential exfiltration",
112
+ }
113
+ }
114
+
115
+ const category = classifyCommand(command)
116
+ if (category === "DESTRUCTIVE" || category === "MUTATION" || category === "UNKNOWN") {
117
+ return { status: "ask", category, reason: `classified ${category} — needs confirmation` }
118
+ }
119
+
120
+ // READ / TEST / BUILD: the org globs already allow these. Stay out of the way.
121
+ return undefined
122
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The Elliot proxy provider: a `config` hook that registers a custom
3
+ * `elliot` OpenCode provider pointed at the Elliot LightLLM proxy, and an
4
+ * `auth` hook whose `loader` returns a self-refreshing `fetch` that attaches
5
+ * a live bearer token per request.
6
+ *
7
+ * Mechanism pinned in `.superpowers/sdd/2026-09-03-eai-proxy-auth/task-1-findings.md`:
8
+ * - `config` hook mutates its input in place (no separate output param).
9
+ * - `auth.loader`'s return value is spread wholesale into the provider's
10
+ * resolved `options`, so `{ apiKey, fetch }` land there directly.
11
+ * - `apiKey` is baked into the AI-SDK client at construction time and
12
+ * never changes; the actually-rotating credential is injected per
13
+ * request by `options.fetch`'s `Authorization` header (never the static
14
+ * `apiKey`) — same pattern as OpenCode's bundled snowflake-cortex plugin.
15
+ * - The loader only runs once a stored auth entry exists for its
16
+ * `provider` id (`provider.ts:1584`); the plugin seeds that entry itself
17
+ * at init (see `index.ts`) so no interactive `opencode auth login` step
18
+ * is ever required.
19
+ *
20
+ * Everything here is a no-op when the user isn't logged in
21
+ * (`readElliotConfig()` returns null) — OpenCode then runs exactly as it
22
+ * would without this plugin.
23
+ */
24
+ import { freshToken, readElliotConfig } from "./elliot-auth.js"
25
+
26
+ export const ELLIOT_LLM_PROXY_URL = "https://backend.elliotsystemsonline.com/llm-proxy"
27
+ export const ELLIOT_PROVIDER_ID = "elliot"
28
+
29
+ /**
30
+ * Registers the `elliot` provider (baseURL = the LightLLM proxy) and
31
+ * defaults `cfg.model` to it, mutating `cfg` in place. No-op when the user
32
+ * isn't logged in. Never overwrites a model the user already configured, or
33
+ * any other existing key under `cfg.provider`/`cfg.plugin`/`cfg.mcp`/etc.
34
+ */
35
+ export function configHook(cfg: any): void {
36
+ if (!readElliotConfig()) return
37
+
38
+ cfg.provider ??= {}
39
+ cfg.provider[ELLIOT_PROVIDER_ID] = {
40
+ npm: "@ai-sdk/anthropic",
41
+ name: "Elliot (governed)",
42
+ options: { baseURL: ELLIOT_LLM_PROXY_URL },
43
+ models: { "elliot-governed": { name: "Elliot (governed)" } },
44
+ }
45
+
46
+ cfg.model ??= "elliot/elliot-governed"
47
+ }
48
+
49
+ /**
50
+ * Merge `Authorization: Bearer <token>` into whatever headers `init` already
51
+ * carries, without dropping them. Works whether `init.headers` is a plain
52
+ * object, a `Headers` instance, or an array of pairs.
53
+ */
54
+ function withAuthHeader(init: RequestInit | undefined, token: string): RequestInit {
55
+ const headers = new Headers(init?.headers)
56
+ headers.set("Authorization", `Bearer ${token}`)
57
+ return { ...init, headers }
58
+ }
59
+
60
+ /**
61
+ * The per-request fetch wrapper: reads `~/.elliot/config.json` fresh on
62
+ * every call (so a refresh picked up by another process is honored
63
+ * immediately), refreshes via `freshToken` when needed, and attaches the
64
+ * bearer token. Never throws — a missing/expired/unrefreshable token just
65
+ * means the request goes out unauthenticated and the proxy 401s.
66
+ */
67
+ async function elliotFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
68
+ try {
69
+ const cfg = readElliotConfig()
70
+ const token = cfg ? await freshToken(cfg) : null
71
+ if (token) {
72
+ return globalThis.fetch(input, withAuthHeader(init, token))
73
+ }
74
+ return globalThis.fetch(input, init)
75
+ } catch {
76
+ return globalThis.fetch(input, init)
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Builds the `auth` hook object per the Task-1 pinned `AuthHook` shape.
82
+ * `methods` is empty — this provider is never set up via interactive
83
+ * `opencode auth login`; the plugin seeds the stored auth entry itself
84
+ * (see `index.ts`) so the loader always fires once the user is logged into
85
+ * Elliot.
86
+ */
87
+ export function makeAuthHook() {
88
+ return {
89
+ provider: ELLIOT_PROVIDER_ID,
90
+ methods: [] as unknown[],
91
+ loader: async (_auth: () => Promise<unknown>, _provider: unknown) => ({
92
+ apiKey: "elliot-managed",
93
+ fetch: elliotFetch,
94
+ }),
95
+ }
96
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Repository scope from the session worktree — the trusted-context half of the
3
+ * "the model never names a repository" invariant.
4
+ *
5
+ * Ported (trimmed, not imported) from `elliot-cli/src/project.ts`: the same
6
+ * `execFile` discipline — a fixed, hardcoded argument array, never a string a
7
+ * shell would parse, and never anything the LLM supplies. The worktree path
8
+ * comes from OpenCode's own `PluginInput`/`ToolContext`, which is session
9
+ * truth, not model output.
10
+ *
11
+ * Authorization is NOT here and never will be: the derived `owner/name` is a
12
+ * hint the Elliot backend authorizes via `resolve_repo_scope`. A wrong value
13
+ * can therefore select nothing the tenant was not already granted — which is
14
+ * why every failure path below degrades to `null` (tenant-wide search) rather
15
+ * than throwing. Scope is an optimization, never a gate.
16
+ */
17
+ import { execFile } from "node:child_process"
18
+
19
+ export interface RepoScope {
20
+ owner: string
21
+ name: string
22
+ }
23
+
24
+ /**
25
+ * Handles SSH (git@host:owner/repo.git) and HTTPS (https://host/owner/repo.git)
26
+ * remote forms generically — not GitHub-specific. Same regex the CLI used.
27
+ */
28
+ export function parseOwnerRepo(remoteUrl: string | null | undefined): RepoScope | null {
29
+ if (!remoteUrl) return null
30
+ const match = remoteUrl.match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/)
31
+ if (!match) return null
32
+ return { owner: match[1], name: match[2] }
33
+ }
34
+
35
+ /** `owner/name`, the wire form `repo_scope_hint` carries. */
36
+ export function formatScope(scope: RepoScope): string {
37
+ return `${scope.owner}/${scope.name}`
38
+ }
39
+
40
+ function gitRemoteUrl(worktree: string): Promise<string | null> {
41
+ return new Promise((resolve) => {
42
+ execFile(
43
+ "git",
44
+ ["remote", "get-url", "origin"],
45
+ { cwd: worktree, timeout: 5_000, windowsHide: true },
46
+ (err, stdout) => {
47
+ // Any failure — not a repo, no origin, git absent, timeout — is a
48
+ // normal discovery miss, never an error to surface.
49
+ if (err) return resolve(null)
50
+ const url = stdout.trim()
51
+ resolve(url.length ? url : null)
52
+ },
53
+ )
54
+ })
55
+ }
56
+
57
+ /**
58
+ * Derive `owner/name` for a worktree, or null when it cannot be known.
59
+ *
60
+ * Cached per worktree path for the plugin's lifetime: the remote of an open
61
+ * session's repository does not meaningfully change mid-session, and the CLI's
62
+ * rule ("detected once, never re-run per tool call") carries over.
63
+ */
64
+ const cache = new Map<string, Promise<RepoScope | null>>()
65
+
66
+ export function scopeForWorktree(worktree: string | null | undefined): Promise<RepoScope | null> {
67
+ if (!worktree) return Promise.resolve(null)
68
+ const hit = cache.get(worktree)
69
+ if (hit) return hit
70
+ const pending = gitRemoteUrl(worktree).then(parseOwnerRepo)
71
+ cache.set(worktree, pending)
72
+ return pending
73
+ }
74
+
75
+ /** Test-only: clear the per-worktree cache. */
76
+ export function resetScopeCache(): void {
77
+ cache.clear()
78
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Structural mirror of the slice of `TuiPluginApi` this chrome uses.
3
+ *
4
+ * `@opencode-ai/plugin/tui` is deliberately absent from the module list the host
5
+ * shares with runtime-loaded plugins (see `host.d.ts`), so its types cannot be
6
+ * imported. The host validates plugins by SHAPE, not by nominal type, which is
7
+ * what makes a local mirror correct rather than a workaround.
8
+ *
9
+ * Only the members the chrome actually reads appear here. A mirror that copied
10
+ * the whole surface would be a second copy of upstream's API to keep in sync,
11
+ * and every unused member in it is a silent drift risk.
12
+ */
13
+ import type { RGBA } from "@opentui/core"
14
+
15
+ /** Theme roles this chrome paints with. Names match upstream's exactly. */
16
+ export interface ThemeCurrent {
17
+ readonly primary: RGBA
18
+ readonly text: RGBA
19
+ readonly textMuted: RGBA
20
+ readonly background: RGBA
21
+ readonly backgroundPanel: RGBA
22
+ readonly border: RGBA
23
+ readonly success: RGBA
24
+ readonly warning: RGBA
25
+ readonly error: RGBA
26
+ }
27
+
28
+ /** `TuiSidebarFileItem`. */
29
+ export interface FileItem {
30
+ readonly file: string
31
+ readonly additions: number
32
+ readonly deletions: number
33
+ }
34
+
35
+ /** `TuiSidebarMcpItem`. */
36
+ export interface McpStatusItem {
37
+ readonly name: string
38
+ readonly status: string
39
+ readonly error?: string
40
+ }
41
+
42
+ /** A `Message`, narrowed to the id needed to fetch its parts. */
43
+ export interface MessageLike {
44
+ readonly id: string
45
+ }
46
+
47
+ /** A `Part`, narrowed to what a knowledge citation needs. */
48
+ export interface PartLike {
49
+ readonly type: string
50
+ readonly tool?: string
51
+ readonly state?: {
52
+ readonly status?: string
53
+ readonly output?: string
54
+ }
55
+ }
56
+
57
+ /** `TuiState` — the read-only session view the chrome derives everything from. */
58
+ export interface StateApi {
59
+ readonly ready: boolean
60
+ readonly path: {
61
+ readonly worktree: string
62
+ readonly directory: string
63
+ }
64
+ readonly vcs: { readonly branch?: string; readonly default_branch?: string } | undefined
65
+ readonly session: {
66
+ count: () => number
67
+ diff: (sessionID: string) => ReadonlyArray<FileItem>
68
+ messages: (sessionID: string) => ReadonlyArray<MessageLike>
69
+ }
70
+ part: (messageID: string) => ReadonlyArray<PartLike>
71
+ mcp: () => ReadonlyArray<McpStatusItem>
72
+ }
73
+
74
+ /** `TuiPromptProps` — the host's prompt component, reused rather than rebuilt. */
75
+ export interface PromptProps {
76
+ sessionID?: string
77
+ visible?: boolean
78
+ disabled?: boolean
79
+ onSubmit?: () => void
80
+ ref?: (ref: unknown) => void
81
+ hint?: unknown
82
+ right?: unknown
83
+ showPlaceholder?: boolean
84
+ placeholders?: { normal?: string[]; shell?: string[] }
85
+ }
86
+
87
+ export interface TuiPluginApi {
88
+ readonly app: { readonly version: string }
89
+ readonly theme: {
90
+ readonly current: ThemeCurrent
91
+ /**
92
+ * Copies a theme file from the plugin root into a directory the host's
93
+ * theme discovery scans. Resolved relative to the plugin root by
94
+ * `createThemeInstaller` (packages/opencode/src/plugin/tui/runtime.ts:245).
95
+ */
96
+ install: (jsonPath: string) => Promise<void>
97
+ has: (name: string) => boolean
98
+ }
99
+ readonly state: StateApi
100
+ readonly ui: {
101
+ Prompt: (props: PromptProps) => unknown
102
+ }
103
+ readonly slots: {
104
+ register(input: { order?: number; slots: Record<string, unknown> }): unknown
105
+ }
106
+ }
107
+
108
+ export type TuiPlugin = (api: TuiPluginApi) => Promise<void>
109
+
110
+ /** Props the host passes to the session-scoped slots. */
111
+ export interface SessionSlotProps {
112
+ readonly session_id: string
113
+ readonly visible?: boolean
114
+ readonly disabled?: boolean
115
+ readonly on_submit?: () => void
116
+ readonly ref?: (ref: unknown) => void
117
+ }