From eecedadd6ac2d4cfcfb4d5013a587e31d8d53fd1 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sat, 17 Oct 2026 13:48:18 -0500 Subject: [PATCH 1/2] Trust answers before the prompt draws; Subagents answers permissions; /trust keeps up - Trust (OpenCode 2): the agent half answers in permission.evaluate, before a request exists, so OpenCode's card no longer flickers in bursts. The interface publishes its rules for it. - Subagents: a permission card in the pane (y / Y / n), an always-visible message box (tab or click), and a drag passes through as a selection. - Trust: folders outside the project are counted and suggested ($TMPDIR as one family), never learned; a family is the same in every folder; placed commands read command-first. - Trust: /trust repaints in 3-4ms instead of ~470ms on a 13k-command ledger. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 70 ++++ packages/client/src/opencode/server/index.ts | 86 ++++- packages/client/src/opencode/server/parts.ts | 81 +++++ packages/client/src/opencode/server/v1.ts | 5 + packages/client/src/opencode/server/v2.ts | 66 +++- packages/client/test/server.test.ts | 130 +++++++ packages/opencode/src/server.ts | 4 + packages/subagents/README.md | 14 +- packages/subagents/src/cli/preview.ts | 25 +- packages/subagents/src/core/adapt/v1.ts | 50 ++- packages/subagents/src/core/adapt/v2.ts | 40 +- packages/subagents/src/core/model/changes.ts | 23 ++ packages/subagents/src/core/model/model.ts | 50 +++ packages/subagents/src/core/sample.ts | 12 +- packages/subagents/src/core/view/foot.ts | 201 ++++++++++ packages/subagents/src/core/view/screen.ts | 163 ++++++--- packages/subagents/src/tui/answers.ts | 70 ++++ packages/subagents/src/tui/index.tsx | 6 +- packages/subagents/src/tui/messages.ts | 72 ++-- packages/subagents/src/tui/paint.ts | 14 +- packages/subagents/src/tui/pane.ts | 71 +++- packages/subagents/src/tui/source.ts | 29 ++ packages/subagents/src/tui/surface.ts | 11 +- packages/subagents/src/tui/view/overlay.tsx | 22 +- packages/subagents/test/model.test.ts | 143 ++++++++ packages/subagents/test/view.test.ts | 208 ++++++++++- packages/trust/README.md | 30 +- packages/trust/src/agent/answer.ts | 194 ++++++++++ packages/trust/src/core/engine.ts | 69 +++- packages/trust/src/core/family.ts | 199 ++++++++-- packages/trust/src/core/folders.ts | 146 ++++++++ packages/trust/src/core/keys.ts | 24 +- packages/trust/src/core/ledger.ts | 30 +- packages/trust/src/core/paths.ts | 12 +- packages/trust/src/core/policy.ts | 27 +- packages/trust/src/core/rules.ts | 37 ++ packages/trust/src/core/sample.ts | 40 +- packages/trust/src/core/suggest.ts | 18 +- packages/trust/src/core/view/actions.ts | 16 +- packages/trust/src/core/view/activity.ts | 36 +- packages/trust/src/core/view/card.ts | 137 ++++++- packages/trust/src/core/view/explorer.ts | 9 +- packages/trust/src/core/view/model.ts | 59 ++- packages/trust/src/core/view/parts.ts | 53 ++- packages/trust/src/core/view/sidebar.ts | 56 +-- packages/trust/src/core/view/tree.ts | 87 +++-- packages/trust/src/server.ts | 60 ++- packages/trust/src/tui/index.tsx | 37 +- packages/trust/src/tui/requests.ts | 15 +- packages/trust/src/tui/snapshot.ts | 112 ++++++ packages/trust/test/answer.test.ts | 313 ++++++++++++++++ packages/trust/test/engine.test.ts | 34 ++ packages/trust/test/family.test.ts | 15 +- packages/trust/test/outside.test.ts | 363 +++++++++++++++++++ packages/trust/test/tui.test.ts | 50 +++ packages/trust/test/widen.test.ts | 4 +- site/src/content/docs/platform/bays.md | 2 +- site/src/content/docs/subagents/overview.md | 23 +- site/src/content/docs/trust/overview.mdx | 36 +- 59 files changed, 3728 insertions(+), 281 deletions(-) create mode 100644 packages/subagents/src/core/view/foot.ts create mode 100644 packages/subagents/src/tui/answers.ts create mode 100644 packages/trust/src/agent/answer.ts create mode 100644 packages/trust/src/core/folders.ts create mode 100644 packages/trust/src/tui/snapshot.ts create mode 100644 packages/trust/test/answer.test.ts create mode 100644 packages/trust/test/outside.test.ts 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..05c29077 --- /dev/null +++ b/packages/subagents/src/core/view/foot.ts @@ -0,0 +1,201 @@ +/** + * 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… tab or click to type + * ┃ + * ┃ 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, hintRuns } 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 } = {}, +): FootRow[] { + const ask = pending[0] + if (!ask) return [] + const fill = "card" as const + const bar: Run = { text: `${PAD}▎ `, tone: "warning", fill } + const rows: FootRow[] = [ + { + 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) }) + /** 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 + } + const parts = hintRuns(hint).map((run): Run => ({ ...run, 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 }) + 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 { + line = spread( + [ + bar, + prompt.draft + ? { text: prompt.draft, tone: "text", fill } + : { text: placeholder, tone: "muted", fill }, + ], + [{ text: `${prompt.draft ? "tab to keep typing" : "tab or click to type"} `, 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..7ae13c91 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,78 @@ 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, + ...(input.replyError ? { error: input.replyError } : {}), + }) + rows.push(...card) + if (card.length > 0 && tier === 0) rows.push({ row: fit([], width) }) + rows.push(...promptRows(session, prompt, width, { spacer: tier === 0, meta: tier < 2 })) + 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 +482,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 +568,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 +616,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 +624,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..4e36db86 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,200 @@ 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") + expect(rows[title + 2]).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: [] })) + expect(rest.find((row) => row.includes("┃ Message explore…"))?.trimEnd()).toMatch(/tab or click to type$/) + const kept = text(draw({ session, nodes, prompt: { draft: "skip docs", focused: false, busy: true } })) + expect(kept.find((row) => row.includes("┃ skip docs"))?.trimEnd()).toMatch(/tab to keep typing$/) + 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. From 6429a19709e23ef30dddec45c29dd3758e54346a Mon Sep 17 00:00:00 2001 From: Codestz Date: Sat, 17 Oct 2026 13:53:18 -0500 Subject: [PATCH 2/2] Subagents: the card breathes, and the foot says each thing once Air inside the permission card, its keys in the card's warning tone rather than the accent, the message box two rows under a card, a gap before the hints, and the box no longer repeats the footer's [tab] Message. Seen on the first real run: three panels edge to edge read as one wall. Co-Authored-By: Claude Opus 5.5 --- packages/subagents/src/core/view/foot.ts | 30 +++++++++++++++++----- packages/subagents/src/core/view/screen.ts | 7 ++++- packages/subagents/test/view.test.ts | 22 +++++++++------- 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/packages/subagents/src/core/view/foot.ts b/packages/subagents/src/core/view/foot.ts index 05c29077..4af34090 100644 --- a/packages/subagents/src/core/view/foot.ts +++ b/packages/subagents/src/core/view/foot.ts @@ -3,11 +3,13 @@ * 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 + * ▎ + * ▎ y Allow once Y Allow always n Reject * - * ┃ Message explore… tab or click to type + * ┃ Message explore… * ┃ * ┃ Explore · space-bunny-free opencode * @@ -17,7 +19,7 @@ * the box starts typing as `tab` does. */ -import { HINT_GAP, type Hint, hintRuns } from "@opencode-cockpit/client/design" +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" @@ -82,13 +84,21 @@ export function permissionRows( session: Session, pending: readonly Permission[], width: number, - options: { lines?: number; error?: string } = {}, + 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( [ @@ -110,6 +120,7 @@ export function permissionRows( }) 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[] = [] @@ -119,13 +130,18 @@ export function permissionRows( runs.push({ text: " ".repeat(HINT_GAP), fill }) used += HINT_GAP } - const parts = hintRuns(hint).map((run): Run => ({ ...run, fill })) + /** 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 } @@ -167,14 +183,14 @@ export function promptRows( width, ) } else { - line = spread( + /** 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 }, ], - [{ text: `${prompt.draft ? "tab to keep typing" : "tab or click to type"} `, tone: "muted", fill }], width, ) } diff --git a/packages/subagents/src/core/view/screen.ts b/packages/subagents/src/core/view/screen.ts index 7ae13c91..c49a5edc 100644 --- a/packages/subagents/src/core/view/screen.ts +++ b/packages/subagents/src/core/view/screen.ts @@ -409,11 +409,16 @@ function footer(input: ScreenInput, width: number, tier: Tier): FootRow[] { 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) }) - rows.push(...promptRows(session, prompt, width, { spacer: tier === 0, meta: tier < 2 })) + /** 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" diff --git a/packages/subagents/test/view.test.ts b/packages/subagents/test/view.test.ts index 4e36db86..d0daf729 100644 --- a/packages/subagents/test/view.test.ts +++ b/packages/subagents/test/view.test.ts @@ -770,7 +770,9 @@ describe("the foot: a permission card and the message box", () => { const title = rows.findIndex((row) => row.includes("△ Permission required · bash")) expect(title).toBeGreaterThan(0) expect(rows[title + 1]).toContain("▎ $ pnpm install --frozen-lockfile") - expect(rows[title + 2]).toContain("[y] Allow Once [Y] Allow Always [n] Reject") + /** 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") }) @@ -797,7 +799,7 @@ describe("the foot: a permission card and the message box", () => { 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) + expect(rows.some((row) => row.includes("y Allow once"))).toBe(true) }) test("nothing pending, or every one answered from here, draws no card", () => { @@ -807,14 +809,14 @@ describe("the foot: a permission card and the message box", () => { 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 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"], + ["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) @@ -827,9 +829,11 @@ describe("the foot: a permission card and the message box", () => { 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: [] })) - expect(rest.find((row) => row.includes("┃ Message explore…"))?.trimEnd()).toMatch(/tab or click to type$/) + /** 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(/tab to keep typing$/) + 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 } }), ) @@ -890,7 +894,7 @@ describe("the foot: a permission card and the message box", () => { 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("y Allow once"))).toBe(true) expect(rows.some((row) => row.includes("┃ Message explore…"))).toBe(true) })