Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 83 additions & 3 deletions packages/client/src/opencode/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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<typeof setTimeout> | 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
? {
Expand Down Expand Up @@ -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) => {
Expand Down
81 changes: 81 additions & 0 deletions packages/client/src/opencode/server/parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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> | 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> | 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<void>
/** Every event, as the host sends it: v1's `{ type, properties }`, v2's `{ type, data }`. */
event?: (event: unknown) => Promise<void> | void
Expand Down Expand Up @@ -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) => {
Expand Down
5 changes: 5 additions & 0 deletions packages/client/src/opencode/server/v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ export function addToV1Config(config: V1Config, parts: Pick<ServerParts, "skills
}
}

/**
* A feature's parts as v1's hooks. `permission` and `toolBefore` have none: v1's `permission.ask` is
* declared and never called (docs/opencode/permissions.md), so on v1 a permission is answered from
* the interface, after OpenCode has drawn its prompt.
*/
export function partsToV1Hooks(parts: ServerParts): Hooks {
return {
...(parts.tools ? { tool: parts.tools } : {}),
Expand Down
66 changes: 65 additions & 1 deletion packages/client/src/opencode/server/v2.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { isAbsolute, relative, resolve } from "node:path"
import { type ToolContext, type ToolDefinition, tool } from "@opencode-ai/plugin"
import { type Log, silentLog } from "../../log.ts"
import type { CommandSpec, ServerHost, ToolCall } from "./parts.ts"
import type { CommandSpec, PermissionAsk, ServerHost, ToolCall, ToolStart } from "./parts.ts"
import { contentText } from "./v1.ts"

/** The parts of OpenCode 2.0.15's server plugin context used here (`@opencode/plugin`'s `Context`). */
Expand All @@ -12,6 +12,19 @@ export interface V2ServerContext {
transform(edit: (editor: V2ToolEditor) => void): Promise<unknown>
/** 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<unknown>
/**
* 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<unknown>
}
/**
* 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<unknown>
}
/** 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<unknown> }
Expand Down Expand Up @@ -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(…)`
Expand Down
Loading
Loading