diff --git a/CHANGELOG.md b/CHANGELOG.md index 7de9c36e..11cd4ae4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,76 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Added + +- **Subagents: answer a permission from the pane.** A subagent that asked for a permission while you + were watching it had to be closed before you could answer, in OpenCode's prompt underneath. Its + pane now shows the request as a card above the message box, as OpenCode's own prompt does: what it + asks for — a shell call's whole command line — and `[y] Allow once`, `[Y] Allow always`, + `[n] Reject`, each a key or a click. Several waiting read `1 of 3`; an answer the host refuses + comes back on the card with why. It answers the same request, so OpenCode's prompt goes too. Not + `enter`, which opens the item under the cursor: one press too many would have approved a command. + The switcher marks any other subagent that is waiting with `△`. Both OpenCodes; OpenCode 2's + requests are now read at all, where before they fell through as an unknown event. +- **Trust: folders outside the project are suggested, never learned.** OpenCode's + `external_directory` prompt — a tool about to read or write outside the project — was always left + to you, and on a real ledger it was one prompt in sixteen, almost all in the OS temp dir, every one + approved by hand. Trust now counts those approvals by folder and still never answers one by its + count: once approvals across two or more temp folders reach the threshold, the ledger suggests + `★ any folder in $TMPDIR?`, and `w` lets Trust answer there from now on. The temp dir is one + family under every name it goes by (`/var/folders/…` and `/private/var/folders/…`, `/tmp` and + `/private/tmp`); any other folder can be widened with `w` too, and covers the folders under it, + never its siblings. `/`, your home folder, `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config`, `~/.kube`, + `~/.docker`, `/etc`, a folder that may hold secrets and one that holds the project are never + suggested or widened. Both halves decide it the same way. + +### Changed + +- **Trust answers before OpenCode draws its prompt (OpenCode 2).** Trust answered from the interface, + after OpenCode had already put its prompt on screen: a frame each time, and in a burst of parallel + subagents a prompt that flickered over and over and took what you were typing. OpenCode 2 lets an + agent-side plugin decide a permission before any request is made, so Trust's agent half now + answers there, and nothing is drawn at all. It decides with what the interface decides with: the + same ledger, read again before every answer (an approval or a pause in any window counts), the + same rules — the interface shares OpenCode's permissions with it in `rules.json` beside the + ledger, never the rest of your config — and the same settings. When anything is missing or in + doubt (the interface is not running, the command line was not heard, the ledger cannot be + written), it answers nothing and the interface handles the request as before. Its answers are in + the ledger and the sidebar like any other. OpenCode 1 has no such hook: Trust answers from the + interface there, a frame after the prompt shows. + +- **Subagents: the message box is always there.** It sits at the foot of the pane like OpenCode's + own prompt — an accent edge, the agent, its model and whose model it is under it — instead of + appearing only after `m`. `tab` or a click on it starts typing; `enter` sends and leaves you + typing; `esc` hands the keys back to the pane and keeps the draft; `←` `→` move the + cursor and `ctrl+u` clears what is before it. `m` is no longer a key. +- **Subagents: a drag in the pane is no longer a click.** Pressing and releasing on different cells + used to open or fold the item under the release; it is now left to OpenCode as a selection, which + OpenCode copies as it does its own text. +- **Trust: commands run from another folder read clearly.** A command the agent ran after + `cd packages/api` read `(in packages/api) TZ=… npx …`, and a row cut from the right lost the + program — `any (in /Users/…/support-data-u…) …` said nothing. The command now comes first and its + place after it, muted and short (`npx vitest run in packages/api`, `~/…/support-data-uploader/scripts` + for a folder outside the project), in the tree, the suggestions, the card, the activity and the + sidebar; a narrow row cuts the command's tail, never its program, and keeps the place. The card + says it in words: "Run from packages/api — the agent changed folder first." Such commands are no + longer foldered by their place or their env words, but under their program. +- **Trust: a family is the same wherever it runs.** `rg` run from `packages/api`, from the top and + from a worktree were three families, each approved and widened on its own — on one real ledger 491 + of 1,705 families were a place. A family is now the command alone: any `rg` you widened is any `rg` + in the project, in whatever folder. Leaving the project stays a question of its own — OpenCode asks + `external_directory` for it, counted by folder above. The exact command keeps its place, so a rule + one command earned stays that command's; widenings, dismissals and learned reads recorded with a + place before now hold in every folder. + +### Fixed + +- **Trust: `/trust` keeps up on a big ledger.** On a real ledger of 13,000 commands every keystroke + repainted in about half a second — the shell was parsed again for every command on every paint — + so scrolling lagged and keys piled up until the dialog looked stuck. A command's reading is now kept + once made, and what the screens are built from is rebuilt only when the ledger changes: 3–4ms a + keystroke on the same ledger. + ## [0.11.0] - 2026-10-06 ### Added diff --git a/packages/client/src/opencode/server/index.ts b/packages/client/src/opencode/server/index.ts index 7be67027..4a598c5c 100644 --- a/packages/client/src/opencode/server/index.ts +++ b/packages/client/src/opencode/server/index.ts @@ -17,11 +17,28 @@ import type { Hooks, PluginInput, ToolDefinition } from "@opencode-ai/plugin" import { cockpitVersion, createLog, type Log } from "../../log.ts" import { setupServer } from "../../setup/server.ts" import { recordAgent } from "../service.ts" -import { composeParts, type ServerHost, type ServerParts, type ServerStart, type ToolCall } from "./parts.ts" +import { + composeParts, + type PermissionAsk, + type ServerHost, + type ServerParts, + type ServerStart, + type ToolCall, + type ToolStart, +} from "./parts.ts" import { readSkill } from "./skills.ts" import { registerSurfaces } from "./surfaces.ts" import { partsToV1Hooks, serverFromV1 } from "./v1.ts" -import { commandText, serverFromV2, toolToV2, type V2Event, type V2ServerContext, v2ToolCall } from "./v2.ts" +import { + commandText, + serverFromV2, + toolToV2, + type V2Event, + type V2ServerContext, + v2PermissionAsk, + v2ToolCall, + v2ToolStart, +} from "./v2.ts" export * from "./parts.ts" export * from "./skills.ts" @@ -121,6 +138,12 @@ function withSurfaces(host: ServerHost, parts: ServerParts): ServerParts { } } +/** + * How long OpenCode waits on a feature's `permission` before the request goes to the person. The agent + * waits too, so a feature that hangs costs a moment, never the turn — and is then simply asked. + */ +export const PERMISSION_MS = 2_000 + /** Starts a feature: what loaded and where first, so a feature that never answers still said it was loaded. */ async function begin( id: string, @@ -134,10 +157,45 @@ async function begin( try { /** `cockpit_settings`, the `cockpit-setup` skill and `/cockpit-setup`: the first entry here adds them. */ const parts = withSurfaces(host, composeParts([await start(host, options), setupServer(host, id)])) - const { toolAfter } = parts + const { toolAfter, toolBefore, permission } = parts return { ...parts, tools: loggedTools(parts.tools, host.log), + ...(toolBefore + ? { + toolBefore: async (call: ToolStart) => { + try { + await toolBefore(call) + } catch (error) { + host.log.warn("toolBefore failed", { tool: call.tool, error }) + } + }, + } + : {}), + /** An answer that fails or takes too long is no answer: the person is asked, as without Cockpit. */ + ...(permission + ? { + permission: async (request: PermissionAsk) => { + let timer: ReturnType | undefined + const late = new Promise<"late">((done) => { + timer = setTimeout(() => done("late"), PERMISSION_MS) + }) + try { + const answer = await Promise.race([permission(request), late]) + if (answer === "late") { + host.log.warn("permission answer too slow; asking", { action: request.action }) + return undefined + } + return answer + } catch (error) { + host.log.warn("permission failed; asking", { action: request.action, error }) + return undefined + } finally { + clearTimeout(timer) + } + }, + } + : {}), /** Listening to a call must never break it: a failure is logged and goes no further. */ ...(toolAfter ? { @@ -227,6 +285,28 @@ export function dualServer(id: string, start: ServerStart) { }) } else host.log.warn("this OpenCode has no execute.after hook; tool output is not followed") } + if (parts.toolBefore) { + const before = parts.toolBefore + if (ctx.tool.hook) { + await ctx.tool.hook("execute.before", async (event) => { + await before(v2ToolStart(event)) + }) + } else host.log.warn("this OpenCode has no execute.before hook; tool calls are not heard early") + } + if (parts.permission) { + const permission = parts.permission + /** + * Only what config left at "ask": an "allow" needs no answer, and a hook that wrote "allow" + * over another plugin's "deny" would be undoing a refusal. The hook is OpenCode's to await — + * nothing here throws into it (`begin` already caught it), since a throw would fail the call. + */ + if (ctx.permission?.hook) { + await ctx.permission.hook("evaluate", async (event) => { + if (event.effect !== "ask") return + if ((await permission(v2PermissionAsk(event))) === "allow") event.effect = "allow" + }) + } else host.log.warn("this OpenCode has no permission evaluate hook; answers come from the interface") + } if (parts.system) { const system = parts.system await ctx.session.hook("context", async (event) => { diff --git a/packages/client/src/opencode/server/parts.ts b/packages/client/src/opencode/server/parts.ts index 55fbd740..0a2e50cf 100644 --- a/packages/client/src/opencode/server/parts.ts +++ b/packages/client/src/opencode/server/parts.ts @@ -66,6 +66,39 @@ export interface ToolCall { agent?: string } +/** + * A tool call about to run, as `toolBefore` hears of it: what the model sent, before the tool reads + * it — and before any permission it asks for. OpenCode 2 only. + */ +export interface ToolStart { + sessionID: string + tool: string + callID: string + /** What the model sent. */ + args: unknown + agent?: string + messageID?: string +} + +/** + * A permission OpenCode 2 is about to ask the person for, as `permission` hears of it: after config + * left it at "ask" and before any request exists, so nothing has been drawn yet. Measured from 2.0.18's + * own `Permission.evaluate` (docs/opencode/permissions.md). There is no request id yet — and no `save`, + * OpenCode's own "always", which only the request it then makes carries. + */ +export interface PermissionAsk { + sessionID: string + /** The agent asking (`build`, `general`, `explore`…). */ + agent?: string + /** As OpenCode 2 names it: `shell`, `edit`, `read`, `subagent`… */ + action: string + /** What config was matched against: a shell request's commands, a path, a URL. */ + resources: string[] + /** The tool call asking, when a tool asks: the `callID` its `toolBefore` came with. */ + callID?: string + messageID?: string +} + /** * A skill shipped in a package: the folder holding its `SKILL.md`, whose frontmatter names it. OpenCode 1 * reads the folder (`skills.paths`), OpenCode 2 is handed the file's text (`ctx.skill`): either way the @@ -103,6 +136,18 @@ export interface ServerParts { * what a tool answered and cannot change it. A throw is logged and never reaches the call. */ toolAfter?: (call: ToolCall) => Promise | void + /** + * Every tool call about to run, with what the model sent. OpenCode 2 only: v1's + * `tool.execute.before` is not wired, since nothing here needs it there. Read-only, like `toolAfter`. + */ + toolBefore?: (call: ToolStart) => Promise | void + /** + * A permission about to be asked: "allow" answers it before OpenCode makes a request, so no prompt + * is ever drawn; anything else leaves it to the person. OpenCode 2 only — v1's `permission.ask` + * hook is declared and never called, so a feature answers there from the interface instead. A + * throw, or a hook that never settles, must never cost the request: it is logged and left asked. + */ + permission?: (request: PermissionAsk) => Promise<"allow" | undefined> sessionDeleted?: (sessionID: string) => Promise /** Every event, as the host sends it: v1's `{ type, properties }`, v2's `{ type, data }`. */ event?: (event: unknown) => Promise | void @@ -162,6 +207,42 @@ export function composeParts(parts: ServerParts[]): ServerParts { }, } : {}), + ...(any("toolBefore") + ? { + toolBefore: async (call: ToolStart) => { + let failed: { error: unknown } | undefined + for (const part of parts) { + try { + await part.toolBefore?.(call) + } catch (error) { + failed ??= { error } + } + } + if (failed) throw failed.error + }, + } + : {}), + ...(any("permission") + ? { + /** + * Allowed when any feature allows it: a feature can only answer for the person, never + * refuse for them, so one saying nothing leaves the next free to answer. A failure is + * reported only when nobody answered. + */ + permission: async (request: PermissionAsk) => { + let failed: { error: unknown } | undefined + for (const part of parts) { + try { + if ((await part.permission?.(request)) === "allow") return "allow" as const + } catch (error) { + failed ??= { error } + } + } + if (failed) throw failed.error + return undefined + }, + } + : {}), ...(any("sessionDeleted") ? { sessionDeleted: async (sessionID: string) => { diff --git a/packages/client/src/opencode/server/v1.ts b/packages/client/src/opencode/server/v1.ts index af3a47cb..de0af9f1 100644 --- a/packages/client/src/opencode/server/v1.ts +++ b/packages/client/src/opencode/server/v1.ts @@ -119,6 +119,11 @@ export function addToV1Config(config: V1Config, parts: Pick void): Promise /** 2.0.18: after every tool call, Code Mode's inner calls included (docs/opencode/trail-server.md). */ hook?(name: "execute.after", run: (event: V2ToolAfter) => unknown): Promise + /** + * 2.0.18: before every tool call runs — and so before any permission it asks, which a tool asks + * from inside its own run. The input is the object the tool is then given. + */ + hook?(name: "execute.before", run: (event: V2ToolBefore) => unknown): Promise + } + /** + * 2.0.18's permission domain. `evaluate` runs inside `Permission.evaluate`, after config's rules and + * before a request is made or published; setting `effect` decides it (OpenCode's own policy plugin + * sets `"deny"`). Read from the 2.0.18 binary (docs/opencode/permissions.md). + */ + permission?: { + hook(name: "evaluate", run: (event: V2PermissionEvaluate) => unknown): Promise } /** 2.0.15's skill registry: an added skill is offered to the model like the user's own. */ skill?: { transform(edit: (editor: V2SkillEditor) => void): Promise } @@ -51,6 +64,57 @@ export interface V2ToolAfter { error?: unknown } +/** What v2's `execute.before` hands a plugin, read from 2.0.18. */ +export interface V2ToolBefore { + tool: string + sessionID: string + agent?: string + messageID?: string + id: string + input?: unknown +} + +/** + * What v2's permission `evaluate` hands a plugin, read from 2.0.18: the request about to be made, + * without its id (none exists yet) or its `save`. `effect` is what config decided — `"ask"` or + * `"allow"`; a `deny` never gets here — and what the hook leaves in it is what OpenCode does. + */ +export interface V2PermissionEvaluate { + sessionID: string + agent?: string + action: string + resources: readonly string[] + metadata?: unknown + source?: { type?: string; messageID?: string; id?: string } + effect: "ask" | "allow" | "deny" + message?: string +} + +/** A v2 `execute.before` as a `ToolStart`. */ +export function v2ToolStart(event: V2ToolBefore): ToolStart { + return { + sessionID: event.sessionID, + tool: event.tool, + callID: event.id, + args: event.input, + ...(event.agent ? { agent: event.agent } : {}), + ...(event.messageID ? { messageID: event.messageID } : {}), + } +} + +/** A v2 `evaluate` as a `PermissionAsk`: the call it came from only when a tool asked. */ +export function v2PermissionAsk(event: V2PermissionEvaluate): PermissionAsk { + const tool = event.source?.type === "tool" ? event.source : undefined + return { + sessionID: event.sessionID, + action: event.action, + resources: Array.isArray(event.resources) ? [...event.resources] : [], + ...(event.agent ? { agent: event.agent } : {}), + ...(tool?.id ? { callID: tool.id } : {}), + ...(tool?.messageID ? { messageID: tool.messageID } : {}), + } +} + /** * Code Mode's outer call. On v2 a plugin or MCP tool is called from inside `execute`, and the hook * fires for both with the same id — the outer one carrying everything the code printed, `search(…)` diff --git a/packages/client/test/server.test.ts b/packages/client/test/server.test.ts index 5d40a17b..a430dcce 100644 --- a/packages/client/test/server.test.ts +++ b/packages/client/test/server.test.ts @@ -494,3 +494,133 @@ describe("toolAfter", () => { expect(heard).toEqual(["a", "b"]) }) }) + +describe("answering a permission before it is asked (OpenCode 2)", () => { + /** v2's fake with the two hooks this needs: `execute.before` and permission `evaluate`. */ + function withHooks() { + const fake = fakeV2() + const registered: string[] = [] + let before: ((event: unknown) => unknown) | undefined + let evaluate: ((event: { effect: string }) => unknown) | undefined + const ctx = { + ...fake.ctx, + tool: { + ...fake.ctx.tool, + hook: async (name: string, handler: (event: unknown) => unknown) => { + registered.push(name) + if (name === "execute.before") before = handler + }, + }, + permission: { + hook: async (name: string, handler: typeof evaluate) => { + registered.push(`permission.${name}`) + evaluate = handler + }, + }, + } + return { ctx, registered, before: () => before, evaluate: () => evaluate } + } + const asked = (effect = "ask") => ({ + sessionID: "ses_1", + agent: "build", + action: "shell", + resources: ["git status"], + source: { type: "tool", messageID: "msg_1", id: "call_1" }, + effect, + }) + + test("an allow is written into the evaluation: OpenCode makes no request, so draws no prompt", async () => { + const heard: unknown[] = [] + const fake = withHooks() + const entry = dualServer("cockpit.test", async () => ({ + toolBefore: (call) => void heard.push(call), + permission: async (request) => { + heard.push(request) + return "allow" + }, + })) + await entry.setup(fake.ctx as never) + expect(fake.registered).toEqual(["execute.before", "permission.evaluate"]) + await fake.before()?.({ + tool: "shell", + sessionID: "ses_1", + id: "call_1", + input: { command: "git status" }, + }) + const event = asked() + await fake.evaluate()?.(event) + expect(event.effect).toBe("allow") + expect(heard).toEqual([ + { sessionID: "ses_1", tool: "shell", callID: "call_1", args: { command: "git status" } }, + { + sessionID: "ses_1", + agent: "build", + action: "shell", + resources: ["git status"], + callID: "call_1", + messageID: "msg_1", + }, + ]) + }) + + test("a feature that fails, or says nothing, leaves it asked; what config allowed is not asked about", async () => { + let calls = 0 + const fake = withHooks() + const entry = dualServer("cockpit.test", async () => ({ + toolBefore: () => { + throw new Error("listener broke") + }, + permission: async () => { + calls++ + if (calls === 1) throw new Error("ledger broke") + return undefined + }, + })) + await entry.setup(fake.ctx as never) + await fake.before()?.({ tool: "shell", sessionID: "ses_1", id: "call_1", input: {} }) + for (const _ of [1, 2]) { + const event = asked() + await fake.evaluate()?.(event) + expect(event.effect).toBe("ask") + } + const allowed = asked("allow") + await fake.evaluate()?.(allowed) + expect(calls).toBe(2) + }) + + test("an OpenCode without the hooks loads the feature all the same", async () => { + const entry = dualServer("cockpit.test", async () => ({ permission: async () => "allow" as const })) + await expect(entry.setup(fakeV2().ctx as never)).resolves.toBeDefined() + }) + + test("composed: allowed when any feature allows, a failure reported only when none did", async () => { + const order: string[] = [] + const allows = composeParts([ + { + permission: async () => { + order.push("a") + throw new Error("a broke") + }, + }, + { + permission: async () => { + order.push("b") + return "allow" + }, + }, + { permission: async () => void order.push("c") }, + ]) + const request = { sessionID: "s", action: "shell", resources: [] } + expect(await allows.permission?.(request)).toBe("allow") + expect(order).toEqual(["a", "b"]) + const none = composeParts([ + { permission: async () => undefined }, + { + permission: async () => { + throw new Error("broke") + }, + }, + ]) + await expect(none.permission?.(request) ?? Promise.resolve()).rejects.toThrow("broke") + }) +}) diff --git a/packages/opencode/src/server.ts b/packages/opencode/src/server.ts index 1a10eee0..66ec5aae 100644 --- a/packages/opencode/src/server.ts +++ b/packages/opencode/src/server.ts @@ -4,6 +4,7 @@ import { createShellServer } from "@opencode-cockpit/shell/server" import { createStatusServer } from "@opencode-cockpit/status/server" import { createSubagentsServer } from "@opencode-cockpit/subagents/server" import { createTrailServer } from "@opencode-cockpit/trail/server" +import { createTrustServer } from "@opencode-cockpit/trust/server" import { BUNDLE, type CockpitOptions, featureOptions, isEnabled } from "./features.ts" const shell = createShellServer({ source: BUNDLE }) @@ -11,6 +12,7 @@ const status = createStatusServer({ source: BUNDLE }) const review = createReviewServer({ source: BUNDLE }) const subagents = createSubagentsServer({ source: BUNDLE }) const trail = createTrailServer({ source: BUNDLE }) +const trust = createTrustServer({ source: BUNDLE }) export default dualServer(BUNDLE, async (host, rawOptions) => { const options = rawOptions as CockpitOptions | undefined @@ -21,5 +23,7 @@ export default dualServer(BUNDLE, async (host, rawOptions) => { if (isEnabled(options, "review")) parts.push(await review(host, featureOptions(options, "review"))) if (isEnabled(options, "subagents")) parts.push(await subagents(host, featureOptions(options, "subagents"))) if (isEnabled(options, "trail")) parts.push(await trail(host, featureOptions(options, "trail"))) + /** On OpenCode 2, Trust answers before a prompt is drawn (trust/src/agent/answer.ts). */ + if (isEnabled(options, "trust")) parts.push(await trust(host, featureOptions(options, "trust"))) return composeParts(parts) }) diff --git a/packages/subagents/README.md b/packages/subagents/README.md index d892dae6..dcc7a597 100644 --- a/packages/subagents/README.md +++ b/packages/subagents/README.md @@ -48,7 +48,8 @@ markdown too; the answer is markdown. | `e` | Open, or fold, every call | | `a` | A call's whole output and arguments — open shows the first 60 lines, whole up to 2,000 | | `t` | Show or hide thinking — shown by default, and remembered | -| `m` | Write it a message, at the foot of the pane (pasting works) | +| `tab` · a click on the box | Write it a message in the box at the foot — `enter` sends, `esc` gives the keys back and keeps the draft, `←` `→` move the cursor, `ctrl+u` clears what is before it (pasting works) | +| `y` `Y` `n` · a click | Held on a permission: allow it once, allow it always, or reject it, from the card above the box | | `x` | Stop it (press twice) — or, once it has finished, remove it from the list | | `X` | Remove every finished subagent from the list | | `b` | Move it to the background, so the main agent carries on (OpenCode's own `ctrl+b`) | @@ -69,7 +70,16 @@ pane under a "Round N" rule. The main agent has three tools of its own: | `subagents_read id [after]` | One subagent in full: why it stopped, its task, its whole final answer, and every call it made with what it was about and how it ended — paged with a cursor. Works on cancelled ones, which can be continued | | `subagents_wait [ids] [any] [timeoutSeconds]` | Blocks until background subagents finish, fail, are cancelled or stop on a permission — never longer than its timeout — and returns each one's state and answer | -**Message it.** A subagent that is still working picks your message up in its current run and answers +**Answer it.** A subagent held on a permission shows it as a card at the foot of its pane, over the +message box, the way OpenCode's own prompt does: what it asks for — a shell call's whole command line +— and `[y] Allow once`, `[Y] Allow always`, `[n] Reject`, each a key or a click. With several waiting +the card says `1 of 3` and moves on as each is answered. It answers the same request OpenCode's prompt +shows, so that one goes too; one the host refuses comes back on the card with why. Not `enter`: it +opens the item under the cursor, and one press too many would have approved a command. + +**Message it.** The box at the foot is always there, with the agent, its model and whose model it is +under it. `tab` or a click puts the keys in it; `enter` sends and leaves you typing, as OpenCode's +prompt does; `esc` gives the keys back to the pane and keeps what you wrote. A subagent that is still working picks your message up in its current run and answers it in its report, so the main agent sees it too. A finished one wakes up and answers you, and Cockpit adds the exchange to the main conversation without starting a turn there, so the main agent knows it next time. A message it finished without reading comes back to the field. diff --git a/packages/subagents/src/cli/preview.ts b/packages/subagents/src/cli/preview.ts index fd58f46f..5b5c8433 100644 --- a/packages/subagents/src/cli/preview.ts +++ b/packages/subagents/src/cli/preview.ts @@ -96,7 +96,7 @@ if (args.includes("--help") || args.includes("-h")) { [ "Usage: subagents preview [--width ] [--fixture ] [--columns ]", " [--state closed|open|whole] [--widths 24,30,36,50] [--notice] [--hide]", - " [--keys]", + " [--keys] [--foot]", "", ...Object.entries(FIXTURES).map(([name, { about }]) => ` ${name.padEnd(10)}${about}`), " calls a call of every kind: thinking as markdown, todos, an MCP tool, a task,", @@ -107,6 +107,7 @@ if (args.includes("--help") || args.includes("-h")) { " --notice the block with a settings notice in it", " --hide hideWhenEmpty: an empty block draws nothing", " --keys the pane's [?] Keys screen", + " --foot the foot of the pane: a permission card, and the message box at rest and typing", "", ].join("\n"), ) @@ -213,6 +214,28 @@ if (first) { process.stdout.write(`${out.join("\n")}\n\n`) process.exit(0) } + if (args.includes("--foot")) { + const held = nodes.find(({ session }) => session.pending.length > 0)?.session ?? first + /** The foot only: the rows under the run, which is what the card and the box change. */ + const foot = (title: string, input: Partial[0]>) => { + out.push("", title, "") + const screen = screenRows({ ...base, session: held, height: 24, ...input }) + for (const row of screen.rows.slice(screen.bodyAt + 8)) out.push(paint(row)) + } + const second = held.pending[0] && { ...held.pending[0], id: "per_second" } + foot(`Held on a permission — ${columns} columns`, {}) + if (second) + foot("Two pending, the last answer refused", { + pending: [...held.pending, second], + replyError: "Permission request not found", + }) + foot("Typing to it", { + pending: [], + prompt: { draft: "skip the e2e suite", caret: 9, focused: true, busy: true }, + }) + process.stdout.write(`${out.join("\n")}\n\n`) + process.exit(0) + } out.push("", "The pane — the first subagent", "") const folded = screenRows(base) for (const row of folded.rows) out.push(paint(row)) diff --git a/packages/subagents/src/core/adapt/v1.ts b/packages/subagents/src/core/adapt/v1.ts index 17d0cbfc..48356663 100644 --- a/packages/subagents/src/core/adapt/v1.ts +++ b/packages/subagents/src/core/adapt/v1.ts @@ -16,6 +16,9 @@ import type { Change, ToolState } from "../model/changes.ts" type Json = Record const obj = (value: unknown): Json => (value && typeof value === "object" ? (value as Json) : {}) const str = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined) +/** The strings of a list; anything else in it, or no list, is nothing. */ +export const strings = (value: unknown): string[] => + Array.isArray(value) ? value.filter((each): each is string => typeof each === "string") : [] const TOOL_STATES = new Set(["pending", "running", "completed", "failed"]) const toolState = (status: unknown): ToolState | undefined => { @@ -55,6 +58,7 @@ export function createV1Translator(unknown: (what: string, detail?: Json) => voi ...(agent ? { agent } : {}), ...(str(info.title) ? { title: stripAgentSuffix(str(info.title) as string) } : {}), ...(denied(info.permission) ? { denied: denied(info.permission) as string[] } : {}), + ...modelOf(obj(info.model)), at: Number(obj(info.time).created) || at, }, ] @@ -145,6 +149,8 @@ export function createV1Translator(unknown: (what: string, detail?: Json) => voi type: "session", id, model: str(info.modelID) as string, + ...(str(info.providerID) ? { provider: str(info.providerID) as string } : {}), + ...(str(info.variant) ? { variant: str(info.variant) as string } : {}), at: Number(obj(info.time).created) || at, }, ] @@ -176,10 +182,38 @@ export function createV1Translator(unknown: (what: string, detail?: Json) => voi const message = str(obj(error.data).message) ?? str(error.name) ?? "failed" return [{ type: "status", id: sessionID, status: "failed", error: message, at }] } - case "permission.asked": + /** + * The request itself is the event's properties: `{ id, sessionID, permission, patterns, + * always, tool: { messageID, callID } }`; its answer names it as `requestID`. + */ + case "permission.asked": { + const request = str(props.id) + if (!sessionID || !request) return [] + const tool = obj(props.tool) + return [ + { + type: "permission", + id: sessionID, + request, + state: "asked", + ...(str(props.permission) ? { permission: str(props.permission) as string } : {}), + patterns: strings(props.patterns), + always: strings(props.always), + ...(str(tool.callID) ? { call: str(tool.callID) as string } : {}), + ...(str(tool.messageID) ? { message: str(tool.messageID) as string } : {}), + at, + }, + ] + } + case "permission.replied": { + const request = str(props.requestID) + if (!sessionID) return [] + return request + ? [{ type: "permission", id: sessionID, request, state: "replied", at }] + : [{ type: "status", id: sessionID, status: "busy", at }] + } case "question.asked": return sessionID ? [{ type: "status", id: sessionID, status: "waiting", at }] : [] - case "permission.replied": case "question.replied": case "question.rejected": return sessionID ? [{ type: "status", id: sessionID, status: "busy", at }] : [] @@ -237,6 +271,18 @@ export function createV1Translator(unknown: (what: string, detail?: Json) => voi } } +/** + * A session's `model: { id, providerID, variant }` — both OpenCodes store it so on the session, and + * OpenCode 2 on each assistant message too: what the pane's prompt names under the box. + */ +export function modelOf(model: Json): { model?: string; provider?: string; variant?: string } { + return { + ...(str(model.id) ? { model: str(model.id) as string } : {}), + ...(str(model.providerID) ? { provider: str(model.providerID) as string } : {}), + ...(str(model.variant) ? { variant: str(model.variant) as string } : {}), + } +} + /** `input + output + reasoning + cache`, the host's own total. */ export function tokenTotal(tokens: Json): number { const cache = obj(tokens.cache) diff --git a/packages/subagents/src/core/adapt/v2.ts b/packages/subagents/src/core/adapt/v2.ts index f732a986..649b27f3 100644 --- a/packages/subagents/src/core/adapt/v2.ts +++ b/packages/subagents/src/core/adapt/v2.ts @@ -9,7 +9,7 @@ */ import type { Change } from "../model/changes.ts" -import { summaryOf, tokenTotal } from "./v1.ts" +import { modelOf, strings, summaryOf, tokenTotal } from "./v1.ts" type Json = Record const obj = (value: unknown): Json => (value && typeof value === "object" ? (value as Json) : {}) @@ -45,7 +45,7 @@ export function createV2Translator(unknown: (what: string, detail?: Json) => voi ...(str(info.parentID) ? { parentID: str(info.parentID) as string } : {}), ...(str(info.agent) ? { agent: str(info.agent) as string } : {}), ...(str(info.title) ? { title: str(info.title) as string } : {}), - ...(str(obj(info.model).id) ? { model: str(obj(info.model).id) as string } : {}), + ...modelOf(obj(info.model)), at: Number(obj(info.time).created) || at, }, ] @@ -209,6 +209,40 @@ export function createV2Translator(unknown: (what: string, detail?: Json) => voi : [] case "session.status": return id ? statusChange(id, data.status, at) : [] + /** Each step names the model it runs on: the latest is the one the prompt names. */ + case "session.step.started": { + const model = modelOf(obj(data.model)) + return id && model.model ? [{ type: "session", id, ...model, at }] : [] + } + /** + * `{ id, sessionID, action: "shell", resources, save, source: { type: "tool", messageID, id } }` + * (docs/opencode/permissions.md); answered by anyone, `{ sessionID, requestID, reply }`. + */ + case "permission.asked": { + const request = str(data.id) + if (!id || !request) return [] + const source = obj(data.source) + const call = source.type === "tool" ? str(source.id) : undefined + const permission = str(data.action) ?? str(data.permission) + return [ + { + type: "permission", + id, + request, + state: "asked", + ...(permission ? { permission } : {}), + patterns: strings(data.resources ?? data.patterns), + always: strings(data.save ?? data.always), + ...(call ? { call } : {}), + ...(str(source.messageID) ? { message: str(source.messageID) as string } : {}), + at, + }, + ] + } + case "permission.replied": + return id && str(data.requestID) + ? [{ type: "permission", id, request: str(data.requestID) as string, state: "replied", at }] + : [] default: // Deltas of kinds we do not draw, bookkeeping, and everything not about a session. if ( @@ -238,7 +272,7 @@ export function createV2Translator(unknown: (what: string, detail?: Json) => voi if (message.type !== "assistant") continue out.push({ type: "step", id, at: when }) if (str(obj(message.model).id)) - out.push({ type: "session", id, model: str(obj(message.model).id) as string, at: when }) + out.push({ type: "session", id, ...modelOf(obj(message.model)), at: when }) const content = Array.isArray(message.content) ? message.content : [] /** Live events number thinking and text blocks separately; the keys must match across a reload. */ let thought = 0 diff --git a/packages/subagents/src/core/model/changes.ts b/packages/subagents/src/core/model/changes.ts index 5edafaf7..e757fd3f 100644 --- a/packages/subagents/src/core/model/changes.ts +++ b/packages/subagents/src/core/model/changes.ts @@ -21,6 +21,9 @@ export type Change = title?: string /** The model it runs on, as the host names it: `space-bunny-free`. */ model?: string + /** Whose model that is (`opencode`), and the variant it runs at (`high`), when the host says. */ + provider?: string + variant?: string /** Launched in the background: the main agent carried on without waiting for it. */ background?: boolean /** Permissions its agent's rules deny it outright (OpenCode 1 says; OpenCode 2 does not). */ @@ -69,6 +72,26 @@ export type Change = } /** Running totals for the session, as the host keeps them. */ | { type: "usage"; id: string; tokens?: number; cost?: number; at: number } + /** + * A permission it is held on, asked — or answered, by anyone: OpenCode's own prompt, Trust, the + * pane. `request` is the host's id for it, which a reply names. Only `asked` carries what it is for. + */ + | { + type: "permission" + id: string + request: string + state: "asked" | "replied" + /** As the host names it: `bash` on OpenCode 1, `shell` on 2. */ + permission?: string + /** What it would act on: the commands, the paths. */ + patterns?: string[] + /** What "allow always" would remember. */ + always?: string[] + /** The tool call that asked, so the card can say its whole command, and the message it is in. */ + call?: string + message?: string + at: number + } /** OpenCode 2 starts every subagent's task with this line; the task is what follows it. */ export const SUBAGENT_PREAMBLE = "You are a subagent spawned by another session." diff --git a/packages/subagents/src/core/model/model.ts b/packages/subagents/src/core/model/model.ts index e2841508..c318bfa2 100644 --- a/packages/subagents/src/core/model/model.ts +++ b/packages/subagents/src/core/model/model.ts @@ -29,6 +29,20 @@ export type Entry = ended?: number } +/** A permission a session is held on, until someone answers it or the session ends. */ +export interface Permission { + /** The host's id for the request: what a reply names. */ + id: string + sessionID: string + /** As the host names it: `bash` on OpenCode 1, `shell` on 2. */ + permission: string + patterns: string[] + always: string[] + call?: string + message?: string + at: number +} + export interface Session { id: string parentID?: string @@ -47,8 +61,13 @@ export interface Session { tokens: number cost: number model?: string + /** Whose model it is and the variant it runs at, when the host says: the prompt's line names them. */ + provider?: string + variant?: string background?: boolean denied: string[] + /** Permissions it is held on, oldest first: the pane's card answers the first. */ + pending: Permission[] steps: number /** When anything was last heard about it: a run gone quiet this long is asked about. */ seen: number @@ -79,6 +98,7 @@ function session(model: Model, id: string, at: number): Session { tokens: 0, cost: 0, denied: [], + pending: [], steps: 0, seen: at, } @@ -127,6 +147,8 @@ function applyStatus(s: Session, change: Extract): v * told us — without this its spinner turned forever, and the sidebar said it was still at it. */ function settle(s: Session, at: number): void { + /** Nothing is held on a permission once the run is over: the host drops the request with it. */ + s.pending = [] for (const entry of s.entries) { if (entry.kind === "tool" && (entry.state === "running" || entry.state === "pending")) { entry.state = "failed" @@ -188,6 +210,8 @@ export function apply(model: Model, change: Change): void { if (change.agent) s.agent = change.agent if (change.title) s.title = change.title if (change.model) s.model = change.model + if (change.provider) s.provider = change.provider + if (change.variant) s.variant = change.variant if (change.background) s.background = true if (change.denied) s.denied = change.denied /** A session's own creation time beats when we first heard of it. */ @@ -260,6 +284,32 @@ export function apply(model: Model, change: Change): void { if (change.tokens !== undefined) s.tokens = change.tokens if (change.cost !== undefined) s.cost = change.cost return + case "permission": { + const had = s.pending.length + s.pending = s.pending.filter((each) => each.id !== change.request) + if (change.state === "asked") { + s.pending.push({ + id: change.request, + sessionID: s.id, + permission: change.permission ?? "permission", + patterns: change.patterns ?? [], + always: change.always ?? [], + ...(change.call ? { call: change.call } : {}), + ...(change.message ? { message: change.message } : {}), + at: change.at, + }) + apply(model, { type: "status", id: s.id, status: "waiting", at: change.at }) + return + } + /** + * Answered: back to work once nothing else is pending. Two parallel calls ask twice, and one + * answer used to read as the whole run going again while the other still waited. A reply to a + * request we never heard of (it was asked before we loaded) frees it as it always did. + */ + if (s.pending.length === 0 && (had > 0 || s.status === "waiting")) + apply(model, { type: "status", id: s.id, status: "busy", at: change.at }) + return + } } } diff --git a/packages/subagents/src/core/sample.ts b/packages/subagents/src/core/sample.ts index a42fbfba..1d0fae47 100644 --- a/packages/subagents/src/core/sample.ts +++ b/packages/subagents/src/core/sample.ts @@ -180,7 +180,17 @@ export function sample(): Change[] { input: { command: "npm test -- login" }, at: t(58), }, - { type: "status", id: "ses_login", status: "waiting", at: t(58) }, + { + type: "permission", + id: "ses_login", + request: "per_login_test", + state: "asked", + permission: "bash", + patterns: ["npm test -- login"], + always: ["npm test *"], + call: "l3", + at: t(58), + }, { type: "usage", id: "ses_login", tokens: 3900, cost: 0.003, at: t(58) }, { diff --git a/packages/subagents/src/core/view/foot.ts b/packages/subagents/src/core/view/foot.ts new file mode 100644 index 00000000..4af34090 --- /dev/null +++ b/packages/subagents/src/core/view/foot.ts @@ -0,0 +1,217 @@ +/** + * The foot of the pane, pinned under the run so nothing scrolls it away: a permission the subagent + * is held on, as a card you answer from here, and the box you message it from — OpenCode's own + * prompt, in the pane. + * + * ▎ + * ▎ △ Permission required · bash 1 of 2 + * ▎ $ npm test -- login + * ▎ + * ▎ y Allow once Y Allow always n Reject + * + * ┃ Message explore… + * ┃ + * ┃ Explore · space-bunny-free opencode + * + * Before the card, a subagent that asked while you watched it had to be closed to be answered: the + * only prompt was OpenCode's, under the pane. Pure, like everything in `core/`. Each row comes with + * the parts of it a click acts on (`Hit`): a click on `Allow once` answers as `y` does, and one on + * the box starts typing as `tab` does. + */ + +import { HINT_GAP, type Hint } from "@opencode-cockpit/client/design" +import type { Permission, Session } from "../model/model.ts" +import { PAD } from "./calls.ts" +import { fit, type Row, type Run, spread, widthOf, wrap } from "./rows.ts" + +/** What a click on part of the foot does. */ +export type Act = "focus" | "once" | "always" | "reject" + +/** The columns of a row a click acts on, `from` up to but not including `to`. */ +export interface Hit { + act: Act + from: number + to: number +} + +export interface FootRow { + row: Row + hits?: Hit[] +} + +/** The message box: what is typed so far, where the cursor is in it, and whether keys go into it. */ +export interface Prompt { + draft: string + /** The cursor, as an index into `draft`; its end when unset. */ + caret?: number + focused: boolean + /** It is working: what you send is picked up in its current run. */ + busy: boolean +} + +/** + * The keys the card answers with, and what each sends. Not `enter`: it opens the item under the + * cursor, and one press too many would have approved a command. Not `a`: it already shows a call's + * whole output. `y` and `n` are free in the pane and say yes and no; `Y` is the bigger yes. + */ +export const PERMISSION_KEYS: readonly (Hint & { act: Exclude })[] = [ + { key: "y", label: "Allow once", act: "once" }, + { key: "Y", label: "Allow always", act: "always" }, + { key: "n", label: "Reject", act: "reject" }, +] + +/** Lines of a command the card shows before it cuts the rest: enough to read what will run. */ +export const COMMAND_LINES = 4 + +/** + * What the request would run or touch: the call's whole command line when the run has it — the + * request's own patterns are split per command (`cd x && make` asks twice) — else those patterns. + */ +export function askedFor(session: Session, ask: Permission): string { + const call = ask.call + ? session.entries.find((entry) => entry.kind === "tool" && entry.call === ask.call) + : undefined + const command = call?.kind === "tool" ? call.input.command : undefined + if (typeof command === "string" && command.trim()) return `$ ${command.trim()}` + return ask.patterns.join(" ") +} + +/** + * The card for the first of `pending`, in the warning tone OpenCode's own prompt wears. `error` is + * what the host said when the last answer to it failed: it stays until an answer goes through. + */ +export function permissionRows( + session: Session, + pending: readonly Permission[], + width: number, + options: { lines?: number; error?: string; roomy?: boolean } = {}, +): FootRow[] { + const ask = pending[0] + if (!ask) return [] + const fill = "card" as const + const bar: Run = { text: `${PAD}▎ `, tone: "warning", fill } + /** + * A row of air above and below, and between the command and the keys, as OpenCode's own card has: + * packed edge to edge under a tool block and over the box, three panels read as one wall (seen on + * the first real run). A short pane gives it up first. + */ + const air: FootRow = { row: fit([bar], width) } + const roomy = options.roomy !== false + const rows: FootRow[] = [ + ...(roomy ? [air] : []), + { + row: spread( + [ + bar, + { text: "△ Permission required", tone: "warning", bold: true, fill }, + { text: ` · ${ask.permission}`, tone: "muted", fill }, + ], + pending.length > 1 ? [{ text: `1 of ${pending.length} `, tone: "muted", fill }] : [], + width, + ), + }, + ] + const most = Math.max(1, options.lines ?? COMMAND_LINES) + const text = askedFor(session, ask) + const lines = text ? wrap(text, width - PAD.length - 3) : [] + lines.slice(0, most).forEach((line, at) => { + const last = at === most - 1 && lines.length > most + rows.push({ row: fit([bar, { text: last ? `${line}…` : line, tone: "text", fill }], width) }) + }) + if (options.error) + rows.push({ row: fit([bar, { text: `Not answered: ${options.error}`, tone: "error", fill }], width) }) + if (roomy) rows.push(air) + /** The keys, each one a place to click: its columns are counted as the row is laid out. */ + const runs: Run[] = [bar] + const hits: Hit[] = [] + let used = widthOf(bar.text) + PERMISSION_KEYS.forEach((hint, at) => { + if (at > 0) { + runs.push({ text: " ".repeat(HINT_GAP), fill }) + used += HINT_GAP + } + /** The key in the card's own tone, its label plain: the accent is the box's, not the card's. */ + const parts: Run[] = [ + { text: hint.key, tone: "warning", bold: true, fill }, + { text: ` ${hint.label}`, tone: "text", fill }, + ] + const size = parts.reduce((sum, run) => sum + widthOf(run.text), 0) + runs.push(...parts) + if (used < width) hits.push({ act: hint.act, from: used, to: Math.min(width, used + size) }) + used += size + }) + rows.push({ row: fit(runs, width), hits }) + if (roomy) rows.push(air) + return rows +} + +/** `explore` as OpenCode's prompt names an agent under its box: `Explore`. */ +const named = (agent: string) => agent.charAt(0).toUpperCase() + agent.slice(1) + +/** + * The message box, as OpenCode draws its own: an accent edge, the line you type on, and under it + * the agent, its model and whose model that is. Unfocused it says how to start; focused, the cursor + * is where you type. One line wide: a draft longer than the box shows the part around the cursor. + */ +export function promptRows( + session: Session, + prompt: Prompt, + width: number, + options: { spacer?: boolean; meta?: boolean } = {}, +): FootRow[] { + const fill = "block" as const + const bar: Run = { text: `${PAD}┃ `, tone: "accent", fill } + const whole: Hit[] = [{ act: "focus", from: 0, to: width }] + const placeholder = `Message ${session.agent}…` + const room = Math.max(4, width - widthOf(bar.text) - 1) + let line: Row + if (prompt.focused) { + const caret = Math.max(0, Math.min(prompt.draft.length, prompt.caret ?? prompt.draft.length)) + /** The part of a long draft around the cursor, marked `…` where it was cut. */ + const start = Math.max(0, caret - (room - 2)) + const before = (start > 0 ? "…" : "") + prompt.draft.slice(start, caret) + const after = prompt.draft.slice(caret) + line = fit( + [ + bar, + { text: before, tone: "text", fill }, + { text: "▍", tone: "accent", fill }, + prompt.draft === "" + ? { text: placeholder, tone: "muted", fill } + : { text: after, tone: "text", fill }, + ], + width, + ) + } else { + /** How to start is the footer's `[tab] Message`; said here too, it was one hint twice. */ + line = fit( + [ + bar, + prompt.draft + ? { text: prompt.draft, tone: "text", fill } + : { text: placeholder, tone: "muted", fill }, + ], + width, + ) + } + const rows: FootRow[] = [{ row: line, hits: whole }] + if (options.spacer !== false) rows.push({ row: fit([bar], width), hits: whole }) + if (options.meta !== false) { + /** The variant OpenCode runs everything at by default is not news; one you chose is. */ + const variant = session.variant && session.variant !== "default" ? session.variant : undefined + rows.push({ + row: fit( + [ + bar, + { text: named(session.agent), tone: "accent", bold: true, fill }, + ...(session.model ? [{ text: ` · ${session.model}`, tone: "text" as const, fill }] : []), + ...(session.provider ? [{ text: ` ${session.provider}`, tone: "muted" as const, fill }] : []), + ...(variant ? [{ text: ` · ${variant}`, tone: "muted" as const, fill }] : []), + ], + width, + ), + hits: whole, + }) + } + return rows +} diff --git a/packages/subagents/src/core/view/screen.ts b/packages/subagents/src/core/view/screen.ts index 5f2b8837..c49a5edc 100644 --- a/packages/subagents/src/core/view/screen.ts +++ b/packages/subagents/src/core/view/screen.ts @@ -34,7 +34,7 @@ import { stateMark, toneOf, } from "@opencode-cockpit/client/design" -import { callsOf, type Entry, type Node, type Session, titleOf } from "../model/model.ts" +import { callsOf, type Entry, type Node, type Permission, type Session, titleOf } from "../model/model.ts" import { boxed, cardLines, @@ -49,6 +49,15 @@ import { toolLines, trimEnd, } from "./calls.ts" +import { + COMMAND_LINES, + type FootRow, + type Hit, + PERMISSION_KEYS, + type Prompt, + permissionRows, + promptRows, +} from "./foot.ts" import { markdownRows } from "./markdown.ts" import { compact, cut, fit, type Row, type Run, rowText, spread, widthOf, wrap } from "./rows.ts" import { firstStarted, runPhrase, stateOf } from "./sidebar.ts" @@ -82,8 +91,15 @@ export interface ScreenInput { details: boolean /** `?`: every key the pane takes, in the body's place. */ keys?: boolean - /** A message being typed at the bottom of the pane. */ - input?: { draft: string; busy: boolean } + /** The message box at the foot: always drawn; unset, it is empty and not focused. */ + prompt?: Prompt + /** + * Permissions it is held on that the card offers: the session's own, less any answered from here + * and not yet heard back about. Unset, every one the session has. + */ + pending?: readonly Permission[] + /** What the host said when the last answer to the card's request failed. */ + replyError?: string /** A line under the keys: what just happened. */ notice?: string /** What each item drew last time, kept by the caller between paints (`createScreenCache`). */ @@ -122,6 +138,8 @@ export interface Screen { bodyAt: number /** A `task` call's item, and the subagent it launched: `enter` on it goes there. */ links: Map + /** For each row, the parts of it a click acts on: the card's answers, the message box. */ + hits: (Hit[] | undefined)[] } /** @@ -357,8 +375,11 @@ function header(input: ScreenInput, width: number): Row[] { const each = Math.max(12, Math.floor((width - 14) / nodes.length) - 3) for (const node of nodes) { const current = node.session.id === session.id - const label = cut(`${node.session.agent} ${titleOf(node.session)}`, each) + /** One held on a permission says so here too: it may be the one you are not looking at. */ + const held = node.session.status === "waiting" + const label = cut(`${node.session.agent} ${titleOf(node.session)}`, held ? each - 2 : each) runs.push( + ...(held ? [{ text: "△ ", tone: "warning" as const, fill: "band" as const }] : []), { text: label, tone: current ? "accent" : "muted", bold: current, fill: "band" }, { text: " ", fill: "band" }, ) @@ -368,62 +389,83 @@ function header(input: ScreenInput, width: number): Row[] { return rows } -function footer(input: ScreenInput, width: number): Row[] { +/** + * How much of the foot a short pane keeps. At 0 everything: a command's first lines, room around + * the card and in the box. Each step gives up the least needed, so the run keeps rows to be read. + */ +type Tier = 0 | 1 | 2 + +/** Rows the body keeps before the foot starts giving way. */ +const BODY_MIN = 3 + +function footer(input: ScreenInput, width: number, tier: Tier): FootRow[] { const { session } = input - if (input.input) { - const hint = input.input.busy + /** On the keys screen the only key worth a row is the way back to the run (Review's, Trust's). */ + if (input.keys) { + const back: Run[] = [{ text: PAD }, ...fitHints([closeHint("Hide Keys")], width - PAD.length).runs] + return [{ row: fit(back, width) }, { row: fit([], width) }] + } + const prompt = input.prompt ?? { draft: "", focused: false, busy: false } + const rows: FootRow[] = [] + const card = permissionRows(session, input.pending ?? session.pending, width, { + lines: tier === 0 ? COMMAND_LINES : 1, + roomy: tier === 0, + ...(input.replyError ? { error: input.replyError } : {}), + }) + rows.push(...card) + if (card.length > 0 && tier === 0) rows.push({ row: fit([], width) }) + /** Under a card the box keeps to two rows: the card is what wants reading. */ + const spacer = tier === 0 && (card.length === 0 || prompt.focused) + rows.push(...promptRows(session, prompt, width, { spacer, meta: tier < 2 })) + /** Air between the box and the keys, so the hints do not read as the box's last line. */ + if (tier === 0) rows.push({ row: fit([], width) }) + if (prompt.focused) { + const hint = prompt.busy ? "it picks this up in its current run" : "it answers, and the main agent hears it" - return [ - fit( - [ - { text: `${PAD}┃ `, tone: "accent", fill: "block" }, - { text: input.input.draft, tone: "text", fill: "block" }, - { text: "▍", tone: "accent", fill: "block" }, - ], - width, - ), - /** The keys in the shape every footer uses; the note gives way to them. */ - spread( - [{ text: `${PAD} to ${session.agent} · ${hint}`, tone: "muted" }], + /** The keys in the shape every footer uses; the note gives way to them. */ + rows.push({ + row: spread( + [{ text: `${PAD}to ${session.agent} · ${hint}`, tone: "muted" }], [ ...hintRuns({ key: "enter", label: "Send" }), { text: " " }, - ...hintRuns(closeHint("Cancel")), + ...hintRuns(closeHint("Stop Typing")), { text: " " }, ], width, ), + }) + } else { + /** + * In the order drawn, each with its rank: at half width the lowest-ranked go first, and the way + * out never does — it used to be the lowest, so the first key a narrow pane lost was how to + * leave it. The cutting, the `…` and the shape are the ones every bay shares (client/design). + * `[?] Keys` gives way late: it is where every key the row dropped can still be found. + */ + const all: Hint[] = [ + { key: "j/k", label: "Select", priority: 5 }, + { key: "enter", label: "Open", priority: 9 }, + { key: "tab", label: "Message", priority: 8 }, + { key: "x", label: running(session) ? "Stop" : "Remove", priority: 7 }, + ...(running(session) && !session.background ? [{ key: "b", label: "Background", priority: 6 }] : []), + { key: "t", label: input.thinking ? "Hide Thinking" : "Show Thinking", priority: 3 }, + { key: "i", label: input.details ? "Timeline" : "Details", priority: 6 }, + { key: "w", label: "Width", priority: 4 }, + { key: "?", label: "Keys", priority: 8.5 }, + closeHint("Back"), ] + rows.push({ row: fit([{ text: PAD }, ...fitHints(all, width - PAD.length).runs], width) }) } - /** On the keys screen the only key worth a row is the way back to the run (Review's, Trust's). */ - if (input.keys) { - const back: Run[] = [{ text: PAD }, ...fitHints([closeHint("Hide Keys")], width - PAD.length).runs] - return [fit(back, width), fit([], width)] + if (tier < 2) { + const note = + input.notice ?? + (session.status === "done" && !prompt.focused + ? "Finished — message it; the main agent hears the answer." + : "") + rows.push({ row: fit([{ text: `${PAD}${note}`, tone: "muted" }], width) }) } - /** - * In the order drawn, each with its rank: at half width the lowest-ranked go first, and the way out - * never does — it used to be the lowest, so the first key a narrow pane lost was how to leave it. - * The cutting, the `…` and the shape are the ones every bay shares (client/design). `[?] Keys` - * gives way late: it is where every key the row dropped can still be found. - */ - const all: Hint[] = [ - { key: "j/k", label: "Select", priority: 5 }, - { key: "enter", label: "Open", priority: 9 }, - { key: "m", label: "Message", priority: 8 }, - { key: "x", label: running(session) ? "Stop" : "Remove", priority: 7 }, - ...(running(session) && !session.background ? [{ key: "b", label: "Background", priority: 6 }] : []), - { key: "t", label: input.thinking ? "Hide Thinking" : "Show Thinking", priority: 3 }, - { key: "i", label: input.details ? "Timeline" : "Details", priority: 6 }, - { key: "w", label: "Width", priority: 4 }, - { key: "?", label: "Keys", priority: 8.5 }, - closeHint("Back"), - ] - const keys: Run[] = [{ text: PAD }, ...fitHints(all, width - PAD.length).runs] - const note = - input.notice ?? - (session.status === "done" ? "Finished — message it; the main agent hears the answer." : "") - return [fit(keys, width), fit([{ text: `${PAD}${note}`, tone: "muted" }], width)] + return rows } export interface KeyLine { @@ -445,7 +487,18 @@ export const SUBAGENT_KEYS: readonly KeyLine[] = [ { keys: ["a"], does: "A call's whole output, or back to its first lines" }, { keys: ["e"], does: "Open every call, or fold them all" }, { keys: ["←/→"], does: "Previous or next subagent of this conversation (also [ and ])" }, - { keys: ["m"], does: "Message this subagent; finished, its answer is passed on to the main agent" }, + { + keys: ["tab"], + does: "Type to this subagent (or click the box); finished, its answer is passed on to the main agent", + }, + { + keys: ["ctrl+u"], + does: "While typing: clear what is before the cursor. ←/→ move it, enter sends, esc stops typing", + }, + { + keys: PERMISSION_KEYS.map((hint) => hint.key), + does: "Held on a permission: allow it once, allow it always, or reject it (or click the answer)", + }, { keys: ["x"], does: "Stop it (press twice), or once it has ended, take it off the list" }, { keys: ["X"], does: "Take every finished subagent off the list" }, { keys: ["b"], does: "Move it to the background: the main agent stops waiting for it" }, @@ -520,7 +573,15 @@ export function screenRows(input: ScreenInput): Screen { const width = Math.max(24, input.width) const height = Math.max(8, input.height) const top = header(input, width) - const bottom = footer(input, width) + let tier: Tier = 0 + let bottom = footer(input, width, tier) + while (tier < 2 && height - top.length - bottom.length - 2 < BODY_MIN) { + tier = (tier + 1) as Tier + bottom = footer(input, width, tier) + } + /** Shorter than any foot fits: its last rows, so the pane is still exactly its height. */ + const fits = Math.max(0, height - top.length - 3) + if (bottom.length > fits) bottom = bottom.slice(bottom.length - fits) const room = Math.max(1, height - top.length - bottom.length - 2) const opened: string[] = [] const links = new Map() @@ -560,7 +621,7 @@ export function screenRows(input: ScreenInput): Screen { while (shown.length < room) shown.push({ row: fit([], width) }) const blank = fit([], width) return { - rows: [...top, blank, ...shown.map((line) => line.row), blank, ...bottom], + rows: [...top, blank, ...shown.map((line) => line.row), blank, ...bottom.map((line) => line.row)], items: [ ...top.map(() => undefined), undefined, @@ -568,6 +629,13 @@ export function screenRows(input: ScreenInput): Screen { undefined, ...bottom.map(() => undefined), ], + hits: [ + ...top.map(() => undefined), + undefined, + ...shown.map(() => undefined), + undefined, + ...bottom.map((line) => line.hits), + ], keys, opened, top: first, diff --git a/packages/subagents/src/tui/answers.ts b/packages/subagents/src/tui/answers.ts new file mode 100644 index 00000000..e8744168 --- /dev/null +++ b/packages/subagents/src/tui/answers.ts @@ -0,0 +1,70 @@ +/** + * Answering a permission from the pane: the card at its foot (core/view/foot.ts) offers the first + * request the open subagent is held on, and `y`, `Y`, `n` or a click answer it through the host — + * the same request OpenCode's own prompt shows, so that prompt goes as well. Before this, a subagent + * that asked while you watched it had to be closed to be answered. + * + * An answer is taken as given the moment it is sent: the card moves on to the next request, or + * goes. If the host refuses it, the request comes back with what the host said, to be answered again. + */ + +import type { Log } from "@opencode-cockpit/client/log" +import type { Model, Permission, Session } from "../core/model/model.ts" +import type { Decision, Source } from "./source.ts" +import type { Surface } from "./surface.ts" + +/** What the card offers: the requests not already answered from here, and why the last answer failed. */ +export function cardOf( + session: Session, + replies: Surface["replies"], +): { pending: Permission[]; error?: string } { + const pending = session.pending.filter((ask) => { + const reply = replies.get(ask.id) + return !reply || reply.error !== undefined + }) + const error = pending[0] ? replies.get(pending[0].id)?.error : undefined + return { pending, ...(error ? { error } : {}) } +} + +export interface Answers { + /** Answers the card's request for the subagent open in the pane; nothing when it has none. */ + reply(decision: Decision): void +} + +export function createAnswers(input: { + log: Log + model: Model + surface: Surface + feed: () => Source + opened: () => Session | undefined + draw: () => void +}): Answers { + const { log, model, surface, opened, draw } = input + + /** Answers the host has since heard of — by anyone — are forgotten, so the map stays small. */ + const forget = () => { + const asked = new Set() + for (const session of model.sessions.values()) for (const ask of session.pending) asked.add(ask.id) + for (const id of surface.replies.keys()) if (!asked.has(id)) surface.replies.delete(id) + } + + const reply = (decision: Decision) => { + const session = opened() + const ask = session ? cardOf(session, surface.replies).pending[0] : undefined + if (!session || !ask) return + forget() + surface.replies.set(ask.id, {}) + log.info("permission answered", { id: session.id, request: ask.id, permission: ask.permission, decision }) + draw() + input + .feed() + .reply(ask, decision) + .catch((error: unknown) => { + log.warn("permission answer failed", { id: session.id, request: ask.id, error }) + surface.replies.set(ask.id, { error: error instanceof Error ? error.message : String(error) }) + }) + .finally(draw) + } + + return { reply } +} diff --git a/packages/subagents/src/tui/index.tsx b/packages/subagents/src/tui/index.tsx index fbb19747..aad2e9fb 100644 --- a/packages/subagents/src/tui/index.tsx +++ b/packages/subagents/src/tui/index.tsx @@ -16,6 +16,7 @@ import { noticeText } from "@opencode-cockpit/client/settings" import { loadSubagents, type SubagentsConfig } from "../core/config.ts" import type { Change } from "../core/model/changes.ts" import { applyAll, emptyModel, type Node, rootOf, type Session, subagentsOf } from "../core/model/model.ts" +import { createAnswers } from "./answers.ts" import { createMessages, type Messages, YOURS_KEY } from "./messages.ts" import { createPainter } from "./paint.ts" import { createPane, FULL_KEY, THINKING_KEY } from "./pane.ts" @@ -68,6 +69,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st opened: new Set(), closed: new Set(), whole: new Set(), + replies: new Map(), thinking: api.kv.get(THINKING_KEY, true), details: false, full: api.kv.get(FULL_KEY, false), @@ -193,6 +195,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st draw, set, }) + const answers = createAnswers({ log, model, surface, feed: () => feed, opened, draw }) const pane = createPane({ api, log, @@ -200,6 +203,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st surface, painter, messages, + answers, feed, hidden, saveHidden, @@ -265,7 +269,7 @@ export function createSubagentsTui({ source = SUBAGENTS_PACKAGE }: { source?: st onDismiss={() => { if (!surface.full) pane.close() }} - onClick={(y) => pane.click(y)} + onClick={(y, x) => pane.click(y, x)} onScroll={(delta) => pane.scroll(delta)} /> ), diff --git a/packages/subagents/src/tui/messages.ts b/packages/subagents/src/tui/messages.ts index a3473959..c6118927 100644 --- a/packages/subagents/src/tui/messages.ts +++ b/packages/subagents/src/tui/messages.ts @@ -1,7 +1,9 @@ /** - * Messaging a subagent from its pane: `m` starts a message at the foot, every key goes into it until - * enter sends or esc drops it, and a paste goes in too. A finished subagent's answer goes nowhere on - * its own, so once it has answered, what you asked and what it said are relayed to the main agent. + * Messaging a subagent from its pane: the box at the foot is always there, as OpenCode's own prompt + * is. `tab` or a click on it puts the keys into it; enter sends and leaves you typing, as OpenCode's + * prompt does; esc (or `tab`) hands the keys back to the pane and keeps what you typed. A paste goes + * in too. A finished subagent's answer goes nowhere on its own, so once it has answered, what you + * asked and what it said are relayed to the main agent. */ import type { Host } from "@opencode-cockpit/client/host" @@ -19,8 +21,10 @@ const RELAY_MAX = 4000 const clip = (text: string, most: number) => (text.length > most ? `${text.slice(0, most - 1)}…` : text) export interface Messages { - /** `m`: a message to the subagent open in the pane. */ - startMessage(): void + /** `tab`, or a click on the box: keys go into the message to the subagent open in the pane. */ + focus(): void + /** Keys go back to the pane; the draft stays in the box. */ + blur(): void /** A subagent went idle: if you had asked it something, its answer goes to the main agent. */ relay(id: string): void } @@ -39,14 +43,28 @@ export function createMessages(input: { }): Messages { const { api, log, model, surface, yours, opened, busy, draw, set } = input - const startMessage = () => { + const focus = () => { if (!opened()) return - surface.draft = "" + surface.typing = true surface.notice = undefined log.debug("message: typing", { id: surface.open }) draw() } + const blur = () => { + if (!surface.typing) return + surface.typing = false + draw() + } + + /** `text` in at the cursor, which moves past it. */ + const insert = (text: string) => { + const draft = surface.draft ?? "" + const caret = Math.min(draft.length, surface.caret ?? draft.length) + surface.draft = draft.slice(0, caret) + text + draft.slice(caret) + surface.caret = caret + text.length + } + /** * Messages you sent a finished subagent, until it answers. Its answer goes nowhere on its own — the * main agent's task returned long ago — so once it is idle again, what you asked and what it said @@ -83,10 +101,12 @@ export function createMessages(input: { const followed = after(id, pending.question) if (!followed.some((entry) => entry.kind !== "prompt")) { log.warn("message not answered", { id }) - if (surface.open === id && surface.draft === undefined) + if (surface.open === id && !surface.draft) set({ draft: pending.question, - notice: `${session.agent} finished without reading your message — enter sends it again, esc drops it.`, + caret: undefined, + typing: true, + notice: `${session.agent} finished without reading your message — enter sends it again, ctrl+u clears it.`, }) return } @@ -123,6 +143,7 @@ export function createMessages(input: { const session = opened() const text = surface.draft?.trim() surface.draft = undefined + surface.caret = undefined if (!session || !text) return draw() /** Taken now: the model may learn something about the session before the call returns. */ const { id, agent } = session @@ -151,32 +172,41 @@ export function createMessages(input: { /** A paste while typing goes into the message — one line, as the field is. */ const offPaste = onPaste(api, (text) => { - if (!surface.open || surface.draft === undefined) return false - surface.draft += text.replace(/\r?\n/g, " ") + if (!surface.open || !surface.typing) return false + insert(text.replace(/\r?\n/g, " ")) log.debug("message: pasted", { chars: text.length }) draw() return true }) api.lifecycle.onDispose(offPaste) - /** Typing a message takes every key before the layer, so `e`, `t`, `j`… go into the words. */ + /** + * Typing takes every key before the layer, so `e`, `t`, `j`… go into the words — and `y` into a + * word rather than answering a permission card: the card's keys are the pane's, not the box's. + */ api.keymap.intercept( (ctx) => { - if (!surface.open || surface.draft === undefined) return + if (!surface.open || !surface.typing) return const event = ctx.event ctx.consume({ preventDefault: true, stopPropagation: true }) - if (event.name === "escape") { - surface.draft = undefined - return draw() - } + const draft = surface.draft ?? "" + const caret = Math.min(draft.length, surface.caret ?? draft.length) + if (event.name === "escape" || event.name === "tab") return blur() if (event.name === "return" || event.name === "enter") return sendMessage() - if (event.name === "backspace") surface.draft = surface.draft.slice(0, -1) - else if (event.sequence && !event.ctrl && !event.meta && event.sequence >= " ") - surface.draft += event.sequence + if (event.name === "backspace") { + surface.draft = draft.slice(0, Math.max(0, caret - 1)) + draft.slice(caret) + surface.caret = Math.max(0, caret - 1) + } else if (event.name === "left") surface.caret = Math.max(0, caret - 1) + else if (event.name === "right") surface.caret = Math.min(draft.length, caret + 1) + else if (event.ctrl && event.name === "u") { + /** The readline key OpenCode's prompt keeps too: what is before the cursor goes. */ + surface.draft = draft.slice(caret) + surface.caret = 0 + } else if (event.sequence && !event.ctrl && !event.meta && event.sequence >= " ") insert(event.sequence) draw() }, { priority: 10_000 }, ) - return { startMessage, relay } + return { focus, blur, relay } } diff --git a/packages/subagents/src/tui/paint.ts b/packages/subagents/src/tui/paint.ts index 88968195..bfb4485e 100644 --- a/packages/subagents/src/tui/paint.ts +++ b/packages/subagents/src/tui/paint.ts @@ -13,6 +13,7 @@ import type { SubagentsConfig } from "../core/config.ts" import type { Model, Node, Session } from "../core/model/model.ts" import { createScreenCache, type Screen, screenRows } from "../core/view/screen.ts" import { type SidebarLine, sidebarLines } from "../core/view/sidebar.ts" +import { cardOf } from "./answers.ts" import { createRowPool, type RowPool, solidSurface } from "./render.ts" import type { Surface } from "./surface.ts" @@ -135,6 +136,8 @@ export function createPainter(input: { if (session && show) { const launcher = session.parentID ? model.sessions.get(session.parentID)?.agent : undefined const started = performance.now() + /** Answered from here and not yet heard back about: off the card, unless the answer failed. */ + const card = cardOf(session, surface.replies) const screen = screenRows({ cache, session, @@ -154,7 +157,14 @@ export function createPainter(input: { whole: surface.whole, yours: (entry) => yours.has(`${session.id}:${entry.text}`), reveal: surface.reveal === true, - ...(surface.draft !== undefined ? { input: { draft: surface.draft, busy: busy(session) } } : {}), + prompt: { + draft: surface.draft ?? "", + ...(surface.caret !== undefined ? { caret: surface.caret } : {}), + focused: surface.typing === true, + busy: busy(session), + }, + pending: card.pending, + ...(card.error ? { replyError: card.error } : {}), ...(surface.notice ? { notice: surface.notice } : {}), }) /** Scrolled back to the end: follow the run again. */ @@ -168,7 +178,7 @@ export function createPainter(input: { const took = performance.now() - started /** A paint past a frame is worth knowing about; the run's size says why. */ if (took > 16) log.debug("slow paint", { ms: Math.round(took), entries: session.entries.length }) - /** The prompt's cursor would otherwise blink through the pane, as it did over Review. */ + /** OpenCode's prompt's cursor would otherwise blink through the pane, as it did over Review. */ setTimeout(() => { if (surface.open) api.renderer.setCursorPosition(0, 0, false) }, 0) diff --git a/packages/subagents/src/tui/pane.ts b/packages/subagents/src/tui/pane.ts index d6b9e6a1..b143d423 100644 --- a/packages/subagents/src/tui/pane.ts +++ b/packages/subagents/src/tui/pane.ts @@ -1,12 +1,14 @@ /** * The pane: one subagent at a time, half the window or all of it. Opening and closing it, moving * between subagents and through the items, folding, scrolling, stopping, removing, moving to the - * background — and the keys it takes only while it is up. + * background, answering the permission it is held on — and the keys it takes only while it is up. */ import type { Host, Layer } from "@opencode-cockpit/client/host" import type { Log } from "@opencode-cockpit/client/log" import { applyAll, type Model, type Node, type Session } from "../core/model/model.ts" +import type { Act } from "../core/view/foot.ts" +import type { Answers } from "./answers.ts" import type { Messages } from "./messages.ts" import type { Painter } from "./paint.ts" import type { Source } from "./source.ts" @@ -22,7 +24,8 @@ export interface Pane { open(id: string): void close(): void scroll(by: number): void - click(y: number): void + /** A click at this row of the pane — and this column of it, when the host says. */ + click(y: number, x?: number): void /** From anywhere: the working subagent, or the latest one, of the conversation on screen. */ openLatest(): void clearFinished(): void @@ -37,6 +40,7 @@ export function createPane(input: { surface: Surface painter: Painter messages: Messages + answers: Answers feed: Source /** Subagents removed from the list by hand, kept across restarts. */ hidden: Set @@ -49,7 +53,7 @@ export function createPane(input: { follow: () => void set: (change: Partial) => void }): Pane { - const { api, log, model, surface, painter, messages, feed, hidden, saveHidden, isHidden } = input + const { api, log, model, surface, painter, messages, answers, feed, hidden, saveHidden, isHidden } = input const { nodes, opened, busy, follow, set } = input const draw = painter.draw let disposeKeys: (() => void) | undefined @@ -69,6 +73,8 @@ export function createPane(input: { log.debug("close", { id: surface.open }) surface.open = undefined surface.draft = undefined + surface.caret = undefined + surface.typing = false dropKeys() clearInterval(fast) fast = undefined @@ -80,12 +86,22 @@ export function createPane(input: { if (!model.sessions.has(id)) return log.debug("open", { id, full: surface.full }) const same = surface.open === id + /** A draft is for the subagent it was typed to: another one opens with an empty box. */ Object.assign(surface, { open: id, notice: undefined, stopping: undefined, - draft: undefined, - ...(same ? {} : { top: undefined, selected: undefined, details: false, keys: false }), + typing: false, + ...(same + ? {} + : { + top: undefined, + selected: undefined, + details: false, + keys: false, + draft: undefined, + caret: undefined, + }), }) takeKeys() fast ??= setInterval(() => { @@ -179,9 +195,27 @@ export function createPane(input: { draw() } - const click = (y: number) => { + /** The box starts typing; the card's answers answer, as their keys do. */ + const act = (what: Act) => (what === "focus" ? messages.focus() : answers.reply(what)) + + /** + * A click on part of the foot does what it says; anywhere else it opens or folds the item there, + * and a click off the box while typing gives the keys back to the pane. Without a column, a row + * with one thing to click on (the box) still works; the card's row of answers needs the column. + */ + const click = (y: number, x?: number) => { const shown = painter.shown() - if (!shown || surface.draft !== undefined) return + if (!shown) return + const hits = shown.hits[y] ?? [] + const hit = + x === undefined + ? hits.length === 1 + ? hits[0] + : undefined + : hits.find((each) => x >= each.from && x < each.to) + if (hit) return act(hit.act) + if (hits.length > 0) return + if (surface.typing) messages.blur() const key = shown.items[y] if (key) toggle(key) } @@ -337,10 +371,21 @@ export function createPane(input: { commands: [ { name: "cockpit.subagents.next", title: "Next subagent", run: () => step(1) }, { name: "cockpit.subagents.prev", title: "Previous subagent", run: () => step(-1) }, + { name: "cockpit.subagents.message", title: "Message this subagent", run: () => messages.focus() }, + { + name: "cockpit.subagents.allowOnce", + title: "Allow the permission once", + run: () => answers.reply("once"), + }, + { + name: "cockpit.subagents.allowAlways", + title: "Allow the permission always", + run: () => answers.reply("always"), + }, { - name: "cockpit.subagents.message", - title: "Message this subagent", - run: () => messages.startMessage(), + name: "cockpit.subagents.reject", + title: "Reject the permission", + run: () => answers.reply("reject"), }, { name: "cockpit.subagents.stop", title: "Stop, or remove from the list", run: () => stopOrRemove() }, { @@ -400,7 +445,11 @@ export function createPane(input: { bindings: [ { key: "],right", cmd: "cockpit.subagents.next" }, { key: "[,left", cmd: "cockpit.subagents.prev" }, - { key: "m", cmd: "cockpit.subagents.message" }, + { key: "tab", cmd: "cockpit.subagents.message" }, + /** The card's answers (core/view/foot.ts): only a request on the card gives them anything to do. */ + { key: "y", cmd: "cockpit.subagents.allowOnce" }, + { key: "shift+y", cmd: "cockpit.subagents.allowAlways" }, + { key: "n", cmd: "cockpit.subagents.reject" }, { key: "x", cmd: "cockpit.subagents.stop" }, { key: "shift+x", cmd: "cockpit.subagents.clear" }, { key: "b", cmd: "cockpit.subagents.background" }, diff --git a/packages/subagents/src/tui/source.ts b/packages/subagents/src/tui/source.ts index 7bc6fd7d..bda236e6 100644 --- a/packages/subagents/src/tui/source.ts +++ b/packages/subagents/src/tui/source.ts @@ -11,9 +11,13 @@ import { endOf, finishedAt } from "../core/adapt/ends.ts" import { createV1Translator } from "../core/adapt/v1.ts" import { createV2Translator } from "../core/adapt/v2.ts" import type { Change } from "../core/model/changes.ts" +import type { Permission } from "../core/model/model.ts" export { endOf, finishedAt } +/** An answer to a permission, in the words both OpenCodes take. */ +export type Decision = "once" | "always" | "reject" + export interface Source { /** Loads what exists under a conversation: its subagents, theirs, and their runs so far. */ load: (root: string) => Promise @@ -45,6 +49,11 @@ export interface Source { * with `resume: false` (drawn as one line) — neither answered, both recalled it on the next turn. */ quiet: (sessionID: string, text: string, agent?: string) => Promise + /** + * Answers a permission a subagent is held on, as OpenCode's own prompt would: the same request, so + * that prompt goes too. Rejects with what the host said when it would not take the answer. + */ + reply: (request: Permission, decision: Decision) => Promise /** What the host says about a session now, for a run that went quiet; nothing when it does not know. */ check: (id: string) => Change[] dispose: () => void @@ -74,6 +83,17 @@ const V1_EVENTS = [ // biome-ignore lint/suspicious/noExplicitAny: see above — every access is to a measured field type Loose = Record +/** + * A client result that may say it failed: the plugin's client hands back `{ data, error }` rather + * than throwing, and an answer the host refused must reach the card, not pass as sent. + */ +const unwrap = (result: unknown): unknown => { + const value = (result && typeof result === "object" ? result : {}) as Loose + if (value.error) + throw new Error(String(value.error?.message ?? value.error?.data?.message ?? JSON.stringify(value.error))) + return "data" in value ? value.data : result +} + export function createSource(api: Host, log: Log, emit: (changes: Change[]) => void): Source { /** * Each unexpected shape once, not once per event. An event we do not use is not news — OpenCode 2 @@ -171,6 +191,9 @@ export function createSource(api: Host, log: Log, emit: (changes: Change[]) => v const result = await v1.client.experimental.session.background({ sessionID: parentID }) return (result?.data ?? result) === true }, + async reply(request, decision) { + unwrap(await v1.client.permission.reply({ requestID: request.id, reply: decision })) + }, async quiet(sessionID, text, agent) { await v1.client.session.promptAsync({ sessionID, @@ -245,6 +268,12 @@ export function createSource(api: Host, log: Log, emit: (changes: Change[]) => v await v2.client.session.background({ sessionID: parentID }) return true }, + /** OpenCode 2 names the answer `decision` and wants the session: `reply` is refused (permissions.md). */ + async reply(request, decision) { + unwrap( + await v2.client.permission.reply({ sessionID: request.sessionID, requestID: request.id, decision }), + ) + }, async quiet(sessionID, text) { await v2.client.session.synthetic({ sessionID, text, resume: false, description: "Subagent exchange" }) }, diff --git a/packages/subagents/src/tui/surface.ts b/packages/subagents/src/tui/surface.ts index 403f8e4e..4d5a0530 100644 --- a/packages/subagents/src/tui/surface.ts +++ b/packages/subagents/src/tui/surface.ts @@ -16,8 +16,17 @@ export interface Surface { keys?: boolean /** Half the window, or all of it. Remembered. */ full: boolean - /** A message being typed, at the foot of the pane. */ + /** The message in the box at the foot of the pane, typed so far: kept when you stop typing. */ draft?: string + /** Keys go into the box (`tab`, a click on it) rather than to the pane; esc gives them back. */ + typing?: boolean + /** The cursor in the draft, as an index into it; its end when unset. */ + caret?: number + /** + * Permissions answered from the pane, by request id, until the host says they were: hidden from + * the card while the answer is on its way, and back on it with the error when it failed. + */ + replies: Map notice?: string /** Calls shown whole rather than their first lines. */ whole: Set diff --git a/packages/subagents/src/tui/view/overlay.tsx b/packages/subagents/src/tui/view/overlay.tsx index cbd1e465..0c7d7888 100644 --- a/packages/subagents/src/tui/view/overlay.tsx +++ b/packages/subagents/src/tui/view/overlay.tsx @@ -12,8 +12,8 @@ export interface OverlayProps { onReady: (parts: { backdrop: BoxRenderable; panel: BoxRenderable; lines: TextRenderable[] }) => void /** A click landed outside the pane. */ onDismiss: () => void - /** A click landed on the pane, at this row of it. */ - onClick: (y: number) => void + /** A click landed on the pane, at this row and column of it. */ + onClick: (y: number, x: number) => void /** The wheel turned over the pane: negative is up. */ onScroll: (delta: number) => void } @@ -31,6 +31,8 @@ export function Overlay(props: OverlayProps): JSX.Element { let backdrop: BoxRenderable | undefined let panel: BoxRenderable | undefined const lines: TextRenderable[] = [] + /** Where the button went down: released somewhere else, it was a drag, not a click. */ + let down: { x: number; y: number } | undefined /** Refs arrive child-first; act once every piece is here. */ const ready = () => { if (backdrop && panel && lines.length === MAX_LINES) props.onReady({ backdrop, panel, lines }) @@ -63,11 +65,21 @@ export function Overlay(props: OverlayProps): JSX.Element { flexShrink={0} flexDirection="column" backgroundColor={props.api.theme.current.background} - onMouseDown={(event: MouseEvent) => event.stopPropagation()} - /** At release, not press, as Review learned: a press can land its release on what it opened. */ + onMouseDown={(event: MouseEvent) => { + event.stopPropagation() + down = { x: event.x, y: event.y } + }} + /** + * At release, not press, as Review learned: a press can land its release on what it opened. + * A drag is a selection — OpenTUI's lines are selectable — and is let through untouched, so + * OpenCode copies it as it does its own text; only a press and release in one cell clicks. + */ onMouseUp={(event: MouseEvent) => { + const dragged = down !== undefined && (down.x !== event.x || down.y !== event.y) + down = undefined + if (dragged) return event.stopPropagation() - props.onClick(event.y) + props.onClick(event.y, event.x - (panel?.x ?? 0)) }} onMouse={(event: MouseEvent) => { const scroll = event.scroll diff --git a/packages/subagents/test/model.test.ts b/packages/subagents/test/model.test.ts index 97552fd6..2c76feb5 100644 --- a/packages/subagents/test/model.test.ts +++ b/packages/subagents/test/model.test.ts @@ -182,3 +182,146 @@ describe("what a subagent is doing now", () => { ]) }) }) + +/** + * A permission a subagent is held on, from each OpenCode's own events (docs/opencode/permissions.md): + * kept per session so the pane's card can answer it, gone once anyone answers it or the run ends. + */ +describe("held on a permission", () => { + const at = 5_000 + /** A subagent at work, running a shell command it now has to ask about. */ + const working: Change[] = [ + { type: "session", id: "c", parentID: "p", agent: "general", title: "Fix the login test", at }, + { type: "status", id: "c", status: "busy", at }, + { + type: "tool", + id: "c", + call: "call_1", + name: "bash", + state: "running", + input: { command: "npm test" }, + at, + }, + ] + const v1Asked = (id: string, command: string) => ({ + type: "permission.asked", + properties: { + id, + sessionID: "c", + permission: "bash", + patterns: [command], + always: [`${command.split(" ")[0]} *`], + metadata: {}, + tool: { messageID: "msg_1", callID: "call_1" }, + }, + }) + const v1Replied = (requestID: string) => ({ + type: "permission.replied", + properties: { sessionID: "c", requestID, reply: "once" }, + }) + const v2Asked = (id: string, command: string) => ({ + name: "permission.asked", + details: { + data: { + id, + sessionID: "c", + action: "shell", + resources: [command], + save: [`${command.split(" ")[0]} *`], + metadata: {}, + message: "msg_1", + source: { type: "tool", messageID: "msg_1", id: "call_1" }, + }, + }, + }) + const v2Replied = (requestID: string) => ({ + name: "permission.replied", + details: { data: { sessionID: "c", requestID, reply: "once" } }, + }) + + for (const version of [1, 2] as const) { + const translate = () => (version === 1 ? createV1Translator() : createV2Translator()) + const asked = version === 1 ? v1Asked : v2Asked + const replied = version === 1 ? v1Replied : v2Replied + + test(`OpenCode ${version}: an ask is kept with what it is for, and the run waits`, () => { + const t = translate() + const model = applyAll(emptyModel(), [...working, ...t.event(asked("per_1", "npm test"), at + 1)]) + const sub = model.sessions.get("c") + expect(sub?.status).toBe("waiting") + expect(sub?.pending).toEqual([ + { + id: "per_1", + sessionID: "c", + permission: version === 1 ? "bash" : "shell", + patterns: ["npm test"], + always: ["npm *"], + call: "call_1", + message: "msg_1", + at: at + 1, + }, + ]) + expect(activityOf(sub as NonNullable).text).toBe("waiting for permission") + }) + + test(`OpenCode ${version}: one of two answered still waits; both answered, it works again`, () => { + const t = translate() + const model = applyAll(emptyModel(), [ + ...working, + ...t.event(asked("per_1", "npm test"), at + 1), + ...t.event(asked("per_2", "npm run lint"), at + 2), + ...t.event(replied("per_1"), at + 3), + ]) + const sub = model.sessions.get("c") + expect(sub?.pending.map((ask) => ask.id)).toEqual(["per_2"]) + expect(sub?.status).toBe("waiting") + applyAll(model, t.event(replied("per_2"), at + 4)) + expect(sub?.pending).toEqual([]) + expect(sub?.status).toBe("running") + }) + } + + test("a run that ends drops what it was held on", () => { + for (const end of [ + { type: "status", id: "c", status: "idle", at: at + 9 }, + { type: "status", id: "c", status: "failed", error: "aborted", at: at + 9 }, + ] as Change[]) { + const model = applyAll(emptyModel(), [ + ...working, + ...createV1Translator().event(v1Asked("per_1", "npm test"), at + 1), + end, + ]) + expect(model.sessions.get("c")?.pending).toEqual([]) + } + }) + + test("an answer to a request asked before we loaded still frees the run", () => { + const model = applyAll(emptyModel(), [ + ...working, + { type: "status", id: "c", status: "waiting", at: at + 1 }, + ...createV2Translator().event(v2Replied("per_old"), at + 2), + ]) + expect(model.sessions.get("c")?.status).toBe("running") + }) + + test("both OpenCodes name the model, whose it is and its variant", () => { + const v1 = createV1Translator().session({ + id: "c", + title: "Fix it (@general subagent)", + model: { id: "space-bunny-free", providerID: "opencode", variant: "high" }, + }) + const v2 = createV2Translator().event( + { + name: "session.step.started", + details: { data: { sessionID: "c", agent: "general", model: { id: "gpt-5", providerID: "openai" } } }, + }, + at, + ) + expect(applyAll(emptyModel(), v1).sessions.get("c")).toMatchObject({ + model: "space-bunny-free", + provider: "opencode", + variant: "high", + }) + expect(applyAll(emptyModel(), v2).sessions.get("c")).toMatchObject({ model: "gpt-5", provider: "openai" }) + }) +}) diff --git a/packages/subagents/test/view.test.ts b/packages/subagents/test/view.test.ts index 42d8e4c6..d0daf729 100644 --- a/packages/subagents/test/view.test.ts +++ b/packages/subagents/test/view.test.ts @@ -79,7 +79,8 @@ for (const version of [1, 2] as const) { { thinking: true }, { open: every }, { details: true }, - { input: { draft: "also check the tests", busy: false } }, + { prompt: { draft: "also check the tests", focused: true, busy: false } }, + { prompt: { draft: "", focused: false, busy: false } }, { selected: [...every][1] as string, top: 0 }, ]) { const screen = screenRows({ ...pane(session, nodes), width, height, ...state }) @@ -104,7 +105,7 @@ for (const version of [1, 2] as const) { expect(text).toMatch(/→ Read src\/(session|middleware)\.ts/) expect(text).toMatch(/[◇◆] Thought · [\d.]+m?s/) expect(text).toContain("createSession") - expect(text).toContain("[m] Message") + expect(text).toContain("[tab] Message") }) test("a quiet call is one line; open, it is a box of its arguments; folded, one line again", async () => { @@ -254,13 +255,13 @@ describe("while it works", () => { ...pane(session, nodes), width: 90, height: 20, - input: { draft: "skip the docs", busy: true }, + prompt: { draft: "skip the docs", focused: true, busy: true }, }) .rows.map(rowText) .join("\n") expect(text).toContain("┃ skip the docs▍") expect(text).toContain("it picks this up in its current run") - expect(text).not.toContain("[m] Message") + expect(text).not.toContain("[tab] Message") }) test("a message you sent is a card in the timeline", () => { @@ -388,7 +389,7 @@ describe("stopping and ending", () => { expect(footer(done, 140)).toContain("[x] Remove") const narrow = footer(busy, 72) expect(narrow).toContain("[enter] Open") - expect(narrow).toContain("[m] Message") + expect(narrow).toContain("[tab] Message") expect(narrow).toContain("[x] Stop") expect(narrow).not.toMatch(/…\s*$/) }) @@ -719,3 +720,204 @@ describe("a reloaded run ends when its last message finished", () => { expect(finishedAt([])).toBe(0) }) }) + +/** + * The foot of the pane (core/view/foot.ts): a permission card answered from the pane, and the message + * box that is always there, as OpenCode's own prompt is. + */ +describe("the foot: a permission card and the message box", () => { + const at = 3_000_000 + const ask = (request: string, call = "1"): Change => ({ + type: "permission", + id: "c", + request, + state: "asked", + permission: "bash", + patterns: ["pnpm install"], + always: ["pnpm *"], + call, + at: at + 1, + }) + const held = (command: string, extra: Change[] = []) => { + const m = applyAll(emptyModel(), [ + { + type: "session", + id: "c", + parentID: "p", + agent: "explore", + title: "Install", + model: "space-bunny-free", + provider: "opencode", + variant: "high", + at, + }, + { type: "status", id: "c", status: "busy", at }, + { type: "tool", id: "c", call: "1", name: "bash", state: "pending", input: { command }, at }, + ask("per_1"), + ...extra, + ]) + const nodes = subagentsOf(m, "p") + const session = nodes[0]?.session + if (!session) throw new Error("no subagent") + return { session, nodes } + } + const draw = (input: Partial & Pick) => + screenRows({ ...pane(input.session, input.nodes), width: 90, height: 30, ...input }) + const text = (screen: ReturnType) => screen.rows.map(rowText) + + test("held, the card says what it asks for — the call's whole command — and how to answer", () => { + const rows = text(draw(held("pnpm install --frozen-lockfile"))) + const title = rows.findIndex((row) => row.includes("△ Permission required · bash")) + expect(title).toBeGreaterThan(0) + expect(rows[title + 1]).toContain("▎ $ pnpm install --frozen-lockfile") + /** A row of air between the command and the keys, as OpenCode's own card has. */ + expect(rows[title + 2]?.trim()).toBe("▎") + expect(rows[title + 3]).toContain("y Allow once Y Allow always n Reject") + expect(rows[title]).not.toContain("1 of") + }) + + test("with no call to read, the card says the request's own patterns", () => { + const { session, nodes } = held("ignored") + const rows = text(draw({ session, nodes, pending: [{ ...(session.pending[0] as never), call: "gone" }] })) + expect(rows.some((row) => row.includes("▎ pnpm install"))).toBe(true) + }) + + test("two pending: the first is shown, and says it is one of two", () => { + const rows = text(draw(held("pnpm install", [ask("per_2")]))) + expect(rows.find((row) => row.includes("Permission required"))?.trimEnd()).toMatch(/1 of 2$/) + }) + + test("a long command is cut after four lines, and says so", () => { + const long = Array.from({ length: 40 }, (_, i) => `step${i}`).join(" && ") + const all = text(draw(held(long))) + const card = all.slice(all.findIndex((row) => row.includes("Permission required"))) + const rows = card.filter((row) => row.includes("step")) + expect(rows).toHaveLength(4) + expect(rows.at(-1)?.trimEnd()).toMatch(/…$/) + }) + + test("an answer the host refused is said on the card, which stays", () => { + const rows = text(draw({ ...held("pnpm install"), replyError: "Permission request not found" })) + expect(rows.some((row) => row.includes("Not answered: Permission request not found"))).toBe(true) + expect(rows.some((row) => row.includes("y Allow once"))).toBe(true) + }) + + test("nothing pending, or every one answered from here, draws no card", () => { + const answered = draw({ ...held("pnpm install"), pending: [] }) + expect(text(answered).some((row) => row.includes("Permission required"))).toBe(false) + }) + + test("each answer on the card is a place to click; the box is one too", () => { + const screen = draw(held("pnpm install")) + const y = screen.rows.findIndex((row) => rowText(row).includes("y Allow once")) + const row = rowText(screen.rows[y] ?? []) + const hits = screen.hits[y] ?? [] + expect(hits.map((hit) => hit.act)).toEqual(["once", "always", "reject"]) + for (const [act, label] of [ + ["once", "y Allow once"], + ["always", "Y Allow always"], + ["reject", "n Reject"], + ] as const) { + const hit = hits.find((each) => each.act === act) + expect(row.slice(hit?.from, hit?.to)).toBe(label) + } + const box = screen.rows.findIndex((each) => rowText(each).includes("┃ Message explore…")) + expect(screen.hits[box]).toEqual([{ act: "focus", from: 0, to: 90 }]) + expect(screen.hits.slice(0, screen.bodyAt + 5).every((each) => each === undefined)).toBe(true) + }) + + test("the box at rest says how to start; with a draft it keeps it; typing, the cursor is in it", () => { + const { session, nodes } = held("pnpm install") + const rest = text(draw({ session, nodes, pending: [] })) + /** How to start is the footer's `[tab] Message`, said once. */ + expect(rest.find((row) => row.includes("┃ Message explore…"))?.trimEnd()).toMatch(/Message explore…$/) + expect(rest.some((row) => row.includes("[tab] Message"))).toBe(true) + const kept = text(draw({ session, nodes, prompt: { draft: "skip docs", focused: false, busy: true } })) + expect(kept.find((row) => row.includes("┃ skip docs"))?.trimEnd()).toMatch(/┃ skip docs$/) + const typing = text( + draw({ session, nodes, prompt: { draft: "skip docs", caret: 4, focused: true, busy: true } }), + ) + expect(typing.some((row) => row.includes("┃ skip▍ docs"))).toBe(true) + expect(typing.some((row) => row.includes("[enter] Send") && row.includes("[esc] Stop Typing"))).toBe(true) + const empty = text(draw({ session, nodes, prompt: { draft: "", focused: true, busy: false } })) + expect(empty.some((row) => row.includes("┃ ▍Message explore…"))).toBe(true) + }) + + test("a draft longer than the box shows the part around the cursor", () => { + const { session, nodes } = held("pnpm install") + const draft = `${"a".repeat(200)}END` + const rows = text(draw({ session, nodes, width: 60, prompt: { draft, focused: true, busy: false } })) + const line = rows.find((row) => row.includes("┃ …")) ?? "" + expect(line).toContain("END▍") + expect(widthOf(line)).toBe(60) + }) + + test("under the box: the agent, its model, whose model it is, and a variant you chose", () => { + const { session, nodes } = held("pnpm install") + expect( + text(draw({ session, nodes })).some((row) => + row.includes("┃ Explore · space-bunny-free opencode · high"), + ), + ).toBe(true) + const plain = { ...session, variant: "default", provider: undefined } as Session + const rows = text(draw({ session: plain, nodes })) + expect(rows.find((row) => row.includes("┃ Explore"))?.trimEnd()).toMatch(/┃ Explore · space-bunny-free$/) + const bare = { ...session, model: undefined, provider: undefined, variant: undefined } as Session + expect( + text(draw({ session: bare, nodes })) + .find((row) => row.includes("┃ Explore")) + ?.trimEnd(), + ).toMatch(/┃ Explore$/) + }) + + test("with a card, the pane is still exactly its height, every row its width, down to the smallest", () => { + const { session, nodes } = held(Array.from({ length: 40 }, (_, i) => `step${i}`).join(" && "), [ + ask("per_2"), + ]) + for (const [width, height] of [ + [40, 8], + [40, 12], + [72, 16], + [140, 40], + ] as const) { + for (const prompt of [undefined, { draft: "hi", focused: true, busy: true }]) { + const screen = screenRows({ ...pane(session, nodes), width, height, ...(prompt ? { prompt } : {}) }) + expect(screen.rows).toHaveLength(height) + expect(screen.hits).toHaveLength(height) + for (const row of screen.rows) expect(rowWidth(row)).toBe(width) + } + } + }) + + test("a short pane keeps the card's answers and the box before the command's later lines", () => { + const { session, nodes } = held(Array.from({ length: 40 }, (_, i) => `step${i}`).join(" && ")) + const rows = text(screenRows({ ...pane(session, nodes), width: 72, height: 16 })) + const card = rows.slice(rows.findIndex((row) => row.includes("Permission required"))) + expect(card.filter((row) => row.includes("step"))).toHaveLength(1) + expect(rows.some((row) => row.includes("y Allow once"))).toBe(true) + expect(rows.some((row) => row.includes("┃ Message explore…"))).toBe(true) + }) + + test("the switcher marks a subagent held on a permission", () => { + const m = applyAll(emptyModel(), [ + { type: "session", id: "a", parentID: "p", agent: "general", title: "Read", at }, + { type: "status", id: "a", status: "busy", at }, + { type: "session", id: "c", parentID: "p", agent: "explore", title: "Install", at: at + 1 }, + { type: "status", id: "c", status: "busy", at: at + 1 }, + ask("per_1"), + ]) + const nodes = subagentsOf(m, "p") + const other = nodes.find((node) => node.session.id === "a")?.session as Session + const switcher = text(screenRows({ ...pane(other, nodes), width: 120, height: 20 }))[2] ?? "" + expect(switcher).toContain("△ explore Install") + expect(switcher).not.toContain("△ general") + }) + + test("the keys screen lists the box and the card's answers, and no longer `m`", () => { + const { session, nodes } = held("pnpm install") + const keys = text(screenRows({ ...pane(session, nodes), width: 120, height: 60, keys: true })).join("\n") + expect(keys).toContain("[tab]") + expect(keys).toMatch(/\[y\] \[Y\] \[n\] +Held on a permission/) + expect(keys).not.toContain("[m]") + }) +}) diff --git a/packages/trust/README.md b/packages/trust/README.md index 5d98ebbf..da27723a 100644 --- a/packages/trust/README.md +++ b/packages/trust/README.md @@ -55,11 +55,36 @@ only reach Trust when your config sets them to `ask`. | `webfetch` | the host | — | no | no | | `todowrite`, `lsp` | one rule | — | no | no | | `skill`, `task` | the skill, the subagent type | — | no | no | -| `external_directory`, `doom_loop` | never answered | | | | +| `external_directory` | the folder | the folder; the temp dir as one (`$TMPDIR`) | no | yes — and only a widened folder answers it | +| `doom_loop` | never answered | | | | A `read` of a file that may hold secrets (`.env`, a key, `~/.ssh`) is OpenCode's own default ask, and stays yours to answer however often you approve it. +## Folders outside the project + +Before a tool reads or writes outside the project, OpenCode asks `external_directory` for the folder. +Trust counts those approvals by folder, like any other — and **never answers one by its count**: +approving a folder three times still leaves the next one to you. What answers it is a folder you +widened with `w`, and it covers the folders under it, never the ones beside it. + +Most of those prompts are the agent's scratch space in the OS temp dir (`/var/folders/…/T/opencode/…` +on macOS, `/tmp/…` elsewhere), so the temp dir is **one family**, `$TMPDIR`, whatever name a tool +gives it (`/var/…` or `/private/var/…`, `/tmp` or `/private/tmp`). Once your approvals across two or +more of its folders reach the threshold, the ledger suggests it: + +``` + ★ any folder in $TMPDIR? 14 in 6 │ outside $TMPDIR/ family of 6 folders + │ ★ Suggested · approved 14× across 6 folders in your temp directory + │ Widen [w] lets Trust answer reads and writes there from now on — + │ any folder in $TMPDIR; a folder that may hold secrets still asks. +``` + +Never suggested and never widened, however often you approve them: `/`, your home folder and the +folders above it, `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config`, `~/.kube`, `~/.docker`, `/etc`, any +folder that may hold secrets, and a folder that holds the project — widening it would answer for every +project beside this one. A request naming one of them is always yours, and is not counted. + ## Reads are learned as a family An exact rule rarely repeats in real work: `head -3` on a new file, `rg` for a new word, one new @@ -192,7 +217,8 @@ Today's answers are one strip above it; `a` opens them in full. - **A family** is the program, or the program and its subcommand for tools that have them: `git status`, `docker compose up` (`-p prod` and other global flags are not part of it), `npm run test` (the script is). A wrapper is: `sudo ls` is not `ls`. So is where it runs and the - environment it is given: `(in web) bun test`, `NODE_ENV=… npm run build`. An edit's family, and a + environment it is given: `bun test in web`, `NODE_ENV=… npm run build` — the command first, where + it ran after it, muted and short, so a narrow row never cuts the program. An edit's family, and a read's, is its folder; a `grep` or a `glob` is one family, the tool. - **`w` trusts the whole family**, in this project — on purpose, never by itself. Any `ls …` is then answered, **except** a dangerous command, one that writes a file through a redirection diff --git a/packages/trust/src/agent/answer.ts b/packages/trust/src/agent/answer.ts new file mode 100644 index 00000000..d1700f16 --- /dev/null +++ b/packages/trust/src/agent/answer.ts @@ -0,0 +1,194 @@ +/** + * Trust's agent half: on OpenCode 2, a request answered before it is ever made. + * + * The interface answers on `permission.asked`, and by then OpenCode's prompt is on screen: for a + * frame each time, and in a burst of parallel subagents a prompt that flickers and takes your typing. + * OpenCode 2 lets a server plugin in earlier, at `Permission.evaluate` — after config's rules, before + * any request exists (docs/opencode/permissions.md). Answered there, nothing is drawn at all. + * + * The decision is the interface's, made from the same pieces: the engine folded from the same ledger + * (read again right before each decision, so an approval or a pause from any window counts), the + * rules the interface last read (snapshot.ts — the agent side has no config to read), the same mask + * key. What the request does not carry — a shell call's command line and working directory — comes + * from the call itself, heard as it starts (`toolBefore`). Whenever a piece is missing or in doubt, + * this half answers nothing and the request is asked as before, where the interface decides it again + * with everything it can see. So a doubt here costs one frame of prompt, never a wrong answer. + * + * An answer is written to the ledger — `asked`, then `auto` — before OpenCode is told: the ledger is + * the record, and an answer it could not record is not given. A request left to be asked writes + * nothing here; the interface writes it when it sees it. + */ + +import { randomBytes } from "node:crypto" +import type { Log } from "@opencode-cockpit/client/log" +import type { PermissionAsk, ToolStart } from "@opencode-cockpit/client/server" +import { commandOf, obj } from "../core/adapt/seen.ts" +import type { TrustSettings } from "../core/config.ts" +import { createEngine, type Engine } from "../core/engine.ts" +import type { Request } from "../core/keys.ts" +import type { TrustPaths } from "../core/paths.ts" +import { canonical, rulesFrom } from "../core/rules.ts" +import type { Hasher } from "../core/secret.ts" +import { createJournal } from "../tui/journal.ts" +import { type Snapshot, type SnapshotSettings, snapshotReader } from "../tui/snapshot.ts" + +/** Calls remembered for their command line: far more than can be running at once. */ +const CALLS_MAX = 500 +/** + * How long a decision may take, reading the ledger and writing to it, before the request is left to + * be asked. The agent is waiting on it, so a slow disk costs a prompt, not a stalled turn. + */ +export const DECIDE_MS = 1_000 +/** + * Code Mode's outer call: it carries code, not a command, and the tools it runs come through again + * under the same call id. + */ +const CODE_MODE = "execute" + +export interface Answerer { + /** A tool call starting: its command line kept by call id for the permission it may ask. */ + toolBefore(call: ToolStart): void + /** "allow" when Trust answers for you; undefined leaves the request to be asked. */ + permission(request: PermissionAsk): Promise<"allow" | undefined> +} + +interface Call { + line?: string + workdir?: string + /** Two starts under one id — Code Mode's tools share their call's — so which line asked is unknown. */ + doubt?: true + /** What has been decided for this call already, by action and resources. */ + decided: Set +} + +export function createAnswerer(input: { + directory: string + paths: TrustPaths + /** This entry's own settings: used until the interface's snapshot says otherwise. */ + settings: TrustSettings + hash: Hasher + log: Log + rules?: () => Promise + now?: () => number + decideMs?: number +}): Answerer { + const { directory, paths, hash, log, now = Date.now, decideMs = DECIDE_MS } = input + const rules = input.rules ?? snapshotReader(paths) + const journal = createJournal(paths) + const thresholds = (from: SnapshotSettings) => ({ + threshold: from.threshold, + dangerExtra: from.dangerExtra, + expireDays: from.expireDays, + learnReads: from.learnReads, + }) + let using = JSON.stringify(thresholds(input.settings)) + const engine: Engine = createEngine({ ...thresholds(input.settings), keep: 1 }) + const calls = new Map() + + /** The interface's settings, when they differ from what the engine counts with. */ + const settle = (snapshot: Snapshot) => { + const wanted = JSON.stringify(thresholds(snapshot.settings)) + if (wanted === using) return + using = wanted + engine.reset({ ...thresholds(snapshot.settings), keep: 1 }) + } + + const decide = async (request: PermissionAsk, call: Call, late: () => boolean) => { + const snapshot = await rules() + if (!snapshot) { + log.debug("no rules from the interface; asking", { action: request.action }) + return undefined + } + settle(snapshot) + const { events, reset } = await journal.read() + engine.load(events, { reset }) + const agent = request.agent ?? "unknown" + const at = now() + const asked: Request = { + /** No request exists yet: this id is Trust's own, and only the ledger ever holds it. */ + id: `trust_${randomBytes(9).toString("hex")}`, + sessionID: request.sessionID, + permission: canonical(request.action), + patterns: request.resources, + always: [], + ...(request.callID ? { call: request.callID } : {}), + ...(request.messageID ? { messageID: request.messageID } : {}), + } + const { judgement, event } = engine.ask({ + request: asked, + context: { + ...(call.line !== undefined ? { line: call.line } : {}), + ...(call.workdir ? { workdir: call.workdir } : {}), + root: directory, + hash, + }, + agent, + rules: rulesFrom(snapshot.config, agent), + at, + }) + if (!judgement.answer) { + engine.forget(asked.id) + log.debug("left to ask", { action: request.action, agent, why: judgement.why }) + return undefined + } + const auto = engine.answered(asked.id, now()) + /** Past the deadline the request is already being asked: an answer written now would be false. */ + if (!auto || late()) return undefined + try { + await journal.append([event, auto]) + } catch (error) { + log.warn("ledger not saved; asking", { file: paths.events, error }) + return undefined + } + log.info("auto", { + request: asked.id, + session: request.sessionID, + call: request.callID, + permission: asked.permission, + agent, + subjects: judgement.items.map((item) => item.subject), + why: judgement.why, + ms: now() - at, + }) + return "allow" as const + } + + return { + toolBefore(start) { + if (start.tool === CODE_MODE) return + const known = calls.get(start.callID) + if (known) { + known.doubt = true + return + } + calls.set(start.callID, { ...commandOf(obj(start.args)), decided: new Set() }) + if (calls.size > CALLS_MAX) calls.delete(calls.keys().next().value as string) + }, + async permission(request) { + /** Only a tool's request, from a call heard starting: anything else has nothing to decide with. */ + const call = request.callID ? calls.get(request.callID) : undefined + if (!call || call.doubt) return undefined + /** + * Once per call and resources. OpenCode evaluates a waiting request again when you answer + * another "always" — and that request is already on screen, the interface's to follow. + */ + const key = JSON.stringify([request.action, request.resources]) + if (call.decided.has(key)) return undefined + call.decided.add(key) + if (canonical(request.action) === "bash" && call.line === undefined) return undefined + let expired = false + let timer: ReturnType | undefined + const deadline = new Promise((done) => { + timer = setTimeout(() => { + expired = true + done(undefined) + }, decideMs) + }) + try { + return await Promise.race([decide(request, call, () => expired), deadline]) + } finally { + clearTimeout(timer) + } + }, + } +} diff --git a/packages/trust/src/core/engine.ts b/packages/trust/src/core/engine.ts index bfcdcaf3..db787dd8 100644 --- a/packages/trust/src/core/engine.ts +++ b/packages/trust/src/core/engine.ts @@ -90,8 +90,16 @@ export interface Engine { * the state, bounded, and never read by a decision. */ readonly history: History - /** Events read from the ledger file, in file order. */ - load(events: readonly Event[], options?: { reset?: boolean }): void + /** + * Events read from the ledger file, in file order. `here`: an answer by Trust that this window did + * not send but is still this window's — on OpenCode 2 its agent half answers before a request is + * ever made (agent/answer.ts), and the window only learns of it from the file. Such an answer is + * listed and counted as if this window had given it. + */ + load( + events: readonly Event[], + options?: { reset?: boolean; here?: (answer: Extract) => boolean }, + ): void /** * A request was asked. Returns the judgement and the `asked` event to write; when the judgement is * to answer, the request is marked ours *before* the reply is sent, so the `replied` it causes is @@ -108,6 +116,11 @@ export interface Engine { answered(requestID: string, at: number): Event | undefined /** Our reply failed: the request is the person's again, and their answer counts. */ failed(requestID: string): void + /** + * A request decided and not answered, that this engine will hear no more of — the agent half's, + * which leaves the asking to the interface. Let go, so the pending list does not grow. + */ + forget(requestID: string): void /** A reply arrived — ours, OpenCode's, a person's. The events to write, and what it was taken for. */ replied(input: { requestID: string; reply: Reply; at: number }): { events: Event[]; credit: Credit } /** Requests the host says are no longer pending: answered while we were not looking. */ @@ -148,6 +161,31 @@ export function createEngine(initial: EngineOptions): Engine { const answered: Answered[] = [] let total = 0 + /** One answer into the sidebar's list and count. */ + const remember = (answer: { + at: number + request: string + permission: string + agent: string + items: readonly { subject: string; via?: string }[] + why: string + }) => { + total++ + const subjects = answer.items.map((item) => item.subject) + const via = answer.items.find((item) => item.via !== undefined)?.via + answered.unshift({ + at: answer.at, + request: answer.request, + permission: answer.permission, + agent: answer.agent, + label: subjects.join(" && "), + subjects, + why: answer.why, + ...(via !== undefined ? { via } : {}), + }) + answered.splice(Math.max(1, options.keep ?? 20)) + } + const about = (entry: Pending) => ({ request: entry.request.id, session: entry.request.sessionID, @@ -164,13 +202,24 @@ export function createEngine(initial: EngineOptions): Engine { get history() { return history }, - load(more, { reset = false } = {}) { + load(more, { reset = false, here } = {}) { if (reset) { events = [] state = emptyState() history = emptyHistory() } events = events.concat(more) + if (here) + for (const event of more) + if ( + event.type === "auto" && + !mine.has(event.request) && + !state.settled.has(event.request) && + here(event) + ) { + /** Read before the fold below settles it, so a second window's copy is not one more. */ + remember({ ...event, why: event.rule }) + } fold(state, more) }, ask({ request, context, agent, rules, at }) { @@ -196,26 +245,24 @@ export function createEngine(initial: EngineOptions): Engine { if (!entry) return undefined ours.delete(requestID) pending.delete(requestID) - total++ - const subjects = entry.judgement.items.map((item) => item.subject) - const via = entry.judgement.items.find((item) => item.via !== undefined)?.via - answered.unshift({ + remember({ at, request: requestID, permission: entry.request.permission, agent: entry.agent, - label: subjects.join(" && "), - subjects, + items: entry.judgement.items, why: entry.judgement.why, - ...(via !== undefined ? { via } : {}), }) - answered.splice(Math.max(1, options.keep ?? 20)) return { v: 1, at, type: "auto", rule: entry.judgement.why, ...about(entry) } }, failed(requestID) { ours.delete(requestID) mine.delete(requestID) }, + forget(requestID) { + pending.delete(requestID) + ours.delete(requestID) + }, replied({ requestID, reply, at }) { const entry = pending.get(requestID) pending.delete(requestID) diff --git a/packages/trust/src/core/family.ts b/packages/trust/src/core/family.ts index 9afd021b..a4101fc8 100644 --- a/packages/trust/src/core/family.ts +++ b/packages/trust/src/core/family.ts @@ -33,17 +33,43 @@ * it (`outside`), because writing a file is not what "any ls" was agreed to mean. * * Other permissions: an edit's family is its folder (`src/`), a fetch's its host, an agent type is - * its own — the last two already are as wide as they go. + * its own — the last two already are as wide as they go. A folder outside the project is its own + * family, the temp dir one for all of it (`$TMPDIR/`), and a widened one answers the folders under + * it too (folders.ts). */ import { posix } from "node:path" import { dangerOf, subcommand, TOOL_GLOBALS, unwrapOnce } from "./danger.ts" import { GIT_READS, notRead, runsByFlag, sensitive, writesByFlag } from "./effect.ts" import { envWords } from "./env.ts" +import { folderFamily, OUTSIDE, refused, shortPath, shortPlace, TEMP_FAMILY, widenersOf } from "./folders.ts" import { canonical } from "./rules.ts" import { type Command, parse } from "./shell.ts" import { quote } from "./signature.ts" +/* ─── remembering what was read ──────────────────────────────────────────────────────────────── */ + +/** + * Every reading below is pure — a subject is the same command whenever it is read — and the ledger's + * screens read every subject in the project on every paint: on a real ledger of 13,000 commands that + * was half a second a keystroke, most of it the shell parsed again (measured with `--cpu-prof`). So a + * reading is kept once made. Bounded: past the limit the whole store starts over, which costs one + * slow paint and never a wrong answer. + */ +const REMEMBERED = 50_000 + +function remembered(read: (permission: string, subject: string) => T) { + let kept = new Map() + return (permission: string, subject: string): T => { + const key = `${permission}\u0000${subject}` + if (kept.has(key)) return kept.get(key) as T + const value = read(permission, subject) + if (kept.size >= REMEMBERED) kept = new Map() + kept.set(key, value) + return value + } +} + /* ─── reading a subject back ─────────────────────────────────────────────────────────────────── */ /** `(in web) ` or `(in 'my dir') `, as `signature()` writes a place. */ @@ -54,6 +80,12 @@ const PLACE = /^\(in ('(?:[^']|'\\'')*'|[^\s)]+)\) / * quoted by `quote` — so this is exact; anything that does not read as one command is left alone. */ export function readSubject(subject: string): { place?: string; command: Command } | undefined { + return readSubjectOnce("bash", subject) +} + +const readSubjectOnce = remembered((_permission: string, subject: string) => readSubjectNow(subject)) + +function readSubjectNow(subject: string): { place?: string; command: Command } | undefined { let rest = subject let place: string | undefined const found = subject.match(PLACE) @@ -388,23 +420,39 @@ function namesFor(name: string, args: readonly string[]): number { const envWord = (word: string) => namesPlace(word.slice(word.indexOf("=") + 1)) ? word : `${word.slice(0, word.indexOf("="))}=…` -function bashFamily(command: Command, place: string | undefined): string { - const words = [ - ...command.env.map(envWord), - ...familyWords(command.argv, command.redirects).map(quote), - ].join(" ") - return place === undefined ? words : `(in ${quote(place)}) ${words}` +/** + * A command's family has no place in it. It used to: `rg` run from `packages/api` and `rg` run at the + * top were two families, approved and widened apart, and a widening made in one folder asked again + * in the next. The folder a command runs from is not what a person weighs — what it does is — and + * leaving the project is OpenCode's own `external_directory` question, which Trust counts by folder + * (folders.ts). So `any rg` is any `rg` in the project, wherever it runs. The exact command still + * carries its place: a rule earned by one command stays that command's. + */ +function bashFamily(command: Command): string { + return [...command.env.map(envWord), ...familyWords(command.argv, command.redirects).map(quote)].join(" ") +} + +/** + * A family as ledgers before 0.12 recorded it, `(in packages/api) rg`, read as the family it is now: + * `rg`. A widening, a dismissal and a learned family made in one folder now hold in every folder. + */ +export function placeless(permission: string, family: string): string { + if (canonical(permission) !== "bash") return family + const read = readSubject(family) + return read?.place !== undefined && read.command.argv.length > 0 ? bashFamily(read.command) : family } /** * A subject's family, under its permission. The same string for every command in it, so it is what * a widening is recorded against and what the ledger groups by. */ -export function familyOf(permission: string, subject: string): string { +export const familyOf = remembered(familyOfNow) + +function familyOfNow(permission: string, subject: string): string { const name = canonical(permission) if (name === "bash") { const read = readSubject(subject) - return read && read.command.argv.length > 0 ? bashFamily(read.command, read.place) : subject + return read && read.command.argv.length > 0 ? bashFamily(read.command) : subject } if (name === "edit" || name === "read") { const folder = posix.dirname(subject) @@ -412,9 +460,18 @@ export function familyOf(permission: string, subject: string): string { } /** A search and a web query are new words every time: the tool is the family (`any grep`). */ if (WHOLE_TOOL.has(name)) return "*" + if (name === OUTSIDE) return folderFamily(subject) return subject } +/** + * Every family a widening of which answers `subject`: its own — and for a folder outside the project, + * each folder above it too, since a widened folder covers what is under it (folders.ts). + */ +export const wideningsOf = remembered((permission: string, subject: string): string[] => + canonical(permission) === OUTSIDE ? widenersOf(subject) : [familyOf(permission, subject)], +) + /** OpenCode tools whose every request is new text — a glob, a regex, a query — so one family each. */ const WHOLE_TOOL = new Set(["glob", "grep", "websearch"]) @@ -431,7 +488,9 @@ function familyCommand(family: string): Command | undefined { * dangerous, "any git push" is a rule that answers force-pushes — every one of them has to earn * trust on its own, at the higher count. A fetch or an agent type is already as wide as it goes. */ -export function widenable(permission: string, family: string): { ok: true } | { ok: false; why: string } { +export const widenable = remembered(widenableNow) + +function widenableNow(permission: string, family: string): { ok: true } | { ok: false; why: string } { const name = canonical(permission) if (name === "bash") { const command = familyCommand(family) @@ -447,6 +506,10 @@ export function widenable(permission: string, family: string): { ok: true } | { : { ok: true } } if (name === "edit" || name === "read" || WHOLE_TOOL.has(name)) return { ok: true } + if (name === OUTSIDE) { + const why = refused(family) + return why ? { ok: false, why: `${shortPath(family)} is never widened: ${why}` } : { ok: true } + } if (name === "webfetch") return { ok: false, why: "a fetch rule already covers the whole host" } if (name === "task") return { ok: false, why: "an agent type is already one rule" } return { ok: false, why: `a ${name} rule is already as wide as it goes` } @@ -478,7 +541,11 @@ function runsAnother(argv: readonly string[]): boolean { * is "any `ls`", not "anything that starts with ls": a dangerous command, one that writes a file * through a redirection, and one that hands its arguments to another program each still ask. */ -export function outside(permission: string, subject: string): string | undefined { +export const outside = remembered(outsideNow) + +function outsideNow(permission: string, subject: string): string | undefined { + /** A folder that may hold secrets still asks, under any widening (folders.ts). */ + if (canonical(permission) === OUTSIDE) return refused(subject) if (canonical(permission) !== "bash") return undefined const read = readSubject(subject) if (!read) return "it cannot be read as one command" @@ -493,14 +560,16 @@ export function outside(permission: string, subject: string): string | undefined /** A widened `family` answers `subject`. */ export const covers = (permission: string, family: string, subject: string): boolean => - familyOf(permission, subject) === family && outside(permission, subject) === undefined + wideningsOf(permission, subject).includes(family) && outside(permission, subject) === undefined /** * Why a learned family would still ask about this subject — or nothing when it is a plain read * (effect.ts). Narrower than `outside`: a family Trust learned by itself covers reads only, so * `head -3 a.txt` is in a learned `head` and `head .env` or `head -3 a > b` are not. */ -export function notReadSubject(permission: string, subject: string): string | undefined { +export const notReadSubject = remembered(notReadSubjectNow) + +function notReadSubjectNow(permission: string, subject: string): string | undefined { const name = canonical(permission) if (READ_TOOLS.has(name)) return name === "read" && sensitive(subject) ? `${subject} may hold secrets` : undefined @@ -625,24 +694,106 @@ export function showCommand(command: Command): string { return [...env, ...argv].join(" ") } -/** A subject — or a family — as the ledger and the sidebar show it. Paths and hosts are themselves. */ -export function showSubject(permission: string, subject: string): string { - if (canonical(permission) !== "bash") return subject +/** + * A subject as the ledger shows it, in its two parts: what runs, and where — when the agent changed + * folder first (`cd packages/api && npx vitest`, or the tool's own `workdir`). The place used to lead, + * `(in /Users/…/support-data-u…) TZ=… npx …`, and a row cut from the right lost the program, the one + * word that says what the command is. Now the command leads and the place follows, short + * (folders.ts `shortPlace`) and muted, so a row can cut the command's tail and keep both ends. + */ +export interface Shown { + /** The command (or path, or host) as the ledger shows it. */ + text: string + /** Where it ran, short: `packages/api`, `~/…/support-data-uploader/scripts`. */ + place?: string + /** Characters of `text` before its program: the env words in front (`TZ=UTC `). */ + lead: number + /** Characters of `text` up to the end of its program word: what a row must never cut. */ + head: number +} + +export const shownOf = remembered(shownNow) + +function shownNow(permission: string, subject: string): Shown { + const name = canonical(permission) + if (name === OUTSIDE) return { text: shortPath(subject), lead: 0, head: 0 } + if (name !== "bash") return { text: subject, lead: 0, head: 0 } const read = readSubject(subject) - if (!read) return subject - const words = showCommand(read.command) - return read.place === undefined ? words : `(in ${shown(read.place)}) ${words}` + if (!read) return { text: subject, lead: 0, head: 0 } + const text = showCommand(read.command) + const env = showCommand({ env: read.command.env, argv: [] }) + const lead = env === "" ? 0 : env.length + 1 + const program = read.command.argv[0] + const head = lead + (program === undefined ? 0 : shown(program).length) + if (read.place === undefined) return { text, lead, head } + /** A place with a space in it is quoted, so where it ends is not left to the reader. */ + const place = shortPlace(read.place) + return { text, place: /[\s'"]/.test(place) ? shown(place) : place, lead, head } +} + +/** `npx vitest run in packages/api`: the two parts as one string, for a sentence or a filter. */ +export const placed = (shown: Shown): string => + shown.place === undefined ? shown.text : `${shown.text} in ${shown.place}` + +/** A subject — or a family — as the ledger and the sidebar show it. Paths and hosts are themselves. */ +export const showSubject = (permission: string, subject: string): string => + placed(shownOf(permission, subject)) + +/** + * Where a command ran, whole, for the card to say in a sentence: `packages/api`, `~/work/other/api`. + * Nothing when it ran at the project's top. + */ +export function ranIn(permission: string, subject: string): string | undefined { + if (canonical(permission) !== "bash") return undefined + const place = readSubject(subject)?.place + return place === undefined ? undefined : shortPath(place) +} + +/** What a permission is called in front of its subject: nothing for a command, `outside` for a folder. */ +export const kindOf = (permission: string): string => + permission === "bash" ? "" : canonical(permission) === OUTSIDE ? "outside " : `${permission} ` + +/** + * Subjects as one line, what kind they are in front (`kindOf`): one keeps its two parts (`shownOf`); + * a line of several (`git add -A && git commit`) is one text, each with its place. + */ +export function lineShown(permission: string, subjects: readonly string[]): Shown { + const kind = kindOf(permission) + const [only] = subjects + if (subjects.length === 1 && only !== undefined) { + const one = shownOf(permission, only) + return kind === "" ? one : { ...one, text: `${kind}${one.text}`, lead: 0, head: 0 } + } + return { + text: `${kind}${subjects.map((each) => showSubject(permission, each)).join(" && ")}`, + lead: 0, + head: 0, + } } -/** `any ls …`, `any file in src/`: what a widened family answers, in words. */ -export function anyOf(permission: string, family: string): string { +/** + * `any ls …`, `any file in src/`, `any folder in $TMPDIR`: what a widened family answers, in words — + * a command's place after it, as `shownOf` puts it. + */ +export function anyShown(permission: string, family: string): Shown { const name = canonical(permission) + const words = (text: string): Shown => ({ text, lead: 0, head: 0 }) if (name === "edit" || name === "read") - return family === "./" ? "any file at the project's top" : `any file in ${family}` - if (family === "*") return `any ${name}` - return `any ${showSubject(name, family)} …` + return words(family === "./" ? "any file at the project's top" : `any file in ${family}`) + if (name === OUTSIDE) + return words(family === TEMP_FAMILY ? "any folder in $TMPDIR" : `any file in ${shortPath(family)}`) + if (family === "*") return words(`any ${name}`) + const shown = shownOf(name, family) + return { + ...shown, + text: `any ${shown.text} …`, + lead: shown.lead + 4, + head: shown.head + 4, + } } +export const anyOf = (permission: string, family: string): string => placed(anyShown(permission, family)) + /** * A command a little different from `subject`, for the sentence that says what still asks: the * command with its last argument dropped, or nothing when it has none past its family. diff --git a/packages/trust/src/core/folders.ts b/packages/trust/src/core/folders.ts new file mode 100644 index 00000000..a1e4ea73 --- /dev/null +++ b/packages/trust/src/core/folders.ts @@ -0,0 +1,146 @@ +/** + * Folders outside the project: what OpenCode's `external_directory` asks about, and how a place + * outside the project is said in a few cells. + * + * OpenCode asks `external_directory` before a tool reads or writes anything outside the project, with + * the folder as a glob — `/abs/dir/*` (its `patterns` on 1, `resources` on 2). On a user's ledger it + * was 522 of 9,149 prompts, almost all of them in the OS temp dir (`/var/folders/…/T/opencode/…`), and + * every one approved by hand. So a folder is counted, like any subject — and never answered by its + * count: a folder approved three times is still a person's call (`answersByCount`). What answers it is + * a folder the person widened with `w`, which covers the folders under it (`under`), never its + * siblings; and the temp dir is one family, `$TMPDIR/`, so one widening covers every scratch folder + * an agent makes there. + * + * Some folders are never suggested and never widened, however often they are approved (`refused`): the + * disk's root, your home folder and what holds it, the folders keys and settings live in (`~/.ssh`, + * `~/.aws`, `~/.config`, `/etc`…), anything that may hold secrets (effect.ts `sensitive`), and a folder + * that holds the project — widening it would answer for every project beside this one. + * + * The machine's own folders are read once, when Trust loads: the home folder and the temp dir do not + * move under a running OpenCode. Nothing here touches the disk. + */ + +import { homedir, tmpdir } from "node:os" +import { posix } from "node:path" +import { sensitive } from "./effect.ts" +import { canonical } from "./rules.ts" + +/** The permission OpenCode asks before a tool touches anything outside the project. */ +export const OUTSIDE = "external_directory" + +/** The temp dir, as one family: every folder in it is the same place to a person. */ +export const TEMP_FAMILY = "$TMPDIR/" + +/** + * Whether a subject's own count can answer it. Everything's can — except a folder outside the + * project, which only a widening answers. + */ +export const answersByCount = (permission: string): boolean => canonical(permission) !== OUTSIDE + +const trimmed = (path: string) => (path === "/" ? "/" : path.replace(/\/+$/, "")) + +/** + * The temp dir under every name it goes by. `os.tmpdir()` on macOS is `/var/folders/…/T`, whose real + * path is `/private/var/folders/…/T` — tools report either, so both are listed (the realpath, said as + * text: nothing is read from disk). `/tmp` and `/private/tmp` are the same place on every Unix. + */ +export function tempRootsOf(dir: string): string[] { + const base = trimmed(posix.normalize(dir)) + const roots = new Set([base, "/tmp", "/private/tmp"]) + if (base.startsWith("/private/")) roots.add(base.slice("/private".length)) + else if (/^\/(var|tmp)(\/|$)/.test(base)) roots.add(`/private${base}`) + /** The longest first, so `$TMPDIR/x` is read from the whole temp dir, not from `/tmp` inside it. */ + return [...roots].sort((a, b) => b.length - a.length) +} + +/** The temp dir as the OS names it, for a config rule that has to spell it out. */ +export const TEMP_DIR = trimmed(posix.normalize(tmpdir())) +const TEMP_ROOTS = tempRootsOf(TEMP_DIR) +const HOME = trimmed(posix.normalize(homedir())) + +/** `path` is `root` or inside it. */ +const within = (path: string, root: string) => + path === root || path.startsWith(root === "/" ? "/" : `${root}/`) + +/** The temp dir `path` is in, by the name it was given. */ +const tempRootOf = (path: string) => TEMP_ROOTS.find((root) => within(trimmed(path), root)) + +export const isTemp = (path: string): boolean => tempRootOf(path) !== undefined + +/** + * The folder a request names, from its glob: `/abs/dir/*` is `/abs/dir/`. Always absolute and ending + * in `/`; anything else — a relative path, an empty one — is not read as a folder. + */ +export function folderOf(pattern: string): string | undefined { + const bare = pattern.replace(/\*+$/, "") + if (!posix.isAbsolute(bare)) return undefined + const folder = trimmed(posix.normalize(bare)) + return folder === "/" ? "/" : `${folder}/` +} + +/** A folder's family: the temp dir is one (`$TMPDIR/`); any other folder is its own. */ +export const folderFamily = (folder: string): string => (isTemp(folder) ? TEMP_FAMILY : folder) + +/** Folders that hold keys and settings: never widened, and nothing under them either. */ +const KEPT = [".ssh", ".aws", ".gnupg", ".config", ".kube", ".docker"] +const SYSTEM = ["/etc", "/private/etc"] + +/** + * Why a folder outside the project is never suggested or widened — or nothing when it may be. `root`, + * when known, is the project: a folder that holds it would answer for every project beside it. + */ +export function refused(folder: string, root?: string): string | undefined { + if (folder === TEMP_FAMILY) return undefined + const path = trimmed(posix.normalize(folder)) + if (path === "/") return "it is the whole disk" + if (within(HOME, path)) return path === HOME ? "it is your home folder" : "it holds your home folder" + if (KEPT.some((name) => within(path, `${HOME}/${name}`)) || SYSTEM.some((dir) => within(path, dir))) + return "keys and settings live there" + if (sensitive(path) || sensitive(`${path}/`)) return "it may hold secrets" + if (root !== undefined && within(trimmed(posix.normalize(root)), path)) return "it holds the project" + return undefined +} + +/** A widened folder answers `folder`: it is that folder or under it — never a sibling. */ +export function under(folder: string, family: string): boolean { + if (family === TEMP_FAMILY) return isTemp(folder) + return within(trimmed(folder), trimmed(family)) +} + +/** Every folder a widening of which would answer `folder`: its family, itself, and each folder above it. */ +export function widenersOf(folder: string): string[] { + const out = new Set([folderFamily(folder)]) + let path = trimmed(folder) + while (path !== "/" && path !== ".") { + out.add(`${path}/`) + path = posix.dirname(path) + } + return [...out] +} + +/** + * A path as a person reads it: the temp dir as `$TMPDIR`, the home folder as `~`. Nothing is cut: + * `$TMPDIR/opencode/a1b2/`, `~/work/other/`. + */ +export function shortPath(path: string): string { + if (path === TEMP_FAMILY) return path + const temp = tempRootOf(path) + if (temp !== undefined) return `$TMPDIR${path.slice(temp.length)}` + if (within(trimmed(path), HOME)) return `~${path.slice(HOME.length)}` + return path +} + +/** + * Where a command ran, in a few cells, for the end of its row: a folder in the project as itself + * (`packages/api`), one outside as its last two folders after `~`, `$TMPDIR` or `/` — + * `~/…/support-data-uploader/scripts`. The project's folders are short already; an absolute one is + * what pushed the command off the row. + */ +export function shortPlace(place: string): string { + if (!posix.isAbsolute(place)) return place + const short = shortPath(place) + const [head, ...rest] = trimmed(short).split("/") + const parts = rest.filter((part) => part !== "") + if (parts.length <= 2) return short + return `${head}/…/${parts.slice(-2).join("/")}` +} diff --git a/packages/trust/src/core/keys.ts b/packages/trust/src/core/keys.ts index 86426a71..4f45c8b7 100644 --- a/packages/trust/src/core/keys.ts +++ b/packages/trust/src/core/keys.ts @@ -11,7 +11,8 @@ * | `glob`, `grep`, `websearch` | pattern, regex, query | `**\/*.ts` | * | `webfetch` | host | `docs.example.com` | * | `task` (v2 `subagent`) | agent type | `explore` | - * | `external_directory`, `doom_loop` | — never answered | | + * | `external_directory` | folder — answered only through a folder you widened (folders.ts) | `/tmp/x/` | + * | `doom_loop` | — never answered | | * | anything else | the request's patterns, together | `opencode_read_mcp_resource` `*` | * * The key a count is kept under is the subject and the permission — not the agent (ledger.keyOf): @@ -20,6 +21,7 @@ import { dangerOf } from "./danger.ts" import { sensitive } from "./effect.ts" +import { folderOf, OUTSIDE, refused } from "./folders.ts" import { canonical } from "./rules.ts" import type { Hasher } from "./secret.ts" import { type Command, parse } from "./shell.ts" @@ -78,7 +80,6 @@ export type Keyed = /** Permissions Trust never answers: they exist to make a person look. */ const NEVER: Record = { - external_directory: "outside the project — always yours to answer", doom_loop: "the agent is repeating itself — always yours to answer", } @@ -125,6 +126,7 @@ export function subjectsOf(request: Request, context: Context): Keyed { if (never) return { kind: "never", why: never } if (permission === "bash") return bash(request, context) if (request.patterns.length === 0) return { kind: "opaque", why: "the request names nothing" } + if (permission === OUTSIDE) return outside(request, context) if (permission === "webfetch") { const subjects: Subject[] = [] for (const url of request.patterns) { @@ -153,6 +155,24 @@ export function subjectsOf(request: Request, context: Context): Keyed { } } +/** + * A request to go outside the project: one subject per folder (`/abs/dir/*` is `/abs/dir/`). A folder + * that is never widened (`folders.refused`) — the disk, your home, `~/.ssh`, one that holds the project + * — leaves the whole request yours, and nothing about it is counted. + */ +function outside(request: Request, context: Context): Keyed { + const subjects: Subject[] = [] + for (const pattern of request.patterns) { + const folder = folderOf(pattern) + if (!folder) return { kind: "opaque", why: `not a folder: ${pattern}` } + if (refused(folder, context.root)) return { kind: "never", why: OUTSIDE_WHY } + subjects.push({ subject: folder, texts: [pattern] }) + } + return { kind: "subjects", subjects: dedupe(subjects), patterns: request.patterns } +} + +const OUTSIDE_WHY = "outside the project — always yours to answer" + /** Two patterns naming one host or one file are one subject; config is checked against both. */ function dedupe(subjects: Subject[]): Subject[] { const by = new Map() diff --git a/packages/trust/src/core/ledger.ts b/packages/trust/src/core/ledger.ts index d6ef0e17..0d92c7ce 100644 --- a/packages/trust/src/core/ledger.ts +++ b/packages/trust/src/core/ledger.ts @@ -12,7 +12,8 @@ * a ledger you edited by hand should cost you a rule, not the bay. */ -import { familyOf, notReadSubject } from "./family.ts" +import { familyOf, notReadSubject, placeless } from "./family.ts" +import { answersByCount } from "./folders.ts" import { canonical } from "./rules.ts" export interface Item { @@ -186,7 +187,19 @@ function entry(state: State, permission: string, agent: string, item: Item, at: const expired = (found: Entry, at: number, options: FoldOptions) => options.expireMs > 0 && found.lastAt > 0 && at - found.lastAt > options.expireMs +/** + * How many events have changed a state: what the screens key what they built from it on (view/model.ts), + * since a state is folded in place. Kept beside the state rather than in it, so a state stays plain data. + */ +const revisions = new WeakMap() +export const revisionOf = (state: State): number => revisions.get(state) ?? 0 + export function apply(state: State, event: Event, options: FoldOptions): State { + if (event.type !== "asked") revisions.set(state, revisionOf(state) + 1) + return applyNow(state, event, options) +} + +function applyNow(state: State, event: Event, options: FoldOptions): State { switch (event.type) { case "asked": return state @@ -229,19 +242,20 @@ export function apply(state: State, event: Event, options: FoldOptions): State { return state } case "widened": { - const key = keyOf(event.permission, event.family) + const family = placeless(event.permission, event.family) + const key = keyOf(event.permission, family) state.widened.set(key, { key, permission: canonical(event.permission), agent: event.agent, - family: event.family, + family, at: event.at, }) return state } case "unwidened": { /** A learned family taken away starts again from nothing, as a revoked rule does. */ - const key = keyOf(event.permission, event.family) + const key = keyOf(event.permission, placeless(event.permission, event.family)) state.widened.delete(key) state.reads.delete(key) /** A suggestion was answered once: undoing what it led to is not a reason to ask again. */ @@ -249,7 +263,7 @@ export function apply(state: State, event: Event, options: FoldOptions): State { return state } case "dismissed": - state.dismissed.add(keyOf(event.permission, event.family)) + state.dismissed.add(keyOf(event.permission, placeless(event.permission, event.family))) return state case "paused": state.paused = true @@ -343,6 +357,11 @@ export interface Standing { need: number trusted: boolean expired: boolean + /** + * Never trusted by its own count, however high: a folder outside the project (folders.ts), which + * only a folder you widened answers. + */ + yours?: true } /** A widening answers now: one a person made always; a learned one until it goes unused too long. */ @@ -366,6 +385,7 @@ export function standing( if (!found) return { have: 0, need, trusted: false, expired: false } const gone = settings.expireDays > 0 && now - found.lastAt > settings.expireDays * DAY const have = gone ? 0 : found.streak + if (!answersByCount(found.permission)) return { have, need, trusted: false, expired: gone, yours: true } return { have, need, trusted: have >= need, expired: gone } } diff --git a/packages/trust/src/core/paths.ts b/packages/trust/src/core/paths.ts index 08536f68..bb2e1057 100644 --- a/packages/trust/src/core/paths.ts +++ b/packages/trust/src/core/paths.ts @@ -23,6 +23,11 @@ export interface TrustPaths { events: string /** The key secrets are hashed with in signatures (core/secret.ts): beside the ledger, never in it. */ key: string + /** + * OpenCode's config as the interface last read it, for the agent half (tui/snapshot.ts): OpenCode 2 + * gives a server plugin no config to read, and its rules are what Trust decides with. + */ + rules: string } /** Anything that is not plainly a filename becomes a dash. */ @@ -51,5 +56,10 @@ export function trustPaths( env.COCKPIT_HOME ?? join(env.XDG_DATA_HOME ?? join(env.HOME ?? homedir(), ".local", "share"), "opencode-cockpit") const dir = join(base, "trust", `${projectSlug(directory)}-${shortHash(directory)}`) - return { dir, events: join(dir, "events.ndjson"), key: join(dir, "mask.key") } + return { + dir, + events: join(dir, "events.ndjson"), + key: join(dir, "mask.key"), + rules: join(dir, "rules.json"), + } } diff --git a/packages/trust/src/core/policy.ts b/packages/trust/src/core/policy.ts index c915d048..7c25be4d 100644 --- a/packages/trust/src/core/policy.ts +++ b/packages/trust/src/core/policy.ts @@ -13,13 +13,15 @@ * by a family you widened for this agent — unless it is one a widening never covers (dangerous, * writing a file, by redirection or by flag, running another program: `family.outside`) — or by a * family of reads Trust learned for this agent, which covers plain reads only (effect.ts). Config's - * `ask` was settled in 2, so it still wins over both. + * `ask` was settled in 2, so it still wins over both. A folder outside the project is never trusted + * by its count — only a folder you widened answers it, and only the folders under it (folders.ts). * * The answer always carries what an approval of the request would count towards, so a person's * approval of a request Trust declined is counted against exactly what Trust looked at. */ -import { anyOf, familyOf, notReadSubject, outside } from "./family.ts" +import { anyOf, notReadSubject, outside, wideningsOf } from "./family.ts" +import { answersByCount } from "./folders.ts" import { type Context, type Request, subjectsOf } from "./keys.ts" import { type Item, keyOf, live, type State, standing, type Thresholds } from "./ledger.ts" import { type ConfigRule, describeRule, gate } from "./rules.ts" @@ -34,6 +36,8 @@ export interface Progress { via?: string /** The family in `via` is one Trust learned from your approvals of reads in it. */ learned?: true + /** Never trusted by its count: a folder outside the project, until a folder you widened answers it. */ + yours?: true } export interface Judgement { @@ -93,6 +97,7 @@ export function decide(input: DecideInput): Judgement { trusted: where.trusted || via !== undefined, ...(via !== undefined ? { via } : {}), ...(through?.learned ? { learned: true as const } : {}), + ...(where.yours ? { yours: true as const } : {}), }) } const items: Item[] = progress.map(({ subject, danger }) => (danger ? { subject, danger } : { subject })) @@ -104,6 +109,8 @@ export function decide(input: DecideInput): Judgement { if (progress.length === 0) return left("config allows all of it by our reading — yours to answer") const short = progress.filter((each) => !each.trusted) + if (short.length > 0 && !answersByCount(request.permission)) + return { answer: false, why: OUTSIDE_WHY, items, progress } if (short.length > 0) { const worst = short.reduce((a, b) => (b.need - b.have > a.need - a.have ? b : a)) return { @@ -143,6 +150,8 @@ export function decide(input: DecideInput): Judgement { } } +const OUTSIDE_WHY = "outside the project — yours to answer, unless you widen a folder in /trust" + /** * The family this subject is answered through, for this agent: one you widened covers what a widening * covers (`outside`); one Trust learned covers plain reads only (`notReadSubject`). @@ -155,10 +164,12 @@ export function widenedFor( now: number, ): { family: string; learned?: true } | undefined { if (state.widened.size === 0) return undefined - const family = familyOf(permission, subject) - const found = state.widened.get(keyOf(permission, family)) - if (!found || !live(found, settings, now)) return undefined - if (found.learned) - return notReadSubject(permission, subject) === undefined ? { family, learned: true } : undefined - return outside(permission, subject) === undefined ? { family } : undefined + for (const family of wideningsOf(permission, subject)) { + const found = state.widened.get(keyOf(permission, family)) + if (!found || !live(found, settings, now)) continue + if (found.learned) + return notReadSubject(permission, subject) === undefined ? { family, learned: true } : undefined + return outside(permission, subject) === undefined ? { family } : undefined + } + return undefined } diff --git a/packages/trust/src/core/rules.ts b/packages/trust/src/core/rules.ts index fd7381c6..39da2db8 100644 --- a/packages/trust/src/core/rules.ts +++ b/packages/trust/src/core/rules.ts @@ -128,6 +128,43 @@ export function rulesFrom(config: unknown, agent?: string): ConfigRule[] { return [...documents.flatMap(own), ...documents.flatMap((document) => agentRules(document, agent))] } +/** The keys of a config document `rulesFrom` reads, and the per-agent blocks it reads them in. */ +const RULE_KEYS = ["permission", "permissions"] as const +const AGENT_KEYS = ["mode", "agent", "agents"] as const + +function rulesOnly(document: Json): Json { + const out: Json = {} + for (const key of RULE_KEYS) if (document[key] !== undefined) out[key] = document[key] + for (const key of AGENT_KEYS) { + const agents = obj(document[key]) + if (!agents) continue + const kept: Json = {} + for (const [name, value] of Object.entries(agents)) { + const agent = obj(value) + const rules = agent ? rulesOnly({ permission: agent.permission, permissions: agent.permissions }) : {} + if (Object.keys(rules).length > 0) kept[name] = rules + } + if (Object.keys(kept).length > 0) out[key] = kept + } + return out +} + +/** + * A config as `config.get` returned it, with nothing but what `rulesFrom` reads: the permissions, at + * the top and per agent. For writing it down (tui/snapshot.ts) — a config also holds providers and + * their keys, which have no business in another file. `rulesFrom` reads the result exactly as it + * reads the whole. + */ +export function rulesConfig(config: unknown): unknown { + if (Array.isArray(config)) + return config.flatMap((entry) => { + const info = obj(obj(entry)?.info) + return info ? [{ type: "document", info: rulesOnly(info) }] : [] + }) + const document = obj(config) + return document ? rulesOnly(document) : {} +} + /** The rule that decides `text` for `permission`, as OpenCode picks it: the last that matches. */ export function evaluate( rules: readonly ConfigRule[], diff --git a/packages/trust/src/core/sample.ts b/packages/trust/src/core/sample.ts index 86cf5a52..d80c8855 100644 --- a/packages/trust/src/core/sample.ts +++ b/packages/trust/src/core/sample.ts @@ -7,9 +7,10 @@ * engine actually produces rather than what a hand-written state hoped it would. */ +import { homedir, tmpdir } from "node:os" import { createEngine, type Engine } from "./engine.ts" import type { Request } from "./keys.ts" -import { DAY, type Event } from "./ledger.ts" +import { ANY_AGENT, DAY, type Event } from "./ledger.ts" import { rulesFrom } from "./rules.ts" export const SAMPLE_ROOT = "/work/app" @@ -29,7 +30,12 @@ export interface Sample { } const RULES = rulesFrom({ - permission: { bash: { "*": "ask", "npm publish *": "ask" }, edit: "ask", webfetch: "ask" }, + permission: { + bash: { "*": "ask", "npm publish *": "ask" }, + edit: "ask", + webfetch: "ask", + external_directory: "ask", + }, }) /** Runs `script` on a fresh engine; `record` gets every event it loads, in order — a ledger file's lines. */ @@ -134,6 +140,33 @@ function learning(s: Steps): void { s.auto("head -60 src/routes.ts", 1, "bash", "orchestrator") } +/** + * Work that leaves the project, as a user's ledger showed it: tests run after `cd packages/api`, a + * script run from a folder in the home directory, and the agent's scratch folders in the temp dir — + * fourteen approvals across six of them, which Trust suggests widening as one, `$TMPDIR`. Another + * repository was widened by hand, and a folder under it is answered. + */ +function elsewhere(s: Steps): void { + s.at(SAMPLE_NOW - 4 * 3_600_000) + const api = "cd packages/api && TZ=UTC npx vitest run" + s.approve(`${api} src/routes.test.ts`, 2) + s.approve(`${api} src/auth.test.ts`, 1) + s.approve("npx vitest run", 3) + s.auto("npx vitest run", 2) + const scripts = `${homedir()}/work/support-data-uploader/scripts` + s.approve(`cd ${scripts} && TZ=America/Bogota python3 upload.py --dry-run --batch 2024-09`, 2) + const temp = tmpdir().replace(/\/+$/, "") + const scratch = ["a1b2c3", "d4e5f6", "g7h8i9", "j1k2l3", "m4n5o6", "p7q8r9"] + scratch.forEach((id, at) => { + s.approve(`${temp}/opencode/${id}/*`, at < 2 ? 3 : 2, "once", "external_directory") + }) + const other = `${homedir()}/work/other-repo/` + s.approve(`${other}*`, 2, "once", "external_directory") + s.at(SAMPLE_NOW - 3_600_000) + s.widen(other, ANY_AGENT, "external_directory") + s.auto(`${other}src/*`, 1, "external_directory") +} + /** The `crowded` afternoon, as steps: the oldest first, so the newest are what the ledger leads with. */ function crowd(s: Steps): void { const general = (line: string, times: number, how: "once" | "always" = "once") => @@ -343,6 +376,9 @@ export const SAMPLES: Record Sample> = { /** 0.11: reads learned as families, a widening suggested, a secret file and production still asking. */ learning: () => ({ engine: build(learning) }), + /** 0.12: commands run from another folder, and folders outside the project — the temp dir suggested. */ + elsewhere: () => ({ engine: build(elsewhere) }), + /** Dangerous commands on their way: each needs eight in a row, and none of their families widens. */ dangerous: () => ({ engine: build((s) => { diff --git a/packages/trust/src/core/suggest.ts b/packages/trust/src/core/suggest.ts index dc395364..a4cd9dc2 100644 --- a/packages/trust/src/core/suggest.ts +++ b/packages/trust/src/core/suggest.ts @@ -13,6 +13,7 @@ */ import { widenable } from "./family.ts" +import { OUTSIDE } from "./folders.ts" import { keyOf } from "./ledger.ts" import { type Family, type Reading, standOf } from "./view/model.ts" @@ -28,11 +29,24 @@ export interface Suggestion { /** * Kinds a suggestion is made for: commands, edits and reads by folder, searches and web queries as a - * tool. Fetches (a host), agents and skills are as wide as they go already. + * tool, and folders outside the project — the temp dir as one (folders.ts), suggested once approvals + * across two or more of its folders reach the threshold. Fetches (a host), agents and skills are as + * wide as they go already. */ -const SUGGESTED = new Set(["bash", "edit", "read", "glob", "grep", "websearch"]) +const SUGGESTED = new Set(["bash", "edit", "read", "glob", "grep", "websearch", OUTSIDE]) export function suggestionsOf(reading: Reading, families: readonly Family[]): Suggestion[] { + /** Families are rebuilt whenever the ledger changes (view/model.ts), so they key what they suggest. */ + const kept = suggested.get(families) + if (kept) return kept + const out = suggestionsNow(reading, families) + suggested.set(families, out) + return out +} + +const suggested = new WeakMap() + +function suggestionsNow(reading: Reading, families: readonly Family[]): Suggestion[] { const { state, settings } = reading const out: Suggestion[] = [] for (const family of families) { diff --git a/packages/trust/src/core/view/actions.ts b/packages/trust/src/core/view/actions.ts index 4e333d17..ed34bb27 100644 --- a/packages/trust/src/core/view/actions.ts +++ b/packages/trust/src/core/view/actions.ts @@ -4,7 +4,8 @@ * in the ledger is ever changed in place. */ -import { anyOf, readSubject, showSubject, widenable } from "../family.ts" +import { anyOf, readSubject, shownOf, showSubject, widenable } from "../family.ts" +import { OUTSIDE, TEMP_DIR, TEMP_FAMILY } from "../folders.ts" import type { Answer } from "../history.ts" import { ANY_AGENT, type Event, type Widened } from "../ledger.ts" import { type AlwaysGroup, type Command, type Family, type Reading, stale } from "./model.ts" @@ -194,7 +195,7 @@ function familyOfAnswer(answer: Answer, families: readonly Family[]): string | u /** The family's name in a button: commands stay lowercase, where it runs is left to the card. */ export function familyLabel(permission: string, family: string): string { - const text = permission === "bash" ? showSubject("bash", family).replace(/^\(in .*?\) /, "") : family + const text = permission === "edit" ? family : shownOf(permission, family).text return text.length > 20 ? `${text.slice(0, 19)}…` : text } @@ -268,7 +269,13 @@ export function configSnippet(target: Target): { text: string; note?: string } { : [target.command.permission, target.command.subject] const placed = subject.match(/^\(in [^)]*\) (.*)$/s) const pattern = - permission === "webfetch" ? `https://${subject}/*` : placed ? (placed[1] as string) : subject + permission === "webfetch" + ? `https://${subject}/*` + : permission === OUTSIDE + ? `${subject}*` + : placed + ? (placed[1] as string) + : subject const text = JSON.stringify({ permission: { [permission]: { [pattern]: "allow" } } }) return placed ? { text, note: "a config rule cannot say where a command runs: this one allows it anywhere" } @@ -288,6 +295,9 @@ function familySnippet(family: Family): { text: string; note?: string } { } else if (family.permission === "edit") { pattern = `${family.family === "./" ? "" : family.family}*` notes.push("its * reaches into subfolders too") + } else if (family.permission === OUTSIDE) { + pattern = family.family === TEMP_FAMILY ? `${TEMP_DIR}/*` : `${family.family}*` + notes.push("its * reaches into subfolders too") } else if (family.permission === "webfetch") pattern = `https://${family.family}/*` const any = anyOf(family.permission, family.family) notes.unshift( diff --git a/packages/trust/src/core/view/activity.ts b/packages/trust/src/core/view/activity.ts index 3eea0489..1e2b68ba 100644 --- a/packages/trust/src/core/view/activity.ts +++ b/packages/trust/src/core/view/activity.ts @@ -31,7 +31,7 @@ */ import { closeHint, type Hint } from "@opencode-cockpit/client/design" -import { anyOf, showSubject } from "../family.ts" +import { anyOf, lineShown, placed, type Shown } from "../family.ts" import { type Answer, answersPerDay, dayOf, earned, type History, latestAnswers } from "../history.ts" import { keyOf, needFor } from "../ledger.ts" import { revokeLabel, type Target, widenLabel, widenScope } from "./actions.ts" @@ -62,11 +62,12 @@ import { meter, muted, plain, + shownRuns, since, sparkline, wrapRuns, } from "./parts.ts" -import { cursorRow, fit, type Row, type Run, spread, squeeze, widthOf } from "./rows.ts" +import { cursorRow, fit, type Row, type Run, rowText, spread, squeeze, widthOf } from "./rows.ts" export type ActivityItem = /** An answer Trust gave; `count` answers of the same line in a row are one item. */ @@ -92,10 +93,12 @@ export interface ActivityModel { const FEED_MAX = 60 /** What was answered, as the other lists say it: `edit src/app.ts`, `webfetch …`, a command bare. */ -const labelOf = (answer: Answer) => - `${answer.permission === "bash" ? "" : `${answer.permission} `}${answer.items - .map((item) => showSubject(answer.permission, item.subject)) - .join(" && ")}` +const shownOfAnswer = (answer: Answer): Shown => + lineShown( + answer.permission, + answer.items.map((item) => item.subject), + ) +const labelOf = (answer: Answer) => placed(shownOfAnswer(answer)) export function activityModel(input: Reading & { history: History }): ActivityModel { const { history, now } = input @@ -119,8 +122,9 @@ export function activityModel(input: Reading & { history: History }): ActivityMo if (feed.length >= FEED_MAX) break feed.push({ kind: "answer", key: `a:${answer.request}`, answer, count: 1 }) } + /** On their way to being trusted: a folder outside the project never is by its count, so is not here. */ const almost: ActivityItem[] = commands - .filter((command) => command.phase === "learning") + .filter((command) => command.phase === "learning" && !yours(command)) .sort( (a, b) => Number(a.danger !== undefined) - Number(b.danger !== undefined) || @@ -146,6 +150,11 @@ export function activityModel(input: Reading & { history: History }): ActivityMo } } +const yours = (command: Command) => { + const { stand } = leadOf(command) + return stand.kind === "counting" && stand.yours === true +} + /** Approvals still to go before Trust answers it. */ function distanceOf(command: Command): number { const { stand } = leadOf(command) @@ -507,7 +516,7 @@ function columnsOf(model: ActivityModel, width: number, now: number): Columns { const leads = model.items.map((item) => { if (item.kind === "answer") return lead + widthOf(labelOf(item.answer)) + (item.count > 1 ? 4 : 0) if (item.kind === "almost") - return lead + widthOf(showSubject(item.command.permission, item.command.subject)) + return lead + widthOf(placed(lineShown(item.command.permission, [item.command.subject]))) return lead + widthOf(item.group.patterns.join(" ")) }) /** The commands get what they need up to about half the row; the reasons take the rest. */ @@ -554,13 +563,13 @@ function itemRow(item: ActivityItem, input: ActivityInput, columns: Columns, wid const { answer } = item const count = item.count > 1 ? ` ${item.count}×` : "" const room = Math.max(4, columns.chipAt - lead - 2 - count.length) - const label = squeeze(labelOf(answer), room) + const label = shownRuns(shownOfAnswer(answer), room) return fit( [ ...start("✓", "success", clock(answer.at, input.now)), - plain(label), + ...label, muted(count), - { text: " ".repeat(Math.max(0, room - widthOf(label))) }, + { text: " ".repeat(Math.max(0, room - widthOf(rowText(label)))) }, { text: " " }, ...chipCell(answer.agent, columns), muted(answerWhy(answer, input)), @@ -574,11 +583,12 @@ function itemRow(item: ActivityItem, input: ActivityInput, columns: Columns, wid const danger = command.danger !== undefined const room = Math.max(4, columns.chipAt - lead - 2) const counting = stand.kind === "counting" ? stand : undefined - const name = `${command.permission === "bash" ? "" : `${command.permission} `}${showSubject(command.permission, command.subject)}` + const name = shownRuns(lineShown(command.permission, [command.subject]), room) return fit( [ ...start("○", danger ? "error" : "warning"), - plain(cell(name, room)), + ...name, + { text: " ".repeat(Math.max(0, room - widthOf(rowText(name)))) }, { text: " " }, ...chipCell(undefined, columns), ...(counting ? meter(counting.have, counting.need, danger) : []), diff --git a/packages/trust/src/core/view/card.ts b/packages/trust/src/core/view/card.ts index 2b2a217c..b6125f2d 100644 --- a/packages/trust/src/core/view/card.ts +++ b/packages/trust/src/core/view/card.ts @@ -3,7 +3,20 @@ * the exact text and what still asks, the approvals that earned it, its family, and the buttons. */ -import { anyOf, narrower, outside, redirected, showSubject, spelledWords, widenable } from "../family.ts" +import { + anyOf, + kindOf, + narrower, + outside, + ranIn, + redirected, + type Shown, + shownOf, + showSubject, + spelledWords, + widenable, +} from "../family.ts" +import { OUTSIDE, TEMP_FAMILY } from "../folders.ts" import { dayOf, earned, type History, latestAnswers, type Mark } from "../history.ts" import { keyOf, type Thresholds } from "../ledger.ts" import type { Suggestion } from "../suggest.ts" @@ -23,7 +36,7 @@ import { } from "./model.ts" import { button, chip, clock, meter, muted, plain, plural, since, when, wrapRuns } from "./parts.ts" import { filled, fit, type Row, type Run, rowText, squeeze, widthOf } from "./rows.ts" -import { commandText, familyText, type Node, nodeTarget, SECTION_TITLES } from "./tree.ts" +import { commandShown, familyShown, familyText, type Node, nodeTarget, SECTION_TITLES } from "./tree.ts" /** `Still asks` and two spaces: the longest label. */ export const LABEL = 12 @@ -73,6 +86,7 @@ function standingShort(command: Command): Run[] { if (stand.kind === "widened") return [{ text: stand.learned ? "✓ read" : "✓ any", tone: "success" }] if (stand.kind === "counting" && stand.expired) return [muted("expired")] if (command.phase === "once") return [muted("○ once")] + if (stand.kind === "counting" && stand.yours) return [muted(`${stand.have}× yours`)] return stand.kind === "counting" ? [...meter(stand.have, stand.need, command.danger !== undefined), muted(` ${stand.have}/${stand.need}`)] : [] @@ -127,7 +141,8 @@ function exactly(command: Command): Fact { keep: 3, lines: [ [ - plain(commandText(command)), + /** Where it ran has a line of its own (`whereFact`): this is the command, exactly. */ + plain(`${kindOf(command.permission)}${shownOf(command.permission, command.subject).text}`), ...(spelled.length > 0 ? [ muted( @@ -140,8 +155,16 @@ function exactly(command: Command): Fact { } } +/** What a widened folder outside the project never answers: what is beside it, and a secret. */ +const notUnder = (family: string) => + family === TEMP_FAMILY + ? "a folder that may hold secrets" + : "folders beside it, and any that may hold secrets" + /** What still asks once a command is trusted exactly: a smaller one, and the same into a file. */ function stillAsks(command: Command, stand: Stand): Fact { + if (stand.kind === "widened" && command.permission === OUTSIDE) + return { label: "Still asks", keep: 6, lines: [[muted(`${notUnder(stand.family)}.`)]] } if (stand.kind === "widened") return { label: "Still asks", keep: 6, lines: [[muted(`${stand.learned ? NOT_READ : NOT_COVERED}.`)]] } if (command.permission === "edit") @@ -235,6 +258,12 @@ function historySaid(standing: Standing, marks: readonly Mark[], reading: CardRe ), ] if (stand.expired) return [muted(`unused over ${settings.expireDays} days, so it counts again from 0`)] + if (stand.yours) + return [ + muted( + `approved ${entry.approvals}× · outside the project, its count never answers it — [w] on its folder does`, + ), + ] const got = earned(marks, stand.need, expireMs) const left = stand.need - stand.have if (got.broken === "rejected" && got.brokenAt !== undefined) @@ -264,6 +293,11 @@ function standingRuns(standing: Standing, danger: boolean): Run[] { ), ] if (stand.expired) return [{ text: "○ Expired", tone: "muted", bold: true }, muted(" · counting from 0")] + if (stand.yours) + return [ + { text: "○ Yours", tone: "text", bold: true }, + muted(` · approved ${stand.have}× in a row · outside the project, asked until you widen a folder`), + ] return [ ...meter(stand.have, stand.need, danger), { text: ` ${stand.have} of ${stand.need}`, tone: "text", bold: true }, @@ -326,6 +360,8 @@ function commandCard(command: Command, family: Family | undefined, reading: Card ? lead.stand.need : settings.threshold + (danger ? settings.dangerExtra : 0) const facts: Fact[] = [exactly(command)] + const where = whereFact(command.permission, command.subject) + if (where) facts.push(where) if (command.phase === "answering") facts.push(stillAsks(command, lead.stand)) facts.push({ label: "History", @@ -378,13 +414,41 @@ function commandCard(command: Command, family: Family | undefined, reading: Card }) } return { - title: [{ text: commandText(command), tone: "text", bold: true }], + title: titleRuns(commandShown(command)), standing: command.standings.map((each) => standingRuns(each, danger)), facts, buttons: [], } } +/** A title: the command bold, where it ran after it, muted — as its row says it. */ +const titleRuns = (shown: Shown): Run[] => [ + { text: shown.text, tone: "text", bold: true }, + ...(shown.place === undefined ? [] : [muted(` in ${shown.place}`)]), +] + +/** + * Where a command ran, when the agent changed folder first, said once in plain words: a row only + * has room for `in packages/api`, which reads like part of the command until you know. + */ +function whereFact(permission: string, subject: string): Fact | undefined { + const place = ranIn(permission, subject) + if (place === undefined) return undefined + return { + label: "Where", + keep: 5, + lines: [ + [ + muted("Run from "), + plain(place), + muted( + " — the agent changed folder first. Trust counts the same command in another folder separately.", + ), + ], + ], + } +} + const needOf = (settings: Thresholds, danger: boolean) => settings.threshold + (danger ? settings.dangerExtra : 0) @@ -399,7 +463,13 @@ function familyCard(family: Family, reading: CardReading): CardParts { : family.widened.length > 0 ? family.widened.map((each) => [ { text: "✓ Any", tone: "success", bold: true }, - plain(` ${showSubject(family.permission, family.family)} …`), + plain( + ` ${ + family.permission === "bash" || family.permission === OUTSIDE + ? anyOf(family.permission, family.family).replace(/^any /, "") + : `${showSubject(family.permission, family.family)} …` + }`, + ), muted( each.learned ? ` reads · learned from your approvals ${when(reading.now - each.at)}` @@ -424,13 +494,16 @@ function familyCard(family: Family, reading: CardReading): CardParts { facts.push({ label: family.permission === "edit" ? "Files" : "Commands", keep: 7, + /** A family's commands share its place: its title says it once. */ lines: family.commands.map((command) => [ - plain(showSubject(command.permission, command.subject)), + plain(shownOf(command.permission, command.subject).text), { text: " " }, ...standingShort(command), ]), table: true, }) + const where = whereFact(family.permission, family.family) + if (where && !stale(family)) facts.push(where) if (stale(family)) facts.push({ label: "Old", @@ -447,14 +520,24 @@ function familyCard(family: Family, reading: CardReading): CardParts { facts.push({ label: "Still asks", keep: 6, - lines: [[muted(`${family.widened.every((each) => each.learned) ? NOT_READ : NOT_COVERED}.`)]], + lines: [ + [ + muted( + `${family.permission === OUTSIDE ? notUnder(family.family) : family.widened.every((each) => each.learned) ? NOT_READ : NOT_COVERED}.`, + ), + ], + ], }) const fam = stale(family) ? undefined : familyFact(family, reading) if (fam) facts.push({ ...fam, label: "Widen", lines: fam.lines.slice(1) }) return { title: [ - { text: familyText(family), tone: "text", bold: true }, - muted(stale(family) ? " an old widening" : ` family of ${plural(family.commands.length, "command")}`), + ...titleRuns(familyShown(family)), + muted( + stale(family) + ? " an old widening" + : ` family of ${plural(family.commands.length, family.permission === OUTSIDE ? "folder" : "command")}`, + ), ], standing, facts, @@ -521,7 +604,7 @@ function todayCard(reading: CardReading): CardParts { /** What and when; why it answered is the activity's to say, one key away. */ lines: today.map((answer) => [ plain( - `${answer.permission === "bash" ? "" : `${answer.permission} `}${answer.items + `${kindOf(answer.permission)}${answer.items .map((item) => showSubject(answer.permission, item.subject)) .join(" && ")}`, ), @@ -531,7 +614,12 @@ function todayCard(reading: CardReading): CardParts { }) const almost = commandsOf(reading).filter((command) => { const { stand } = leadOf(command) - return command.phase === "learning" && stand.kind === "counting" && stand.need - stand.have === 1 + return ( + command.phase === "learning" && + stand.kind === "counting" && + !stand.yours && + stand.need - stand.have === 1 + ) }) if (almost.length > 0) facts.push({ @@ -669,13 +757,20 @@ function suggestCard(suggestion: Suggestion, reading: CardReading): CardParts { .flatMap((command) => command.standings) .flatMap(({ entry }) => reading.history.marks.get(entry.key) ?? []) .filter((each) => each.kind === "approved" && each.at >= midnight).length + const outsider = family.permission === OUTSIDE + const where = + family.family === TEMP_FAMILY ? "in your temp directory" : `under ${showSubject(OUTSIDE, family.family)}` return { ...parts, /** The family's own `Widen` says what `w` does in general; this card says it for the suggestion. */ standing: [ [ { text: "★ Suggested", tone: "warning", bold: true }, - muted(` · ${plural(approvals, "approval")} in a row across ${plural(commands, "command")}`), + muted( + outsider + ? ` · approved ${approvals}× across ${plural(commands, "folder")} ${where}` + : ` · ${plural(approvals, "approval")} in a row across ${plural(commands, "command")}`, + ), ], ], facts: [ @@ -683,11 +778,19 @@ function suggestCard(suggestion: Suggestion, reading: CardReading): CardParts { label: "Widen", keep: 6, lines: [ - [ - muted("[w] trusts "), - plain(anyOf(family.permission, family.family)), - muted(`${family.permission === "bash" ? ` — ${EXCEPT}` : ""}. [d] stops suggesting it.`), - ], + outsider + ? [ + muted("[w] lets Trust answer reads and writes there from now on — "), + plain(anyOf(family.permission, family.family)), + muted( + `; ${notUnder(family.family)} still ${family.family === TEMP_FAMILY ? "asks" : "ask"}. [d] stops suggesting it.`, + ), + ] + : [ + muted("[w] trusts "), + plain(anyOf(family.permission, family.family)), + muted(`${family.permission === "bash" ? ` — ${EXCEPT}` : ""}. [d] stops suggesting it.`), + ], ], }, ...(today > 0 diff --git a/packages/trust/src/core/view/explorer.ts b/packages/trust/src/core/view/explorer.ts index 80856a05..0761c59a 100644 --- a/packages/trust/src/core/view/explorer.ts +++ b/packages/trust/src/core/view/explorer.ts @@ -33,7 +33,7 @@ */ import { closeHint, type Hint } from "@opencode-cockpit/client/design" -import { showSubject } from "../family.ts" +import { lineShown, placed } from "../family.ts" import { dayOf, type History, latestAnswers } from "../history.ts" import { type Button, @@ -298,7 +298,12 @@ function todayRow( ] : [muted("nothing answered yet")] const label = last - ? `${last.permission === "bash" ? "" : `${last.permission} `}${last.items.map((item) => showSubject(last.permission, item.subject)).join(" && ")}` + ? placed( + lineShown( + last.permission, + last.items.map((item) => item.subject), + ), + ) : "" const room = Math.max(0, width - widthOf([...lead, ...said, ...right].map((run) => run.text).join("")) - 2) const row = spread([...lead, ...said, ...(label ? [plain(squeeze(label, room))] : [])], right, width) diff --git a/packages/trust/src/core/view/model.ts b/packages/trust/src/core/view/model.ts index 8bf67d8d..a425e4fc 100644 --- a/packages/trust/src/core/view/model.ts +++ b/packages/trust/src/core/view/model.ts @@ -9,7 +9,15 @@ */ import { familyOf } from "../family.ts" -import { type Always, type Entry, type State, standing, type Thresholds, type Widened } from "../ledger.ts" +import { + type Always, + type Entry, + revisionOf, + type State, + standing, + type Thresholds, + type Widened, +} from "../ledger.ts" import { widenedFor } from "../policy.ts" export interface Reading { @@ -23,21 +31,32 @@ export type Stand = | { kind: "trusted" } /** Not trusted by its own count, answered through a family you widened — or Trust learned. */ | { kind: "widened"; family: string; learned?: true } - | { kind: "counting"; have: number; need: number; expired: boolean } + /** `yours`: never trusted by its count — a folder outside the project (folders.ts). */ + | { kind: "counting"; have: number; need: number; expired: boolean; yours?: true } export function standOf(entry: Entry, { state, settings, now }: Reading): Stand { const where = standing(entry, entry.danger, settings, now) if (where.trusted) return { kind: "trusted" } const through = widenedFor(state, entry.permission, entry.subject, settings, now) if (through) return { kind: "widened", ...through } - return { kind: "counting", have: where.have, need: where.need, expired: where.expired } + return { + kind: "counting", + have: where.have, + need: where.need, + expired: where.expired, + ...(where.yours ? { yours: true as const } : {}), + } } export const answers = (stand: Stand): boolean => stand.kind !== "counting" /** Approvals still needed; an expired count is as far as it gets. Zero: it answers. */ export const distance = (stand: Stand): number => - stand.kind !== "counting" ? 0 : stand.expired ? Number.MAX_SAFE_INTEGER : stand.need - stand.have + stand.kind !== "counting" + ? 0 + : stand.expired || stand.yours + ? Number.MAX_SAFE_INTEGER + : stand.need - stand.have /** A command's standing: its entry (the project's), and where that puts it. */ export interface Standing { @@ -90,6 +109,27 @@ function phaseOf(standings: readonly Standing[]): Phase { * nothing to show and is left out. */ export function commandsOf(reading: Reading): Command[] { + /** + * Built once per change: both screens ask on every paint, and every keystroke paints — on a real + * ledger of 13,000 commands, rebuilding was most of a 70ms keystroke. A standing turns on time only + * by days (expiry), so the minute is as fine as the clock needs to be. + */ + const minute = Math.floor(reading.now / 60_000) + const kept = built.get(reading.state) + const revision = revisionOf(reading.state) + if (kept && kept.revision === revision && kept.minute === minute && kept.settings === reading.settings) + return kept.commands + const commands = commandsNow(reading) + built.set(reading.state, { revision, minute, settings: reading.settings, commands }) + return commands +} + +const built = new WeakMap< + State, + { revision: number; minute: number; settings: Thresholds; commands: Command[] } +>() + +function commandsNow(reading: Reading): Command[] { const byKey = new Map() for (const entry of reading.state.entries.values()) { if (entry.approvals === 0 && entry.autos === 0) continue @@ -165,6 +205,17 @@ function familyRank(family: Family): number { * and families seen only once last. */ export function familiesOf(reading: Reading, commands: readonly Command[] = commandsOf(reading)): Family[] { + /** The same commands are the same families: kept with the commands they were built from. */ + const kept = grouped.get(commands) + if (kept) return kept + const families = familiesNow(reading, commands) + grouped.set(commands, families) + return families +} + +const grouped = new WeakMap() + +function familiesNow(reading: Reading, commands: readonly Command[]): Family[] { const families = new Map() const familyFor = (permission: string, family: string) => { const key = familyKey(permission, family) diff --git a/packages/trust/src/core/view/parts.ts b/packages/trust/src/core/view/parts.ts index 15070040..99052810 100644 --- a/packages/trust/src/core/view/parts.ts +++ b/packages/trust/src/core/view/parts.ts @@ -9,7 +9,8 @@ */ import { fitHints, GLYPH, type Hint, keyName } from "@opencode-cockpit/client/design" -import { ago, fit, type Row, type Run, spread, type Tone, widthOf } from "./rows.ts" +import type { Shown } from "../family.ts" +import { ago, cut, fit, type Row, type Run, spread, squeeze, type Tone, widthOf } from "./rows.ts" export const muted = (text: string): Run => ({ text, tone: "muted" }) export const plain = (text: string): Run => ({ text, tone: "text" }) @@ -57,6 +58,56 @@ export function meter(have: number, need: number, danger: boolean): Run[] { ] } +/* ─── a command and where it ran ─────────────────────────────────────────────────────────────── */ + +/** Cells a command keeps however short a row is, before the place gives way too. */ +const COMMAND_MIN = 10 + +/** The most of a row a place takes when the command does not fit beside it whole. */ +const PLACE_SHARE = 0.4 + +/** + * A subject in `width` columns as two runs: what runs, in `tone`, then where it ran, muted — + * `npx vitest run src/… in packages/api`. The place is short already (family.ts `shownOf`); when the + * command does not fit beside it, the place keeps at most `PLACE_SHARE` of the row — its last folder + * (`in …/api`) if the whole does not fit there — and the command gives way from its tail, its paths + * cut in the middle first (`squeeze`). Never its program: when the env words in front leave no room + * for it, they go instead (`… npx vitest`). Exactly `width` or fewer columns; the caller pads. + */ +export function shownRuns(shown: Shown, width: number, tone: Tone = "text"): Run[] { + const place = placeIn(shown, width) + const room = Math.max(Math.min(COMMAND_MIN, width), width - widthOf(place)) + const text = commandIn(shown, room) + const left = width - widthOf(text) + return [{ text, tone }, ...(place !== "" && left > 0 ? [muted(cut(place, left))] : [])] +} + +/** ` in packages/api`, or ` in …/api` when the command needs the room. */ +function placeIn(shown: Shown, width: number): string { + if (shown.place === undefined) return "" + const whole = ` in ${shown.place}` + if (widthOf(shown.text) + widthOf(whole) <= width) return whole + const share = Math.max(8, Math.floor(width * PLACE_SHARE)) + if (widthOf(whole) <= share) return whole + const last = shown.place.replace(/\/+$/, "").split("/").at(-1) ?? shown.place + return last === shown.place ? whole : ` in …/${last}` +} + +/** The command's text in `room` columns, its program kept. */ +function commandIn(shown: Shown, room: number): string { + const { text, lead, head } = shown + if (widthOf(text) <= room) return text + /** `…` and a space after the program: the least a cut command shows of what follows it. */ + if (lead === 0 || widthOf(text.slice(0, head)) + 2 <= room) return squeeze(text, room) + return squeeze(`… ${text.slice(lead)}`, room) +} + +/** A shown subject without the words a heading above it already says: `execute_sql` under `mcpx db-local`. */ +export function unprefixed(shown: Shown, prefix: string): Shown { + if (prefix === "" || !shown.text.startsWith(`${prefix} `)) return shown + return { ...shown, text: shown.text.slice(prefix.length + 1), lead: 0, head: 0 } +} + const BARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] /** A day per cell, scaled to the busiest: a day with none is the empty track, in the border tone. */ diff --git a/packages/trust/src/core/view/sidebar.ts b/packages/trust/src/core/view/sidebar.ts index 56808341..42588266 100644 --- a/packages/trust/src/core/view/sidebar.ts +++ b/packages/trust/src/core/view/sidebar.ts @@ -27,9 +27,10 @@ import { HEADING_GAP } from "@opencode-cockpit/client/design" import type { Answered, Pending } from "../engine.ts" -import { anyOf, showSubject } from "../family.ts" +import { anyShown, kindOf, lineShown, type Shown } from "../family.ts" import { keyOf, type State, standing, type Thresholds } from "../ledger.ts" -import { fit, type Row, spread, widthOf } from "./rows.ts" +import { shownRuns } from "./parts.ts" +import { fit, type Row, type Run, spread, widthOf } from "./rows.ts" export interface SidebarInput { width: number @@ -95,14 +96,22 @@ function summary(count: number, project: Tally, room: number): Row { return parts[0] ?? [] } -/** `edit src/app.ts`, but a command is just the command: bash is the common case. */ -export function labelOf(permission: string, label: string): Row { - return permission === "bash" - ? [{ text: label, tone: "text" }] - : [ - { text: `${permission} `, tone: "tool" }, - { text: label, tone: "text" }, - ] +/** + * `edit src/app.ts`, but a command is just the command: bash is the common case. In `room` columns, + * the command first and where it ran after it (`shownRuns`), the program never cut; then `after`. + */ +export function labelOf( + permission: string, + subjects: readonly string[], + room: number, + after: Run[] = [], +): Row { + const kind = kindOf(permission) + const line = lineShown(permission, subjects) + const shown: Shown = kind === "" ? line : { ...line, text: line.text.slice(kind.length) } + /** The command and its place first; what follows (`· any ls`) is what the row cuts when short. */ + const runs = shownRuns(shown, Math.max(1, room - widthOf(kind))) + return [...(kind === "" ? [] : [{ text: kind, tone: "tool" as const }]), ...runs, ...after] } /** How often Trust answered this in all: the least of its parts, since a line is answered whole. */ @@ -120,7 +129,8 @@ export function sidebarRows(input: SidebarInput): Row[] { /** Requests on screen that Trust has started counting; a first sighting at 0 is not news. */ const counting = input.pending.flatMap((pending) => { - const short = pending.judgement.progress.filter((p) => !p.trusted) + /** A folder outside the project counts towards nothing: only a widening ever answers it. */ + const short = pending.judgement.progress.filter((p) => !p.trusted && !p.yours) if (short.length === 0 || pending.judgement.answer) return [] const worst = short.reduce((a, b) => (b.need - b.have > a.need - a.have ? b : a)) return worst.have > 0 ? [{ permission: pending.request.permission, worst }] : [] @@ -159,16 +169,17 @@ export function sidebarRows(input: SidebarInput): Row[] { { text: "● ", tone: "success" }, ...labelOf( answered.permission, - answered.subjects.map((subject) => showSubject(answered.permission, subject)).join(" && "), + answered.subjects, + width - 2 - 1 - widthOf(`${times(state, answered)}×`), + answered.via !== undefined + ? [ + { + text: ` · ${anyShown(answered.permission, answered.via).text.replace(/ …$/, "")}`, + tone: "muted", + }, + ] + : [], ), - ...(answered.via !== undefined - ? [ - { - text: ` · ${anyOf(answered.permission, answered.via).replace(/ …$/, "")}`, - tone: "muted" as const, - }, - ] - : []), ], [{ text: `${times(state, answered)}×`, tone: "muted" }], width, @@ -177,7 +188,10 @@ export function sidebarRows(input: SidebarInput): Row[] { for (const { permission, worst } of counting) rows.push( spread( - [{ text: "○ ", tone: "warning" }, ...labelOf(permission, showSubject(permission, worst.subject))], + [ + { text: "○ ", tone: "warning" }, + ...labelOf(permission, [worst.subject], width - 2 - 1 - widthOf(`${worst.have}/${worst.need}`)), + ], [{ text: `${worst.have}/${worst.need}`, tone: worst.danger ? "error" : "warning" }], width, ), diff --git a/packages/trust/src/core/view/tree.ts b/packages/trust/src/core/view/tree.ts index 6d023808..c56b19b5 100644 --- a/packages/trust/src/core/view/tree.ts +++ b/packages/trust/src/core/view/tree.ts @@ -16,7 +16,8 @@ */ import { dangerOf } from "../danger.ts" -import { readSubject, shown, showSubject } from "../family.ts" +import { anyShown, kindOf, lineShown, readSubject, type Shown, shown, showSubject } from "../family.ts" +import { OUTSIDE } from "../folders.ts" import { type Suggestion, suggestionsOf } from "../suggest.ts" import type { Target } from "./actions.ts" import { @@ -32,7 +33,7 @@ import { type Reading, stale, } from "./model.ts" -import { badge, meter, muted, plain, plural } from "./parts.ts" +import { badge, meter, muted, plain, plural, shownRuns, unprefixed } from "./parts.ts" import { cursorRow, fit, type Row, type Run, rowText, spread, squeeze, type Tone, widthOf } from "./rows.ts" /** The kinds a ledger is grouped by, in the order they are listed; suggestions above them all. */ @@ -122,17 +123,33 @@ const seenOnce = (family: Family) => family.widened.length === 0 && family.commands.every((command) => command.phase === "once") export const commandText = (command: Command) => - `${command.permission === "bash" ? "" : `${command.permission} `}${showSubject(command.permission, command.subject)}` + `${kindOf(command.permission)}${showSubject(command.permission, command.subject)}` /** A family as its heading says it: `head`, `read src/`, and a whole tool (`*`) as its name alone — `grep`. */ export const familyText = (family: Family) => family.family === "*" ? family.permission - : `${family.permission === "bash" ? "" : `${family.permission} `}${showSubject(family.permission, family.family)}` + : `${kindOf(family.permission)}${showSubject(family.permission, family.family)}` -/** A command as its row says it: under Edits the path alone, the heading says what kind it is. */ -const rowCommandText = (command: Command) => - command.permission === "edit" ? command.subject : commandText(command) -const rowFamilyText = (family: Family) => (family.permission === "edit" ? family.family : familyText(family)) +/** What a row draws, command first and its place after (`shownRuns`): under Edits the path alone. */ +export const commandShown = (command: Command): Shown => + command.permission === "edit" + ? { text: command.subject, lead: 0, head: 0 } + : lineShown(command.permission, [command.subject]) +export const familyShown = (family: Family): Shown => + family.permission === "edit" + ? { text: family.family, lead: 0, head: 0 } + : family.family === "*" + ? { text: family.permission, lead: 0, head: 0 } + : lineShown(family.permission, [family.family]) + +/** A suggestion as its row says it: `any npx vitest in packages/api`, `any folder in $TMPDIR`. */ +function suggestShown(family: Family): Shown { + if (family.permission === OUTSIDE) return anyShown(family.permission, family.family) + const shown = familyShown(family) + return { ...shown, text: `any ${shown.text}`, lead: shown.lead + 4, head: shown.head + 4 } +} + +const rowFamilyText = (family: Family) => familyShown(family).text export function explorerModel(input: Reading & Tree & { today?: number }): ExplorerModel { const commands = commandsOf(input) @@ -372,6 +389,7 @@ function statusRuns(node: Node): Run[] { if (stand.kind === "widened") return [{ text: stand.learned ? "✓ read" : "✓ any", tone: "success" }] if (stand.expired) return [muted("expired")] if (command.phase === "once") return [muted("○ once")] + if (stand.yours) return [muted(`${stand.have}× yours`)] return [ ...meter(stand.have, stand.need, command.danger !== undefined), muted(` ${stand.have}/${stand.need}`), @@ -405,13 +423,17 @@ export function treeRow( : [] const risk = familyRisk(node.family) const marks = [...any, ...(risk ? [{ text: " " }, badge(risk, "error")] : [])] - const name = squeeze( - label(rowFamilyText(node.family), node.prefix), + const name = shownRuns( + unprefixed(familyShown(node.family), node.prefix), room - 3 - indent.length - widthOf(rowText([...files, ...marks])), + seenOnce(node.family) ? "muted" : "text", ) + /** Bold is the family's name, not where it ran. */ + const [first, ...place] = name left = [ muted(` ${indent}${node.open ? "▾" : "▸"} `), - { text: name, tone: seenOnce(node.family) ? "muted" : "text", bold: true }, + ...(first ? [{ ...first, bold: true }] : []), + ...place, ...marks, ...files, ] @@ -439,17 +461,18 @@ export function treeRow( const lead = (node.nested ? 5 : 3) + indent.length left = [ { text: " ".repeat(lead) }, - { - text: squeeze(nestedText(node, room - lead), room - lead), - tone: node.command.phase === "once" ? "muted" : "text", - }, + ...shownRuns( + nestedShown(node, room - lead), + room - lead, + node.command.phase === "once" ? "muted" : "text", + ), ] } else if (node.kind === "more") left = [muted(` ${indent}+ ${node.hidden} more`)] else if (node.kind === "always") left = [{ text: " ! ", tone: "warning" }, plain("OpenCode always")] else if (node.kind === "suggest") { left = [ { text: " ★ ", tone: "warning" }, - plain(squeeze(`any ${rowFamilyText(node.suggestion.family)}`, Math.max(4, room - 4))), + ...shownRuns(suggestShown(node.suggestion.family), Math.max(4, room - 4)), muted("?"), ] } else left = [] @@ -471,30 +494,34 @@ export function treeRow( /** * A command under its family's heading, in `room` columns. Cut from the right, two variants of one * family read alike (`git status --sho…` twice), so a command that does not fit gives up the family's - * words first — the heading above already says them: `… --short -uno`. + * words first — the heading above already says them: `… --short -uno`. Its place stays at the end. */ -function nestedText(node: Extract, room: number): string { - const text = label(rowCommandText(node.command), node.prefix) - if (widthOf(text) <= room || !node.nested) return text - const family = label(rowFamilyText(node.family), node.prefix) - if (node.family.permission === "edit" && text.startsWith(family)) return text.slice(family.length) - return text.startsWith(`${family} `) ? `…${text.slice(family.length)}` : text +function nestedShown(node: Extract, room: number): Shown { + const shown = unprefixed(commandShown(node.command), node.prefix) + const { text } = shown + const place = shown.place === undefined ? 0 : widthOf(` in ${shown.place}`) + if (widthOf(text) + place <= room || !node.nested) return shown + const family = unprefixed(familyShown(node.family), node.prefix).text + if (node.family.permission === "edit" && text.startsWith(family)) + return { ...shown, text: text.slice(family.length), lead: 0, head: 0 } + return text.startsWith(`${family} `) + ? { ...shown, text: `…${text.slice(family.length)}`, lead: 0, head: 0 } + : shown } -/** `text` without the words the folders above it already say: `execute_sql` under `mcpx db-local`. */ -const label = (text: string, prefix: string) => - prefix !== "" && text.startsWith(`${prefix} `) ? text.slice(prefix.length + 1) : text - /** * A family's first words, as folders read them: each word as shown, a flag with its value as one - * (`-p dev`), env vars first, and a place as `(in web)`. Only commands are foldered. + * (`-p dev`). What it is told and where it ran are no folders: `TZ=… npx vitest` run in `packages/api` + * sits under `npx` beside the same command run at the top, its env words in front of its program and + * its place at the end of its row — a folder named `TZ=…` or `(in packages/api)` said nothing about + * what ran. Only commands are foldered. */ export function unitsOf(family: Family): string[] { if (family.permission !== "bash") return [family.family] const read = readSubject(family.family) if (!read) return [family.family] - const words = [...read.command.env, ...read.command.argv].map(shown) - const units: string[] = read.place === undefined ? [] : [`(in ${shown(read.place)})`] + const words = read.command.argv.map(shown) + const units: string[] = [] for (let i = 0; i < words.length; i++) { const word = words[i] as string const next = words[i + 1] diff --git a/packages/trust/src/server.ts b/packages/trust/src/server.ts index 49e9d919..59e55a38 100644 --- a/packages/trust/src/server.ts +++ b/packages/trust/src/server.ts @@ -1,12 +1,62 @@ /** - * Published entry point: `@opencode-cockpit/trust/server` — an agent half with nothing of its own, since - * Trust answers from the interface. + * Published entry point: `@opencode-cockpit/trust/server` — Trust's agent half. * - * It exists for OpenCode 2, which sets up a plugin's interface only once its agent half has loaded: + * On OpenCode 2 it answers what Trust would answer before OpenCode makes a request at all, so no + * prompt is drawn (agent/answer.ts); what it leaves, the interface answers as it always has. On + * OpenCode 1 it has nothing of its own — v1 never calls a plugin before its prompt, so Trust answers + * from the interface there, a frame after the prompt shows. + * + * It also exists because OpenCode 2 sets up a plugin's interface only once its agent half has loaded: * listed in `opencode.json`, as `opencode plugin add` writes it, a Trust with no agent half loaded * nothing at all (measured on 2.0.18). Like every Cockpit agent half, it offers `/cockpit-setup`. */ -import { dualServer } from "@opencode-cockpit/client/server" +import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature" +import { dualServer, type ServerStart } from "@opencode-cockpit/client/server" +import { createAnswerer } from "./agent/answer.ts" +import { loadTrust, resolveSettings } from "./core/config.ts" +import { trustPaths } from "./core/paths.ts" +import { maskHasher } from "./tui/key.ts" + +export const TRUST_PACKAGE = "@opencode-cockpit/trust" + +/** Trust's agent half as a factory, so the `opencode-cockpit` bundle can include it. */ +export function createTrustServer({ + source = TRUST_PACKAGE, + env = process.env, +}: { + source?: string + env?: Record +} = {}): ServerStart { + return async (host, rawOptions) => { + if (host.version !== 2) return {} + const claim = claimFeature(host.scope, "trust", source) + if (!claim.active) { + host.log.warn(duplicateFeatureMessage("Trust", claim.owner, source)) + return {} + } + const log = host.log.child("trust") + const { config } = await loadTrust(host.directory, rawOptions, env) + const settings = resolveSettings(config) + if (!settings.enabled) { + log.info("off in the settings") + claim.release() + return {} + } + const paths = trustPaths(host.directory, env) + const answerer = createAnswerer({ + directory: host.directory, + paths, + settings, + hash: maskHasher(paths, log), + log, + }) + return { + toolBefore: (call) => answerer.toolBefore(call), + permission: (request) => answerer.permission(request), + dispose: () => claim.release(), + } + } +} -export default dualServer("opencode-cockpit.trust", async () => ({})) +export default dualServer("opencode-cockpit.trust", createTrustServer()) diff --git a/packages/trust/src/tui/index.tsx b/packages/trust/src/tui/index.tsx index 66b29184..084bf9cf 100644 --- a/packages/trust/src/tui/index.tsx +++ b/packages/trust/src/tui/index.tsx @@ -1,9 +1,13 @@ /** @jsxImportSource @opentui/solid */ /** - * Trust's interface half — the only half it has. OpenCode's `permission.ask` server hook is declared - * and never called (docs/opencode/permissions.md), so a plugin answers a request the way OpenCode's - * own auto mode does: from the interface, on `permission.asked`, with a reply of "once". + * Trust's interface half. On OpenCode 1 it is the half that answers: v1's `permission.ask` server hook + * is declared and never called (docs/opencode/permissions.md), so a request is answered the way + * OpenCode's own auto mode does — from the interface, on `permission.asked`, with a reply of "once" — + * and OpenCode's prompt shows for a frame first. On OpenCode 2 the agent half answers first, before a + * request is ever made (agent/answer.ts), with the rules this half publishes for it (snapshot.ts); + * what it leaves is asked, and answered here exactly as on 1. Its answers reach this window through + * the ledger, and the sidebar lists them. * * Everything that decides is in `core/`; this file wires it to the host: the ledger file read and * appended, the painter (`paint.ts`), requests in and replies out (`requests.ts`), `/trust` @@ -22,6 +26,7 @@ import { maskHasher } from "./key.ts" import { createLedger } from "./ledger.tsx" import { closedDialog, createPainter, type Live } from "./paint.ts" import { createRequests } from "./requests.ts" +import { writeSnapshot } from "./snapshot.ts" import { Rows } from "./view/rows.tsx" const TRUST_PACKAGE = "@opencode-cockpit/trust" @@ -75,7 +80,11 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } = .read() .then(({ events, reset }) => { if (events.length === 0 && !reset) return - engine.load(events, { reset }) + /** The agent half's answers to calls this window saw are this window's (OpenCode 2). */ + engine.load(events, { + reset, + here: (answer) => answer.call !== undefined && requests.saw(answer.call), + }) draw() }) .catch((error) => { @@ -99,8 +108,26 @@ export function createTrustTui({ source = TRUST_PACKAGE }: { source?: string } = }) } + /** One write at a time, the last read last: a slow write never lands over a newer one. */ + let publishing: Promise = Promise.resolve() + const publish = (rules: { config: unknown } | undefined) => { + publishing = publishing + .then(() => writeSnapshot(paths, rules && { config: rules.config, settings })) + .catch((error) => log.warn("rules not shared with the agent half", { file: paths.rules, error })) + } + const hash = maskHasher(paths, log) - const requests = createRequests({ api, log, engine, directory, hash, live, write, draw }) + const requests = createRequests({ + api, + log, + engine, + directory, + hash, + live, + write, + draw, + ...(api.v2 ? { publish } : {}), + }) const ledger = createLedger({ api, log, diff --git a/packages/trust/src/tui/requests.ts b/packages/trust/src/tui/requests.ts index 465c1ff5..867f0b60 100644 --- a/packages/trust/src/tui/requests.ts +++ b/packages/trust/src/tui/requests.ts @@ -25,6 +25,11 @@ export interface Requests { loadRules(): Promise /** Whether the rules could be read; without them Trust answers nothing. */ rulesReady(): boolean + /** + * Whether this window saw the tool call: an answer the agent half gave for it (OpenCode 2) is then + * this window's, for the sidebar. + */ + saw(call: string): boolean /** Requests already waiting, and any the events never said were answered; `adopt` decides them. */ reconcile(adopt: boolean): Promise dispose(): void @@ -40,8 +45,13 @@ export function createRequests(input: { live: Live write: (events: readonly Event[]) => void draw: () => void + /** + * The rules as just read — or `undefined`, they could not be — for the agent half to decide with + * (snapshot.ts). OpenCode 2 only: on 1 the agent half has no way to answer. + */ + publish?: (rules: { config: unknown } | undefined) => void }): Requests { - const { api, log, engine, directory, hash, live, write, draw } = input + const { api, log, engine, directory, hash, live, write, draw, publish } = input /** OpenCode's config as its `config.get` returned it; undefined until read, and Trust stays out. */ let opencodeConfig: unknown let rulesReady = false @@ -52,11 +62,13 @@ export function createRequests(input: { try { opencodeConfig = await feed.config() rulesReady = true + publish?.({ config: opencodeConfig }) if (live.trouble?.startsWith("OpenCode's config")) live.trouble = undefined log.debug("rules", { rules: rulesFrom(opencodeConfig).length }) } catch (error) { /** Without the rules there is no knowing what you asked to be asked about: Trust stays out. */ rulesReady = false + publish?.(undefined) log.error("config unreadable", { error }) live.trouble = "OpenCode's config unreadable — not answering" } @@ -199,6 +211,7 @@ export function createRequests(input: { return { loadRules, rulesReady: () => rulesReady, + saw: (call) => calls.has(call), reconcile, dispose: () => { for (const waiting of parked.values()) clearTimeout(waiting.timer) diff --git a/packages/trust/src/tui/snapshot.ts b/packages/trust/src/tui/snapshot.ts new file mode 100644 index 00000000..0f7f00fa --- /dev/null +++ b/packages/trust/src/tui/snapshot.ts @@ -0,0 +1,112 @@ +/** + * The rules snapshot: OpenCode's permission rules as the interface last read them, beside the ledger + * as `rules.json`, for the agent half to decide with. + * + * OpenCode 2 gives a server plugin no config to read, and the agent half answers before any request + * is made (agent/answer.ts) — so it decides with what the interface was told. The interface writes + * the snapshot each time it reads the config (at start, and on every `config.updated`), and removes it + * when the config cannot be read, since then the interface answers nothing either. It carries the + * interface's settings too, so both halves count to the same threshold whatever their entries say. + * + * Only the permissions are written (`rulesConfig`), never the rest of the config: a config holds + * providers and their keys. Written whole or not at all — to a file of its own, then renamed over the + * old one — so a reader never sees half of it. And it names the process that wrote it: a snapshot + * whose interface is gone is not read, because nobody is keeping it current, and the agent half then + * leaves every request to be asked. + */ + +import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises" +import type { TrustSettings } from "../core/config.ts" +import type { TrustPaths } from "../core/paths.ts" +import { rulesConfig } from "../core/rules.ts" + +/** What decides alike in both halves. */ +export type SnapshotSettings = Pick + +export interface Snapshot { + v: 1 + at: number + /** The interface process that wrote it. */ + pid: number + /** `config.get`'s answer with only its permissions: what `rulesFrom` reads. */ + config: unknown + settings: SnapshotSettings +} + +/** The rules as read now, or `undefined` when they could not be: the snapshot is then removed. */ +export async function writeSnapshot( + paths: TrustPaths, + input: { config: unknown; settings: SnapshotSettings } | undefined, + { pid = process.pid, now = Date.now() }: { pid?: number; now?: number } = {}, +): Promise { + if (!input) { + await rm(paths.rules, { force: true }) + return + } + const { threshold, dangerExtra, expireDays, learnReads } = input.settings + const snapshot: Snapshot = { + v: 1, + at: now, + pid, + config: rulesConfig(input.config), + settings: { threshold, dangerExtra, expireDays, learnReads }, + } + await mkdir(paths.dir, { recursive: true }) + const temporary = `${paths.rules}.${pid}.tmp` + await writeFile(temporary, `${JSON.stringify(snapshot)}\n`, { mode: 0o600 }) + await rename(temporary, paths.rules) +} + +/** Whether a process is running. `EPERM` is one that runs as someone else: running still. */ +export function alive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM" + } +} + +const isSnapshot = (value: unknown): value is Snapshot => { + const raw = value as Partial | undefined + const settings = raw?.settings as Partial | undefined + return ( + raw?.v === 1 && + typeof raw.at === "number" && + typeof raw.pid === "number" && + raw.config !== undefined && + typeof settings?.threshold === "number" && + typeof settings.dangerExtra === "number" && + typeof settings.expireDays === "number" && + typeof settings.learnReads === "boolean" + ) +} + +/** + * The snapshot, read again only when the file changed. `undefined` when there is none, it cannot be + * read, or its interface is no longer running. + */ +export function snapshotReader( + paths: TrustPaths, + running: (pid: number) => boolean = alive, +): () => Promise { + let seen: { mtime: number; size: number; snapshot: Snapshot | undefined } | undefined + return async () => { + const found = await stat(paths.rules).catch(() => undefined) + if (!found) { + seen = undefined + return undefined + } + if (!seen || seen.mtime !== found.mtimeMs || seen.size !== found.size) { + const text = await readFile(paths.rules, "utf8").catch(() => undefined) + let parsed: unknown + try { + parsed = text === undefined ? undefined : JSON.parse(text) + } catch { + parsed = undefined + } + seen = { mtime: found.mtimeMs, size: found.size, snapshot: isSnapshot(parsed) ? parsed : undefined } + } + return seen.snapshot && running(seen.snapshot.pid) ? seen.snapshot : undefined + } +} diff --git a/packages/trust/test/answer.test.ts b/packages/trust/test/answer.test.ts new file mode 100644 index 00000000..9b9ebc2d --- /dev/null +++ b/packages/trust/test/answer.test.ts @@ -0,0 +1,313 @@ +/** + * The agent half against a host made of an object: a call heard starting, then OpenCode 2's + * `evaluate` for the permission it asks — answered before any request exists, or left to be asked. + * The ledger and the rules snapshot are real files; what is checked is when it answers, what it + * writes, and every way it stays out. That `execute.before` comes before `evaluate` is read from + * 2.0.18's binary (docs/opencode/permissions.md), not measured here. + */ +import { describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { silentLog } from "@opencode-cockpit/client/log" +import type { PermissionAsk, ServerHost, ServerParts } from "@opencode-cockpit/client/server" +import { createAnswerer } from "../src/agent/answer.ts" +import { DEFAULTS, resolveSettings } from "../src/core/config.ts" +import { createEngine } from "../src/core/engine.ts" +import type { Event } from "../src/core/ledger.ts" +import { type TrustPaths, trustPaths } from "../src/core/paths.ts" +import { rulesConfig, rulesFrom } from "../src/core/rules.ts" +import { plainHash } from "../src/core/secret.ts" +import { createTrustServer } from "../src/server.ts" +import { createJournal } from "../src/tui/journal.ts" +import { snapshotReader, writeSnapshot } from "../src/tui/snapshot.ts" + +const DIRECTORY = "/work/app" +/** OpenCode 2's config, as its `config.get` hands it over: documents, lowest first. */ +const ASK_SHELL = [ + { type: "document", info: { permissions: [{ action: "shell", resource: "*", effect: "ask" }] } }, +] + +function place() { + const home = mkdtempSync(join(tmpdir(), "trust-agent-")) + const env = { COCKPIT_HOME: home, HOME: home, XDG_CONFIG_HOME: join(home, "config") } + return { env, paths: trustPaths(DIRECTORY, env) } +} + +/** `line` approved by a person `times` times, in the ledger — as an interface writes it. */ +async function approved(paths: TrustPaths, line: string, times: number, extra: Event[] = []) { + const engine = createEngine(DEFAULTS) + const events: Event[] = [] + for (let i = 0; i < times; i++) { + const at = Date.now() - 60_000 + i * 10_000 + const { event } = engine.ask({ + request: { id: `per_${i}`, sessionID: "ses_1", permission: "bash", patterns: [line], always: [] }, + context: { line, root: DIRECTORY, hash: plainHash }, + agent: "build", + rules: rulesFrom(ASK_SHELL), + at, + }) + events.push(event, ...engine.replied({ requestID: `per_${i}`, reply: "once", at: at + 1_500 }).events) + } + await createJournal(paths).append([...events, ...extra]) +} + +const ledger = async (paths: TrustPaths) => (await createJournal(paths).read()).events + +let n = 0 +/** The agent runs `line` in a call: heard starting, then evaluated. */ +function shell(line: string | undefined, overrides: Partial = {}) { + n++ + const callID = `call_${n}` + return { + start: { sessionID: "ses_1", tool: "shell", callID, args: line === undefined ? {} : { command: line } }, + ask: { + sessionID: "ses_1", + agent: "build", + action: "shell", + resources: line === undefined ? [] : [line], + callID, + messageID: "msg_1", + ...overrides, + } satisfies PermissionAsk, + } +} + +function answerer(paths: TrustPaths, rules = snapshotReader(paths, () => true)) { + return createAnswerer({ + directory: DIRECTORY, + paths, + settings: resolveSettings({}), + hash: plainHash, + log: silentLog, + rules, + }) +} + +const share = (paths: TrustPaths, config: unknown = ASK_SHELL) => + writeSnapshot(paths, { config, settings: resolveSettings({}) }) + +describe("answering before OpenCode asks", () => { + test("a command approved three times is answered, and the ledger says so", async () => { + const { paths } = place() + await share(paths) + await approved(paths, "git status", 3) + const trust = answerer(paths) + const { start, ask } = shell("git status") + trust.toolBefore(start) + expect(await trust.permission(ask)).toBe("allow") + const written = (await ledger(paths)).slice(-2) + expect(written.map((event) => event.type)).toEqual(["asked", "auto"]) + expect(written[1]).toMatchObject({ + type: "auto", + session: "ses_1", + call: start.callID, + permission: "bash", + agent: "build", + items: [{ subject: "git status" }], + }) + /** The id is Trust's own: no request existed to take one from. */ + expect((written[1] as { request: string }).request).toStartWith("trust_") + }) + + test("not yet trusted: left to be asked, and nothing written — the interface writes it", async () => { + const { paths } = place() + await share(paths) + await approved(paths, "git status", 2) + const before = (await ledger(paths)).length + const trust = answerer(paths) + const { start, ask } = shell("git status") + trust.toolBefore(start) + expect(await trust.permission(ask)).toBeUndefined() + expect(await ledger(paths)).toHaveLength(before) + }) + + test("a shell call whose line was never heard is left to be asked", async () => { + const { paths } = place() + await share(paths) + await approved(paths, "git status", 3) + const trust = answerer(paths) + expect(await trust.permission(shell("git status").ask)).toBeUndefined() + const { start, ask } = shell(undefined) + trust.toolBefore(start) + expect(await trust.permission({ ...ask, resources: ["git status"] })).toBeUndefined() + }) + + test("without the interface's rules nothing is answered: none shared, or its interface gone", async () => { + const { paths } = place() + await approved(paths, "git status", 3) + const none = answerer(paths) + const first = shell("git status") + none.toolBefore(first.start) + expect(await none.permission(first.ask)).toBeUndefined() + + await share(paths) + const gone = answerer( + paths, + snapshotReader(paths, () => false), + ) + const second = shell("git status") + gone.toolBefore(second.start) + expect(await gone.permission(second.ask)).toBeUndefined() + }) + + test("paused in any window, nothing is answered", async () => { + const { paths } = place() + await share(paths) + await approved(paths, "git status", 3) + const trust = answerer(paths) + await createJournal(paths).append([{ v: 1, at: Date.now(), type: "paused" }]) + const { start, ask } = shell("git status") + trust.toolBefore(start) + expect(await trust.permission(ask)).toBeUndefined() + }) + + test("outside the project is always yours, however it was asked", async () => { + const { paths } = place() + await share(paths) + const trust = answerer(paths) + const { start, ask } = shell("cat /etc/hosts") + trust.toolBefore(start) + expect( + await trust.permission({ ...ask, action: "external_directory", resources: ["/etc/*"] }), + ).toBeUndefined() + }) + + test("asked again for the same call — OpenCode re-evaluates waiting requests after an always — is left alone", async () => { + const { paths } = place() + await share(paths) + await approved(paths, "git status", 3) + const trust = answerer(paths) + const { start, ask } = shell("git status") + trust.toolBefore(start) + expect(await trust.permission(ask)).toBe("allow") + expect(await trust.permission(ask)).toBeUndefined() + }) + + test("two starts under one call id, as Code Mode's tools share theirs: which line asked is unknown", async () => { + const { paths } = place() + await share(paths) + await approved(paths, "git status", 3) + const trust = answerer(paths) + const { start, ask } = shell("git status") + trust.toolBefore({ ...start, tool: "execute", args: { code: "…" } }) + trust.toolBefore(start) + trust.toolBefore({ ...start, args: { command: "rm -rf build" } }) + expect(await trust.permission(ask)).toBeUndefined() + }) + + test("a ledger that cannot be written is no answer: the person is asked", async () => { + const { paths } = place() + await share(paths) + await approved(paths, "git status", 3) + const trust = createAnswerer({ + directory: DIRECTORY, + /** Readable where it was written, appended to where nothing can be. */ + paths: { ...paths, dir: "/dev/null/trust", events: paths.events }, + settings: resolveSettings({}), + hash: plainHash, + log: silentLog, + rules: snapshotReader(paths, () => true), + }) + const { start, ask } = shell("git status") + trust.toolBefore(start) + expect(await trust.permission(ask)).toBeUndefined() + }) + + test("the interface's settings decide over the entry's own", async () => { + const { paths } = place() + await writeSnapshot(paths, { config: ASK_SHELL, settings: resolveSettings({ threshold: 2 }) }) + await approved(paths, "git status", 2) + const trust = answerer(paths) + const { start, ask } = shell("git status") + trust.toolBefore(start) + expect(await trust.permission(ask)).toBe("allow") + }) +}) + +describe("the agent half's entry", () => { + const host = (version: 1 | 2): ServerHost => ({ + version, + directory: DIRECTORY, + scope: {}, + session: { get: async () => undefined, notify: async () => {} }, + readFile: async () => undefined, + log: silentLog, + }) + const start = (version: 1 | 2, options: unknown = {}) => { + const { env, paths } = place() + return { + paths, + parts: createTrustServer({ source: `test-${Math.random()}`, env })(host(version), options), + } + } + const hooks = (parts: ServerParts) => Object.keys(parts).filter((key) => key !== "dispose") + + test("OpenCode 2: hears calls and answers permissions", async () => { + const { paths, parts } = start(2) + const ready = await parts + expect(hooks(ready)).toEqual(["toolBefore", "permission"]) + await share(paths) + await approved(paths, "bun test", 3) + const { start: call, ask } = shell("bun test") + await ready.toolBefore?.(call) + expect(await ready.permission?.(ask)).toBe("allow") + }) + + test("OpenCode 1 never calls a plugin before its prompt: nothing to register", async () => { + expect(hooks(await start(1).parts)).toEqual([]) + }) + + test("turned off in the settings, nothing is registered", async () => { + expect(hooks(await start(2, { enabled: false }).parts)).toEqual([]) + }) +}) + +describe("the rules snapshot", () => { + test("keeps what the rules are read from and nothing else: a provider's key is not copied", () => { + const v1 = { + permission: { bash: { "*": "ask", "git push *": "ask" } }, + agent: { plan: { permission: { edit: "deny" }, model: "x" }, build: { model: "y" } }, + provider: { zen: { options: { apiKey: "sk-secret" } } }, + } + const v2 = [ + { + type: "document", + info: { permissions: [{ action: "shell", resource: "*", effect: "ask" }], mcp: {} }, + }, + { + type: "document", + info: { agents: { plan: { permissions: [{ action: "edit", resource: "*", effect: "deny" }] } } }, + }, + ] + for (const config of [v1, v2]) + for (const agent of [undefined, "plan", "build"]) + expect(rulesFrom(rulesConfig(config), agent)).toEqual(rulesFrom(config, agent)) + expect(JSON.stringify(rulesConfig(v1))).not.toContain("sk-secret") + expect(rulesConfig(v1)).toEqual({ + permission: v1.permission, + agent: { plan: { permission: { edit: "deny" } } }, + }) + }) + + test("is read again when it changes, and removed when the interface could not read the rules", async () => { + const { paths } = place() + const read = snapshotReader(paths, () => true) + expect(await read()).toBeUndefined() + await share(paths) + expect((await read())?.config).toEqual(ASK_SHELL) + await writeSnapshot(paths, { config: { permission: "ask" }, settings: resolveSettings({}) }) + expect((await read())?.config).toEqual({ permission: "ask" }) + await writeSnapshot(paths, undefined) + expect(await read()).toBeUndefined() + }) + + test("a file that is not a snapshot is none", async () => { + const { paths } = place() + mkdirSync(paths.dir, { recursive: true }) + writeFileSync(paths.rules, '{"v":1,"config":') + expect(await snapshotReader(paths, () => true)()).toBeUndefined() + writeFileSync(paths.rules, JSON.stringify({ v: 1, at: 1, pid: 1, config: {} })) + expect(await snapshotReader(paths, () => true)()).toBeUndefined() + }) +}) diff --git a/packages/trust/test/engine.test.ts b/packages/trust/test/engine.test.ts index d440aac1..17dec704 100644 --- a/packages/trust/test/engine.test.ts +++ b/packages/trust/test/engine.test.ts @@ -331,4 +331,38 @@ describe("pause and always", () => { w.engine.reconcile(new Set([id]), young + 60_000) expect(w.engine.pending().map((p) => p.request.id)).toEqual([id]) }) + + test("an answer from the file is this window's when `here` says so — once, however many windows wrote it", () => { + const engine = createEngine(SETTINGS) + const auto = (request: string, call: string) => ({ + v: 1 as const, + at: 5_000, + type: "auto" as const, + rule: "approved by you 3× in a row", + request, + session: "ses_1", + call, + permission: "bash", + agent: "build", + items: [{ subject: "git status" }], + }) + const here = (answer: { call?: string }) => answer.call === "call_here" + engine.load([auto("trust_a", "call_here"), auto("trust_b", "call_elsewhere")], { here }) + engine.load([auto("trust_a", "call_here")], { here }) + expect(engine.count()).toBe(1) + expect(engine.recent().map((answer) => [answer.request, answer.label])).toEqual([ + ["trust_a", "git status"], + ]) + /** Without `here`, as on OpenCode 1: another window's answers stay theirs. */ + const plain = createEngine(SETTINGS) + plain.load([auto("trust_c", "call_here")]) + expect(plain.count()).toBe(0) + }) + + test("a request let go leaves the pending list at once", () => { + const w = world() + const { id } = w.ask("ls") + w.engine.forget(id) + expect(w.engine.pending()).toEqual([]) + }) }) diff --git a/packages/trust/test/family.test.ts b/packages/trust/test/family.test.ts index 5783223c..29d359b3 100644 --- a/packages/trust/test/family.test.ts +++ b/packages/trust/test/family.test.ts @@ -4,6 +4,7 @@ import { covers, familyOf, outside, + placeless, readSubject, shown, showSubject, @@ -83,14 +84,17 @@ describe("a command's family", () => { expect(family(line)).toBe(expected) }) - test("where it runs is part of the family", () => { + test("where it runs is part of the command, not of its family", () => { const parsed = parse("cd web && bun test") if (parsed.kind !== "commands") throw new Error("opaque") const placed = signature(parsed.commands[0] as never, ROOT) expect(placed).toBe("(in web) bun test") - expect(familyOf("bash", placed)).toBe("(in web) bun test") + expect(familyOf("bash", placed)).toBe("bun test") const spaced = signature({ env: [], argv: ["ls", "-la"], cwd: `${ROOT}/my dir` }, ROOT) - expect(familyOf("bash", spaced)).toBe("(in 'my dir') ls") + expect(familyOf("bash", spaced)).toBe("ls") + /** A widening recorded with a place, as before 0.12, is the plain family now. */ + expect(placeless("bash", "(in web) bun test")).toBe("bun test") + expect(placeless("edit", "(in web) x/")).toBe("(in web) x/") expect(readSubject(spaced)).toEqual({ place: "my dir", command: { env: [], argv: ["ls", "-la"] } }) }) @@ -148,7 +152,8 @@ describe("what a widened family covers", () => { test("not another family", () => { expect(covers("bash", "ls", subject("sudo ls"))).toBe(false) expect(covers("bash", "npm run test", subject("npm run deploy"))).toBe(false) - expect(covers("bash", "ls", subject("cd web && ls"))).toBe(false) + /** The same words in another folder are the same family: leaving the project is OpenCode's own question. */ + expect(covers("bash", "ls", subject("cd web && ls"))).toBe(true) }) test("not a dangerous command inside a safe family", () => { @@ -212,7 +217,7 @@ describe("showing a command so no font can merge it", () => { expect(showSubject("bash", subject("ls > out.txt"))).toBe("ls > out.txt") expect(showSubject("bash", subject("ls 2>&1"))).toBe("ls 2>&1") expect(showSubject("bash", subject("NODE_ENV='a b' npm test"))).toBe('NODE_ENV="a b" npm test') - expect(showSubject("bash", "(in 'my dir') ls")).toBe('(in "my dir") ls') + expect(showSubject("bash", "(in 'my dir') ls")).toBe('ls in "my dir"') expect(showSubject("bash", "NODE_ENV=… npm run build")).toBe("NODE_ENV=… npm run build") expect(showSubject("edit", "src/app.ts")).toBe("src/app.ts") }) diff --git a/packages/trust/test/outside.test.ts b/packages/trust/test/outside.test.ts new file mode 100644 index 00000000..1823b35f --- /dev/null +++ b/packages/trust/test/outside.test.ts @@ -0,0 +1,363 @@ +/** + * 0.12: folders outside the project, and commands run from another folder. + * + * A folder OpenCode asks about (`external_directory`) is counted, never answered by its count, and + * answered only through a folder a person widened — the temp dir as one family. A command the agent + * ran after `cd packages/api` reads command first, its place after it, the program never cut. + */ +import { describe, expect, test } from "bun:test" +import { mkdtempSync } from "node:fs" +import { homedir, tmpdir } from "node:os" +import { join } from "node:path" +import { silentLog } from "@opencode-cockpit/client/log" +import { createAnswerer } from "../src/agent/answer.ts" +import { resolveSettings } from "../src/core/config.ts" +import { createEngine, type Engine } from "../src/core/engine.ts" +import { anyOf, familyOf, shownOf, showSubject, widenable } from "../src/core/family.ts" +import { folderFamily, folderOf, refused, shortPlace, TEMP_FAMILY, tempRootsOf } from "../src/core/folders.ts" +import { subjectsOf } from "../src/core/keys.ts" +import { ANY_AGENT } from "../src/core/ledger.ts" +import { trustPaths } from "../src/core/paths.ts" +import { rulesFrom } from "../src/core/rules.ts" +import { SAMPLE_NOW, SAMPLE_SETTINGS, SAMPLES } from "../src/core/sample.ts" +import { plainHash } from "../src/core/secret.ts" +import { parse } from "../src/core/shell.ts" +import { signature } from "../src/core/signature.ts" +import { suggestionsOf } from "../src/core/suggest.ts" +import { widen, widenScope } from "../src/core/view/actions.ts" +import { explorerRows } from "../src/core/view/explorer.ts" +import { familiesOf } from "../src/core/view/model.ts" +import { shownRuns } from "../src/core/view/parts.ts" +import { type Row, rowText, widthOf } from "../src/core/view/rows.ts" +import { sidebarRows } from "../src/core/view/sidebar.ts" +import { explorerModel, type Node, treeRow } from "../src/core/view/tree.ts" +import { createJournal } from "../src/tui/journal.ts" +import { snapshotReader, writeSnapshot } from "../src/tui/snapshot.ts" + +const ROOT = "/work/app" +const SETTINGS = { threshold: 3, dangerExtra: 5, expireDays: 30 } +const RULES = rulesFrom({ permission: { bash: "ask", external_directory: "ask" } }) +const TEMP = tmpdir().replace(/\/+$/, "") +const HOME = homedir().replace(/\/+$/, "") + +const OUTSIDE = "external_directory" + +let n = 0 +function world() { + const engine: Engine = createEngine(SETTINGS) + let clock = 1_000_000 + const ask = (patterns: string[], permission = OUTSIDE, line?: string) => { + n++ + clock += 5_000 + const id = `per_${n}` + const { judgement, event } = engine.ask({ + request: { id, sessionID: "ses_1", permission, patterns, always: [] }, + context: { root: ROOT, ...(line !== undefined ? { line } : {}) }, + agent: "build", + rules: RULES, + at: clock, + }) + engine.load([event]) + return { id, judgement } + } + const approve = (patterns: string[], times: number) => { + for (let i = 0; i < times; i++) { + const { id, judgement } = ask(patterns) + expect(judgement.answer).toBe(false) + clock += 1_500 + engine.load(engine.replied({ requestID: id, reply: "once", at: clock }).events) + } + } + const widened = (family: string) => + engine.load([{ v: 1, at: clock, type: "widened", permission: OUTSIDE, agent: ANY_AGENT, family }]) + const reading = () => ({ state: engine.state, settings: SETTINGS, now: clock }) + return { engine, ask, approve, widened, reading } +} + +const glob = (folder: string) => `${folder.replace(/\/+$/, "")}/*` + +describe("a folder outside the project, as a subject", () => { + test("the request's glob is the folder", () => { + expect(folderOf("/abs/dir/*")).toBe("/abs/dir/") + expect(folderOf("/abs/dir/")).toBe("/abs/dir/") + expect(folderOf("/abs/dir/**")).toBe("/abs/dir/") + expect(folderOf("relative/dir/*")).toBeUndefined() + const keyed = subjectsOf( + { id: "1", sessionID: "s", permission: OUTSIDE, patterns: ["/srv/a/*", "/srv/a/"], always: [] }, + { root: ROOT }, + ) + expect(keyed).toMatchObject({ kind: "subjects", subjects: [{ subject: "/srv/a/" }] }) + }) + + test("a folder never widened leaves the whole request yours, counting nothing", () => { + for (const folder of [ + "/", + HOME, + `${HOME}/.ssh`, + `${HOME}/.aws/sso`, + `${HOME}/.config/opencode`, + "/etc", + "/work", + ROOT, + ]) { + const keyed = subjectsOf( + { id: "1", sessionID: "s", permission: OUTSIDE, patterns: [glob(folder)], always: [] }, + { root: ROOT }, + ) + expect(keyed.kind).toBe("never") + } + }) + + test("never widened, and why: the disk, home and what holds it, keys and settings, secrets, the project's own folders", () => { + expect(refused("/")).toBe("it is the whole disk") + expect(refused(`${HOME}/`)).toBe("it is your home folder") + expect(refused(`${HOME.split("/").slice(0, -1).join("/") || "/"}/`)).toBeDefined() + expect(refused(`${HOME}/.kube/`)).toBe("keys and settings live there") + expect(refused("/etc/nginx/")).toBe("keys and settings live there") + expect(refused("/srv/app/secrets/")).toBe("it may hold secrets") + expect(refused("/work/", ROOT)).toBe("it holds the project") + expect(refused("/srv/other/", ROOT)).toBeUndefined() + expect(refused(TEMP_FAMILY)).toBeUndefined() + expect(widenable(OUTSIDE, `${HOME}/.docker/`).ok).toBe(false) + expect(widenable(OUTSIDE, `${HOME}/work/other/`).ok).toBe(true) + expect(widenable(OUTSIDE, TEMP_FAMILY).ok).toBe(true) + }) +}) + +describe("the temp dir is one family", () => { + test("every name it goes by", () => { + const roots = tempRootsOf("/var/folders/ab/xyz/T/") + expect(roots).toContain("/var/folders/ab/xyz/T") + expect(roots).toContain("/private/var/folders/ab/xyz/T") + expect(roots).toContain("/tmp") + expect(roots).toContain("/private/tmp") + expect(tempRootsOf("/private/var/folders/ab/xyz/T")).toContain("/var/folders/ab/xyz/T") + }) + + test("its folders collapse into $TMPDIR/; any other folder is its own", () => { + for (const folder of [`${TEMP}/opencode/a1/`, "/tmp/x/", "/private/tmp/y/z/", `${TEMP}/`]) + expect(folderFamily(folder)).toBe(TEMP_FAMILY) + expect(familyOf(OUTSIDE, `${HOME}/work/other/`)).toBe(`${HOME}/work/other/`) + }) + + test("said as a person reads it", () => { + expect(showSubject(OUTSIDE, `${TEMP}/opencode/a1/`)).toBe("$TMPDIR/opencode/a1/") + expect(showSubject(OUTSIDE, `${HOME}/work/other/`)).toBe("~/work/other/") + expect(anyOf(OUTSIDE, TEMP_FAMILY)).toBe("any folder in $TMPDIR") + expect(anyOf(OUTSIDE, `${HOME}/work/x/`)).toBe("any file in ~/work/x/") + }) +}) + +describe("answered only through a folder you widened", () => { + test("approved past the threshold, still yours", () => { + const w = world() + w.approve([glob(`${TEMP}/opencode/a1`)], 6) + const { judgement } = w.ask([glob(`${TEMP}/opencode/a1`)]) + expect(judgement.answer).toBe(false) + expect(judgement.why).toContain("outside the project") + expect(judgement.progress[0]).toMatchObject({ have: 6, trusted: false, yours: true }) + }) + + test("$TMPDIR/ widened: any temp folder is answered, under any of its names", () => { + const w = world() + w.widened(TEMP_FAMILY) + for (const folder of [`${TEMP}/opencode/new`, "/tmp/scratch", "/private/tmp/other"]) { + const { judgement } = w.ask([glob(folder)]) + expect(judgement).toMatchObject({ answer: true, why: "in a family you widened: any folder in $TMPDIR" }) + } + expect(w.ask([glob(`${HOME}/work/other`)]).judgement.answer).toBe(false) + }) + + test("a widened folder covers what is under it, never a sibling, never a secret", () => { + const w = world() + w.widened(`${HOME}/work/other/`) + expect(w.ask([glob(`${HOME}/work/other`)]).judgement.answer).toBe(true) + expect(w.ask([glob(`${HOME}/work/other/src/deep`)]).judgement).toMatchObject({ + answer: true, + items: [{ subject: `${HOME}/work/other/src/deep/`, via: `${HOME}/work/other/` }], + }) + expect(w.ask([glob(`${HOME}/work/other-two`)]).judgement.answer).toBe(false) + expect(w.ask([glob(`${HOME}/work/sibling`)]).judgement.answer).toBe(false) + expect(w.ask([glob(`${HOME}/work/other/secrets`)]).judgement.answer).toBe(false) + /** One folder not covered and the whole request asks. */ + expect(w.ask([glob(`${HOME}/work/other/a`), glob("/srv/b")]).judgement.answer).toBe(false) + }) + + test("the agent half answers it too, before OpenCode asks", async () => { + const home = mkdtempSync(join(tmpdir(), "trust-outside-")) + const paths = trustPaths(ROOT, { COCKPIT_HOME: home, HOME: home }) + const config = [ + { type: "document", info: { permissions: [{ action: "*", resource: "*", effect: "ask" }] } }, + ] + await writeSnapshot(paths, { config, settings: resolveSettings({}) }) + const trust = createAnswerer({ + directory: ROOT, + paths, + settings: resolveSettings({}), + hash: plainHash, + log: silentLog, + rules: snapshotReader(paths, () => true), + }) + const ask = (folder: string, callID: string) => { + trust.toolBefore({ sessionID: "ses_1", tool: "read", callID, args: { filePath: `${folder}/a.txt` } }) + return trust.permission({ + sessionID: "ses_1", + agent: "build", + action: OUTSIDE, + resources: [glob(folder)], + callID, + messageID: "msg_1", + }) + } + expect(await ask(`${TEMP}/opencode/q1`, "call_a")).toBeUndefined() + await createJournal(paths).append([ + { v: 1, at: Date.now(), type: "widened", permission: OUTSIDE, agent: ANY_AGENT, family: TEMP_FAMILY }, + ]) + expect(await ask(`${TEMP}/opencode/q2`, "call_b")).toBe("allow") + expect(await ask(`${HOME}/work/other`, "call_c")).toBeUndefined() + }) +}) + +describe("suggested, never learned", () => { + test("approvals across two or more temp folders reaching the threshold: $TMPDIR/ is suggested", () => { + const w = world() + w.approve([glob(`${TEMP}/opencode/a1`)], 2) + w.approve([glob(`${TEMP}/opencode/b2`)], 1) + const reading = w.reading() + const found = suggestionsOf(reading, familiesOf(reading)) + expect(found).toHaveLength(1) + expect(found[0]).toMatchObject({ approvals: 3, commands: 2 }) + expect(found[0]?.family.family).toBe(TEMP_FAMILY) + expect(w.engine.state.widened.size).toBe(0) + }) + + test("one folder, however often, is not suggested: its own count already says it", () => { + const w = world() + w.approve([glob(`${HOME}/work/other`)], 5) + const reading = w.reading() + expect(suggestionsOf(reading, familiesOf(reading))).toEqual([]) + }) + + test("w widens it; a folder never widened is refused", () => { + const at = 5 + const temp = widen({ permission: OUTSIDE, family: TEMP_FAMILY, undo: false }, at) + expect(temp.events).toEqual([ + { v: 1, at, type: "widened", permission: OUTSIDE, agent: ANY_AGENT, family: TEMP_FAMILY }, + ]) + const home = widen({ permission: OUTSIDE, family: `${HOME}/`, undo: false }, at) + expect(home.events).toEqual([]) + expect(home.notice.text).toContain("never widened") + }) + + test("the ledger offers it, and its card says what w does in plain words", () => { + const w = world() + for (const id of ["a1", "b2", "c3"]) w.approve([glob(`${TEMP}/opencode/${id}`)], 2) + const reading = { ...w.reading(), history: w.engine.history } + const view = explorerRows({ + ...reading, + open: new Set(), + full: new Set(), + filter: "", + width: 120, + height: 30, + project: "app", + }) + const text = view.rows.map(rowText).join("\n") + expect(text).toContain("★ any folder in $TMPDIR?") + expect(text).toContain("approved 6× across 3 folders in your temp directory") + expect(text).toContain("[w] lets Trust answer reads and writes there from now on") + expect(text).toContain("2× yours") + const scope = widenScope( + { kind: "family", family: familiesOf(reading)[0] as never, suggested: true }, + familiesOf(reading), + ) + expect(scope).toMatchObject({ family: TEMP_FAMILY, undo: false }) + }) +}) + +describe("a command run from another folder reads command first", () => { + const sub = (line: string, workdir?: string) => { + const parsed = parse(line) + if (parsed.kind !== "commands") throw new Error(line) + const command = parsed.commands.at(-1) as never as { cwd?: string } + return signature(workdir ? ({ ...command, cwd: workdir } as never) : (command as never), ROOT) + } + + test("the place after the command, short", () => { + const placed = sub("cd packages/api && TZ=UTC npx vitest run src/a.test.ts") + expect(placed).toStartWith("(in packages/api) ") + expect(showSubject("bash", placed)).toBe("TZ=UTC npx vitest run src/a.test.ts in packages/api") + expect(shownOf("bash", placed)).toMatchObject({ place: "packages/api", lead: 7, head: 10 }) + expect(shortPlace(`${HOME}/work/support-data-uploader/scripts`)).toBe("~/…/support-data-uploader/scripts") + expect(shortPlace(`${TEMP}/opencode/x`)).toBe("$TMPDIR/opencode/x") + expect(shortPlace("/opt/a/b/c")).toBe("/…/b/c") + expect(anyOf("bash", familyOf("bash", placed))).toBe("any TZ=… npx vitest run …") + }) + + test("cut to fit: the command's tail goes, never its program, and the place stays", () => { + const shown = shownOf("bash", sub("cd packages/api && TZ=UTC npx vitest run src/routes.test.ts")) + for (const width of [24, 30, 40, 60]) { + const text = rowText(shownRuns(shown, width)) + expect(widthOf(text)).toBeLessThanOrEqual(width) + expect(text).toContain("npx") + expect(text).toMatch(/in (packages|…)\/api$/) + } + /** Env words that leave no room for the program go instead. */ + const long = shownOf("bash", sub("TZ=America/Bogota_and_more python3 upload.py --dry-run")) + expect(rowText(shownRuns(long, 20))).toStartWith("… python3") + }) + + test("the tree's row, the suggestion, the card and the sidebar all say it the same way", () => { + const engine = SAMPLES.elsewhere?.().engine as Engine + const reading = { + state: engine.state, + settings: SAMPLE_SETTINGS, + now: SAMPLE_NOW, + history: engine.history, + } + const model = explorerModel({ ...reading, open: new Set(), full: new Set(), filter: "" }) + const suggest = model.nodes.find( + (node): node is Extract => + node.kind === "suggest" && node.suggestion.family.permission === "bash", + ) as Node + expect(rowText(treeRow(suggest, 80, 10, 0, false))).toContain("★ any TZ=… npx vitest run?") + const view = explorerRows({ + ...reading, + open: new Set(), + full: new Set(), + filter: "", + width: 120, + height: 40, + project: "app", + selected: model.nodes.find( + (node) => node.kind === "command" && node.command.subject.includes("python3"), + )?.key as string, + }) + const text = view.rows.map(rowText).join("\n") + expect(text).toContain("Run from ~/work/support-data-uploader/scripts — the agent") + expect(text).not.toContain("(in ") + + const answered = sub("cd packages/api && npx vitest run src/routes.test.ts") + const rows: Row[] = sidebarRows({ + width: 36, + recent: [ + { + at: 1, + request: "r", + permission: "bash", + agent: "build", + label: answered, + subjects: [answered], + why: "x", + }, + ], + count: 1, + pending: [], + state: engine.state, + limit: 3, + }) + const line = rowText(rows.at(-1) ?? []) + expect(line).toStartWith("● npx vitest") + expect(line).toContain("in …/api") + }) +}) diff --git a/packages/trust/test/tui.test.ts b/packages/trust/test/tui.test.ts index 76c51b2a..09ffaabd 100644 --- a/packages/trust/test/tui.test.ts +++ b/packages/trust/test/tui.test.ts @@ -316,3 +316,53 @@ describe("the dialog", () => { expect(pressEscape(fake)).toBe(false) }) }) + +describe("on OpenCode 2", () => { + /** v1's fake with v2's reach in its place: what the interface reads config and events through. */ + function fakeV2(documents: unknown[]): Fake { + const base = fakeV1() + const listeners: ((event: unknown) => void)[] = [] + const v2 = { + data: { + listen: (listener: (event: unknown) => void) => { + listeners.push(listener) + return () => {} + }, + session: { get: () => undefined }, + }, + client: { + permission: { reply: async (input: unknown) => ({ data: base.replies.push(input) }) }, + config: { get: async () => ({ data: documents }) }, + }, + } + const host = { ...(base.host as object), version: 2, v1: undefined, v2 } as unknown as Host + return { + ...base, + host, + emit: (event) => { + for (const listener of listeners) listener(event) + }, + } + } + + test("the rules are shared with the agent half: its permissions, nothing else of the config", async () => { + process.env.COCKPIT_HOME = mkdtempSync(join(tmpdir(), "trust-tui-")) + process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "trust-tui-config-")) + const permissions = [{ action: "shell", resource: "*", effect: "ask" }] + const fake = fakeV2([ + { type: "document", info: { permissions, provider: { zen: { options: { apiKey: "sk-secret" } } } } }, + ]) + current = fake + await createTrustTui({ source: `test-${Math.random()}` })(fake.host, {}) + await wait(30) + const written = JSON.parse(readFileSync(trustPaths(DIRECTORY).rules, "utf8")) + expect(written.config).toEqual([{ type: "document", info: { permissions } }]) + expect(written.pid).toBe(process.pid) + expect(written.settings).toEqual({ threshold: 3, dangerExtra: 5, expireDays: 30, learnReads: true }) + }) + + test("OpenCode 1 shares nothing: its agent half cannot answer", async () => { + await start() + expect(() => readFileSync(trustPaths(DIRECTORY).rules, "utf8")).toThrow() + }) +}) diff --git a/packages/trust/test/widen.test.ts b/packages/trust/test/widen.test.ts index 68b182b3..cbeb893c 100644 --- a/packages/trust/test/widen.test.ts +++ b/packages/trust/test/widen.test.ts @@ -71,9 +71,9 @@ describe("the policy for a widened family", () => { expect(ask("ls -la", [widened("ls")], { agent: "build" }).answer).toBe(true) }) - test("not another family, nor the same words somewhere else or as root", () => { + test("not another family, nor the same words as root — but the same words in another folder", () => { expect(ask("sudo ls", [widened("ls")]).answer).toBe(false) - expect(ask("cd web && ls", [widened("ls")]).answer).toBe(false) + expect(ask("cd web && ls", [widened("ls")]).answer).toBe(true) expect(ask("lsof -i", [widened("ls")]).answer).toBe(false) }) diff --git a/site/src/content/docs/platform/bays.md b/site/src/content/docs/platform/bays.md index 89072232..ed23a79a 100644 --- a/site/src/content/docs/platform/bays.md +++ b/site/src/content/docs/platform/bays.md @@ -51,7 +51,7 @@ opencode plugin @opencode-cockpit/updater@0.11.0 --global --force ## Subagents — available Every subagent of the conversation in the sidebar with what it is doing now, its whole run a click -away in a pane, and a message away with `m`. Follow-ups continue the subagent that did the work +away in a pane, a message away in the box at its foot, and a permission it asks for answered there. Follow-ups continue the subagent that did the work instead of starting a new one, and the main agent can list, read and wait on its subagents. See [Subagents](/subagents/overview/). diff --git a/site/src/content/docs/subagents/overview.md b/site/src/content/docs/subagents/overview.md index 2005bcf5..5f4f463c 100644 --- a/site/src/content/docs/subagents/overview.md +++ b/site/src/content/docs/subagents/overview.md @@ -83,7 +83,8 @@ the run as it grows; scroll or move the cursor and it stays where you put it unt | `e` | Open, or fold, every call | | `a` | A call's whole output and arguments — open shows the first 60 lines, whole up to 2,000 | | `t` | Show or hide thinking — shown by default, and remembered | -| `m` | Write it a message, at the foot of the pane (pasting works) | +| `tab` · a click on the box | Write it a message in the box at the foot — `enter` sends, `esc` gives the keys back and keeps the draft, `←` `→` move the cursor, `ctrl+u` clears what is before it (pasting works) | +| `y` `Y` `n` · a click | Held on a permission: allow it once, allow it always, or reject it, from the card above the box | | `x` | Stop it (press twice) — or, once it has finished, remove it from the list | | `X` | Remove every finished subagent from the list | | `b` | Move it to the background, so the main agent carries on (OpenCode's own `ctrl+b`) | @@ -116,8 +117,10 @@ continued it", or "You" — and the sidebar counts rounds. ## Messaging a subagent -Press `m` and write at the foot of the pane; `enter` sends, `esc` lets it go. Measured on both -OpenCodes: +The box at the foot of the pane is always there, as OpenCode's own prompt is, with the agent, its +model and whose model it is under it. `tab` or a click on it puts the keys in it; `enter` sends and +leaves you typing; `esc` gives the keys back to the pane and keeps what you wrote; `←` `→` move the +cursor and `ctrl+u` clears what is before it. Measured on both OpenCodes: - **While it works**, it picks the message up in its current run, acts on it, and says so in its answer — which the main agent receives. @@ -128,6 +131,20 @@ OpenCodes: - **If it finishes without reading it** — sent in the last moments of a run — your message comes back to the field, to send again. +## Answering a permission + +A subagent held on a permission shows it as a card at the foot of its pane, above the box, the way +OpenCode's own prompt does: what it asks for — a shell call's whole command line, a few lines of it — +and `[y] Allow once`, `[Y] Allow always`, `[n] Reject`, each a key or a click. Before, a subagent that +asked while you watched it had to be closed to be answered. With several waiting the card says +`1 of 3` and moves on as each is answered, and the switcher at the top marks with `△` any other +subagent that is waiting. It answers the same request OpenCode's prompt shows, so that prompt goes +too; an answer the host refuses comes back on the card with why. + +The keys are not `enter` — it opens the item under the cursor, and one press too many would have +approved a command — and not `a`, which shows a call's whole output. While you type in the box, `y` +and `n` are letters. + ## Stopping and removing `x` twice stops a working subagent, and first tells the main agent you stopped it on purpose — told diff --git a/site/src/content/docs/trust/overview.mdx b/site/src/content/docs/trust/overview.mdx index e2ad1e0f..16a5532c 100644 --- a/site/src/content/docs/trust/overview.mdx +++ b/site/src/content/docs/trust/overview.mdx @@ -55,11 +55,36 @@ when your config sets them to `ask`. | `webfetch` | the host | — | no | no | | `todowrite`, `lsp` | one rule | — | no | no | | `skill`, `task` | the skill, the subagent type | — | no | no | -| `external_directory`, `doom_loop` | never answered | | | | +| `external_directory` | the folder | the folder; the temp dir as one (`$TMPDIR`) | no | yes — and only a widened folder answers it | +| `doom_loop` | never answered | | | | A `read` of a file that may hold secrets (`.env`, a key, `~/.ssh`) is OpenCode's own default ask, and stays yours to answer however often you approve it. +## Folders outside the project + +Before a tool reads or writes outside the project, OpenCode asks `external_directory` for the folder. +Trust counts those approvals by folder, like any other — and **never answers one by its count**: +approving a folder three times still leaves the next one to you. What answers it is a folder you +widened with `w`, and it covers the folders under it, never the ones beside it. + +Most of those prompts are the agent's scratch space in the OS temp dir (`/var/folders/…/T/opencode/…` +on macOS, `/tmp/…` elsewhere), so the temp dir is **one family**, `$TMPDIR`, whatever name a tool +gives it (`/var/…` or `/private/var/…`, `/tmp` or `/private/tmp`). Once your approvals across two or +more of its folders reach the threshold, the ledger suggests it: + +``` + ★ any folder in $TMPDIR? 14 in 6 │ outside $TMPDIR/ family of 6 folders + │ ★ Suggested · approved 14× across 6 folders in your temp directory + │ Widen [w] lets Trust answer reads and writes there from now on — + │ any folder in $TMPDIR; a folder that may hold secrets still asks. +``` + +Never suggested and never widened, however often you approve them: `/`, your home folder and the +folders above it, `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config`, `~/.kube`, `~/.docker`, `/etc`, any +folder that may hold secrets, and a folder that holds the project — widening it would answer for every +project beside this one. A request naming one of them is always yours, and is not counted. + ## Earned, and lost - **Three approvals in a row** (`threshold`) and it is trusted — or, for a command that only reads, @@ -262,13 +287,20 @@ two families. | `docker compose -p prod down -v` | `docker compose down` | | `npm run test` | `npm run test` | | `sudo ls` | `sudo ls` — running as root is not `ls` | -| `cd web && bun test` | `(in web) bun test` | +| `cd web && bun test` | `bun test in web` | | `NODE_ENV=prod npm run build` | `NODE_ENV=… npm run build` | | `ls > out.txt` | `ls` | | edit `src/app.ts` | `edit src/` | | read `src/app.ts` | `read src/` | | grep `createServer`, glob `**/*.ts` | `grep`, `glob` — the tool | +A command the agent ran from another folder — `cd packages/api && npx vitest run`, or the tool's own +working directory — is its own rule and its own family: the same words do something else there. The +ledger shows the command first and the place after it, muted and short — `npx vitest run in +packages/api`, `python3 upload.py in ~/…/support-data-uploader/scripts` — so a narrow row cuts the +command's tail, never its program. The card says it once in words: "Run from packages/api — the agent +changed folder first." + A family with one command is drawn as that row; the others start folded, with how many of their commands are trusted (`✓`) and how many are not yet (`○`). `space`, `enter` or `→` opens one, listing its first three commands and `+ N more`; `←` folds it, or from a command inside it goes to its heading.