Compare commits

..

3 Commits

Author SHA1 Message Date
Aiden Cline c9ce145219 refactor(core): centralize external directory defaults 2026-08-03 15:37:11 -05:00
Aiden Cline f038138852 refactor(core): remove incomplete plan transitions 2026-08-03 15:27:40 -05:00
Aiden Cline ac4fe70f35 fix(core): apply safe defaults to all agents 2026-08-03 15:12:15 -05:00
18 changed files with 179 additions and 94 deletions
+14 -2
View File
@@ -26,6 +26,7 @@ export interface Selection {
type Data = {
agents: Map<ID, Types.DeepMutable<Info>>
permissions: Types.DeepMutable<Info["permissions"]>
default?: ID
}
@@ -33,6 +34,7 @@ export type Draft = {
list: () => readonly Info[]
get: (id: ID) => Info | undefined
default: (id: ID | undefined) => void
permissions: (permissions: Info["permissions"]) => void
update: (id: ID, fn: (agent: Types.DeepMutable<Info>) => void) => void
remove: (id: ID) => void
}
@@ -53,15 +55,25 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const state = State.create<Data, Draft>({
name: "agent",
initial: () => ({ agents: new Map() }),
initial: () => ({ agents: new Map(), permissions: [] }),
draft: (draft) => ({
list: () => Array.fromIterable(draft.agents.values()) as Info[],
get: (id) => draft.agents.get(id),
default: (id) => {
draft.default = id
},
permissions: (permissions) => {
draft.permissions.push(...permissions)
for (const agent of draft.agents.values()) agent.permissions.push(...permissions)
},
update: (id, fn) => {
const current = draft.agents.get(id) ?? (Info.empty(id) as Types.DeepMutable<Info>)
const defaults = Info.default(id)
const current =
draft.agents.get(id) ??
({
...defaults,
permissions: [...defaults.permissions, ...draft.permissions],
} as Types.DeepMutable<Info>)
if (!draft.agents.has(id)) draft.agents.set(id, current)
fn(current)
current.id = id
+44 -49
View File
@@ -2,15 +2,12 @@ export * as AgentPlugin from "./agent"
import path from "path"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Agent } from "../agent"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../location"
import { Permission } from "../permission"
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
import { Reference } from "../reference"
import { Skill } from "../skill"
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
@@ -100,38 +97,39 @@ Rules:
export const Plugin = define({
id: "opencode.agent",
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
const worktree = location.directory
const whitelistedDirs = [SHELL_OUTPUT_GLOB, path.join(Global.Path.tmp, "*")]
const readonlyExternalDirectory: Permission.Ruleset = [
{ action: "external_directory", resource: "*", effect: "ask" },
...whitelistedDirs.map(
const global = yield* Global.Service
const references = yield* Reference.Service
const skills = yield* Skill.Service
const externalDirectories: { current: Permission.Ruleset } = { current: [] }
const refreshExternalDirectories = Effect.fn("AgentPlugin.refreshExternalDirectories")(function* () {
const [referenceList, skillSources, skillList] = yield* Effect.all([
references.list(),
skills.sources(),
skills.list(),
])
externalDirectories.current = Array.from(
new Set([
path.join(global.data, "shell", "*", "*"),
path.join(global.data, "tool-output", "*"),
path.join(global.tmp, "*"),
path.join(global.config, "*"),
...referenceList.map((reference) => path.join(reference.path, "*")),
...skillSources.flatMap((source) => (source.type === "directory" ? [path.join(source.path, "*")] : [])),
...skillList.map((skill) => path.join(path.dirname(skill.location), "*")),
]),
(resource): Permission.Rule => ({ action: "external_directory", resource, effect: "allow" }),
),
]
const defaults: Permission.Ruleset = [
{ action: "*", resource: "*", effect: "allow" },
...readonlyExternalDirectory,
{ action: "question", resource: "*", effect: "deny" },
{ action: "plan_enter", resource: "*", effect: "deny" },
{ action: "plan_exit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
]
)
})
yield* refreshExternalDirectories()
yield* ctx.agent.transform((draft) => {
draft.permissions(externalDirectories.current)
draft.update(Agent.defaultID, (item) => {
item.name = Agent.Name.make("Build")
item.description = "The default agent. Executes tools based on configured permissions."
item.mode = "primary"
item.permissions.push(
...Permission.merge(defaults, [
{ action: "question", resource: "*", effect: "allow" },
{ action: "plan_enter", resource: "*", effect: "allow" },
]),
)
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
})
draft.update(Agent.ID.make("plan"), (item) => {
@@ -139,18 +137,8 @@ export const Plugin = define({
item.description = "Plan mode. Disallows all edit tools."
item.mode = "primary"
item.permissions.push(
...Permission.merge(defaults, [
{ action: "question", resource: "*", effect: "allow" },
{ action: "plan_exit", resource: "*", effect: "allow" },
{ action: "external_directory", resource: path.join(Global.Path.data, "plans", "*"), effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "edit", resource: path.join(".opencode", "plans", "*.md"), effect: "allow" },
{
action: "edit",
resource: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")),
effect: "allow",
},
]),
{ action: "question", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
)
})
@@ -159,7 +147,10 @@ export const Plugin = define({
item.description =
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
item.mode = "subagent"
item.permissions.push(...Permission.merge(defaults, [{ action: "subagent", resource: "*", effect: "deny" }]))
item.permissions.push(
{ action: "question", resource: "*", effect: "deny" },
{ action: "subagent", resource: "*", effect: "deny" },
)
})
draft.update(Agent.ID.make("explore"), (item) => {
@@ -170,7 +161,6 @@ export const Plugin = define({
item.mode = "subagent"
item.permissions.push(
...Permission.merge(
defaults,
[
{ action: "*", resource: "*", effect: "deny" },
{ action: "grep", resource: "*", effect: "allow" },
@@ -180,7 +170,7 @@ export const Plugin = define({
{ action: "read", resource: "*", effect: "allow" },
{ action: "subagent", resource: "*", effect: "deny" },
],
readonlyExternalDirectory,
[{ action: "external_directory", resource: "*", effect: "ask" }, ...externalDirectories.current],
),
)
})
@@ -190,7 +180,7 @@ export const Plugin = define({
item.mode = "primary"
item.hidden = true
item.system = PROMPT_COMPACTION
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
})
draft.update(Agent.ID.make("title"), (item) => {
@@ -198,7 +188,7 @@ export const Plugin = define({
item.mode = "primary"
item.hidden = true
item.system = PROMPT_TITLE
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
})
draft.update(Agent.ID.make("summary"), (item) => {
@@ -206,8 +196,13 @@ export const Plugin = define({
item.mode = "primary"
item.hidden = true
item.system = PROMPT_SUMMARY
item.permissions.push(...Permission.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
})
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "reference.updated" || event.type === "skill.updated"),
Stream.runForEach(() => refreshExternalDirectories().pipe(Effect.andThen(ctx.agent.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
+1
View File
@@ -96,6 +96,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
list: () => mutable(draft.list()),
get: (id) => mutable(draft.get(Agent.ID.make(id))),
default: (id) => draft.default(id === undefined ? undefined : Agent.ID.make(id)),
permissions: draft.permissions,
update: (id, update) => draft.update(Agent.ID.make(id), update),
remove: (id) => draft.remove(Agent.ID.make(id)),
})
+1 -1
View File
@@ -159,9 +159,9 @@ const pre = [
const post = [
ConfigReferencePlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigAgentPlugin.Plugin,
ConfigCommandPlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
ConfigWebSearchPlugin.Plugin,
VariantPlugin.Plugin,
+2
View File
@@ -6,6 +6,7 @@ import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Schema, Scope } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { PluginRuntime } from "../../plugin/runtime"
@@ -35,6 +36,7 @@ const description = (shell?: string) =>
...(shell ? [`Commands run on ${OS} using ${shell}.`] : []),
"Quote file paths containing spaces or special characters.",
"Prefer dedicated tools over shell commands when possible.",
`Use \`${Global.Path.tmp}\` for temporary work outside the workspace. It already exists and is pre-approved for external-directory access.`,
"When output is large, the full result is saved to a file and a truncated preview is returned.",
"Rely on automatic truncation unless filtering the output is more useful.",
"Commands accept an optional timeout, background commands have no timeout by default.",
+54 -1
View File
@@ -8,13 +8,36 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { Reference } from "@opencode-ai/core/reference"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill"
import { Global } from "@opencode-ai/util/global"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
import { agentHost, host } from "./plugin/host"
const testLocation = location({ directory: AbsolutePath.make("/project") })
const locationLayer = Layer.succeed(Location.Service, Location.Service.of(testLocation))
const referencePath = AbsolutePath.make("/references/docs")
const skillPath = AbsolutePath.make("/skills/team")
const references = Reference.Service.of({
list: () =>
Effect.succeed([
Reference.Info.make({
name: "docs",
path: referencePath,
source: Reference.LocalSource.make({ type: "local", path: referencePath }),
}),
]),
transform: () => Effect.succeed({ dispose: Effect.void }),
reload: () => Effect.void,
})
const skills = Skill.Service.of({
sources: () => Effect.succeed([Skill.DirectorySource.make({ type: "directory", path: skillPath })]),
list: () => Effect.succeed([]),
transform: () => Effect.succeed({ dispose: Effect.void }),
reload: () => Effect.void,
})
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, Location.node]), [
@@ -120,13 +143,31 @@ describe("Agent", () => {
const id = Agent.ID.make("custom")
yield* agent.transform((editor) => editor.update(id, () => {}))
expect(yield* agent.get(id)).toEqual(Agent.Info.empty(id))
expect(yield* agent.get(id)).toEqual(Agent.Info.default(id))
yield* agent.transform((editor) => editor.remove(id))
expect(yield* agent.get(id)).toBeUndefined()
}),
)
it.effect("applies runtime permissions to existing and future agents", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const existing = Agent.ID.make("existing")
const future = Agent.ID.make("future")
const permission = { action: "external_directory", resource: "/tmp/*", effect: "allow" } as const
yield* agent.transform((draft) => {
draft.update(existing, () => {})
draft.permissions([permission])
draft.update(future, () => {})
})
expect((yield* agent.get(existing))?.permissions).toContainEqual(permission)
expect((yield* agent.get(future))?.permissions).toContainEqual(permission)
}),
)
it.effect("does not ambiently opt built-in agents into bash", () =>
Effect.gen(function* () {
const agent = yield* Agent.Service
@@ -135,6 +176,9 @@ describe("Agent", () => {
agent: agentHost(agent),
}),
).pipe(
Effect.provideService(Global.Service, Global.Service.of(Global.make())),
Effect.provideService(Reference.Service, references),
Effect.provideService(Skill.Service, skills),
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
@@ -152,6 +196,12 @@ describe("Agent", () => {
"title",
])
expect((yield* agent.get(Agent.defaultID))?.system).toBeUndefined()
const permissions = (yield* agent.get(Agent.defaultID))?.permissions ?? []
expect(Permission.evaluate("external_directory", "/references/docs/*", permissions).effect).toBe("allow")
expect(Permission.evaluate("external_directory", "/skills/team/*", permissions).effect).toBe("allow")
expect(
Permission.evaluate("external_directory", `${Global.Path.config}/*`, permissions).effect,
).toBe("allow")
for (const item of agents) {
expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false)
}
@@ -166,6 +216,9 @@ describe("Agent", () => {
agent: agentHost(agent),
}),
).pipe(
Effect.provideService(Global.Service, Global.Service.of(Global.make())),
Effect.provideService(Reference.Service, references),
Effect.provideService(Skill.Service, skills),
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
+3
View File
@@ -23,6 +23,9 @@ const decode = Schema.decodeUnknownSync(Config.Info)
const defaultPermissions = [
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
] satisfies Permission.Ruleset
test("rejects named agent color tokens", () => {
+1 -1
View File
@@ -38,10 +38,10 @@ describe("config plugin reloads", () => {
const host = yield* PluginHost.make(plugins)
const test = yield* Config.Test
yield* ConfigReferencePlugin.Plugin.effect(host)
yield* ConfigAgentPlugin.Plugin.effect(host)
yield* ConfigCommandPlugin.Plugin.effect(host)
yield* ConfigSkillPlugin.Plugin.effect(host)
yield* ConfigReferencePlugin.Plugin.effect(host)
yield* ConfigProviderPlugin.Plugin.effect(host)
expect((yield* agents.get(Agent.ID.make("first")))?.description).toBe("First agent")
+1 -1
View File
@@ -12,7 +12,7 @@ import { readInitial, readUpdate } from "./lib/instructions"
const build = Agent.ID.make("build")
const selection = (permissions: Permission.Ruleset = []) => {
const info = Agent.Info.make({ ...Agent.Info.empty(build), permissions })
const info = Agent.Info.make({ ...Agent.Info.default(build), permissions })
return { id: info.id, info }
}
+1
View File
@@ -145,6 +145,7 @@ export function agentHost(agent: Agent.Interface): Plugin.Context["agent"] {
return value && agentInfo(value)
},
default: (id) => draft.default(id === undefined ? undefined : Agent.ID.make(id)),
permissions: draft.permissions,
update: (id, update) =>
draft.update(Agent.ID.make(id), (value) => {
const current = agentInfo(value)
+1 -1
View File
@@ -166,7 +166,7 @@ test("Core reuses the canonical shared schemas", async () => {
]
for (const [core, shared] of schemas) expect(core).toBe(shared)
expect(Agent.Info.empty(Agent.ID.make("test"))).toEqual(Agent.Info.empty(Agent.ID.make("test")))
expect(Agent.Info.default(Agent.ID.make("test"))).toEqual(Agent.Info.default(Agent.ID.make("test")))
expect(coreModel.Info.default(coreProvider.ID.make("test"), coreModel.ID.make("model"))).toEqual(
Model.Info.default(Provider.ID.make("test"), Model.ID.make("model")),
)
@@ -47,7 +47,7 @@ const layer = (list: () => Skill.Info[]) =>
describe("SkillInstructions", () => {
it.effect("renders described agent skills and updates the complete available list", () => {
const agent = Agent.Info.make({
...Agent.Info.empty(build),
...Agent.Info.default(build),
permissions: [{ action: "skill", resource: "denied", effect: "deny" }],
})
let skills = [hidden, denied, manual, effect]
@@ -80,7 +80,7 @@ describe("SkillInstructions", () => {
})
it.effect("announces added and removed skills as deltas without restating the list", () => {
const agent = Agent.Info.make(Agent.Info.empty(build))
const agent = Agent.Info.make(Agent.Info.default(build))
const debugging = Skill.Info.make({
id: Skill.ID.make("debugging"),
name: Skill.Name.make("Debugging"),
@@ -117,7 +117,7 @@ describe("SkillInstructions", () => {
})
it.effect("restates the full skill list when a description changes", () => {
const agent = Agent.Info.make(Agent.Info.empty(build))
const agent = Agent.Info.make(Agent.Info.default(build))
let skills = [effect]
return Effect.gen(function* () {
const instructions = yield* SkillInstructions.Service
@@ -138,7 +138,7 @@ describe("SkillInstructions", () => {
it.effect("omits instructions when the selected agent denies all skills", () => {
const agent = Agent.Info.make({
...Agent.Info.empty(build),
...Agent.Info.default(build),
permissions: [{ action: "skill", resource: "*", effect: "deny" }],
})
return Effect.gen(function* () {
@@ -149,7 +149,7 @@ describe("SkillInstructions", () => {
it.effect("omits instructions when a resource-specific denial follows the global denial", () => {
const agent = Agent.Info.make({
...Agent.Info.empty(build),
...Agent.Info.default(build),
permissions: [
{ action: "skill", resource: "*", effect: "deny" },
{ action: "skill", resource: "hidden", effect: "deny" },
@@ -163,7 +163,7 @@ describe("SkillInstructions", () => {
it.effect("retains specifically allowed skills after a global denial", () => {
const agent = Agent.Info.make({
...Agent.Info.empty(build),
...Agent.Info.default(build),
permissions: [
{ action: "skill", resource: "*", effect: "deny" },
{ action: "skill", resource: "effect", effect: "allow" },
@@ -179,7 +179,7 @@ describe("SkillInstructions", () => {
it.effect("omits instructions when a specifically allowed skill is denied again", () => {
const agent = Agent.Info.make({
...Agent.Info.empty(build),
...Agent.Info.default(build),
permissions: [
{ action: "skill", resource: "*", effect: "deny" },
{ action: "skill", resource: "effect", effect: "allow" },
+1
View File
@@ -7,6 +7,7 @@ export interface AgentDraft {
list(): readonly Types.DeepMutable<Agent.Info>[]
get(id: string): Types.DeepMutable<Agent.Info> | undefined
default(id: string | undefined): void
permissions(permissions: Agent.Info["permissions"]): void
update(id: string, update: (agent: Types.DeepMutable<Agent.Info>) => void): void
remove(id: string): void
}
+1
View File
@@ -7,6 +7,7 @@ export interface AgentDraft {
list(): readonly DeepMutable<Agent.Info>[]
get(id: string): DeepMutable<Agent.Info> | undefined
default(id: string | undefined): void
permissions(permissions: Agent.Info["permissions"]): void
update(id: string, update: (agent: DeepMutable<Agent.Info>) => void): void
remove(id: string): void
}
+4 -1
View File
@@ -36,7 +36,7 @@ export const Info = Schema.Struct({
.annotate({ identifier: "Agent.Info" })
.pipe(
statics(() => ({
empty: (id: ID) =>
default: (id: ID) =>
({
id,
name: Name.make(id),
@@ -46,6 +46,9 @@ export const Info = Schema.Struct({
permissions: [
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
],
}) satisfies Info,
})),
+34 -5
View File
@@ -15,7 +15,7 @@ import {
type SessionTab,
type SessionTabUnread,
} from "../context/session-tabs-model"
import { createAnimatable, spring } from "../ui/animation"
import { createAnimatable, spring, tween } from "../ui/animation"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
@@ -554,6 +554,21 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const glowColor = () => feedbackColor() ?? accent()
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
const title = () => tab.title ?? "Untitled session"
const [outgoingTitle, setOutgoingTitle] = createSignal<string>()
const wipe = createAnimatable({ front: 1 }, { enabled: animations, transition: tween({ duration: 0.3 }) })
createEffect((previous: string) => {
const next = title()
if (next === previous) return next
if (previous === NEW_SESSION_TAB_TITLE) {
setOutgoingTitle(undefined)
wipe.jump({ front: 1 })
return next
}
setOutgoingTitle(previous)
wipe.jump({ front: 0 })
wipe.animate({ front: 1 })
return next
}, title())
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
// The number cell keeps one trailing space, even for double-digit tabs.
const numberWidth = () => String(tabNumber()).length + 1
@@ -562,6 +577,20 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0))
const visibleTitle = createMemo(() => Locale.takeWidth(title(), availableTitleWidth()))
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const outgoingTitleParts = createMemo(() => {
const outgoing = outgoingTitle()
if (outgoing === undefined) return undefined
return Locale.graphemes(Locale.takeWidth(outgoing, availableTitleWidth()))
})
// A new title wipes in from the left over the previous one.
const displayedParts = createMemo(() => {
const front = wipe.value().front
const parts = visibleTitleParts()
const previous = outgoingTitleParts()
if (previous === undefined || front >= 1) return parts
const cut = Math.round(front * Math.max(parts.length, previous.length))
return [...parts.slice(0, cut), ...previous.slice(cut)]
})
const titleFades = createMemo(
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
)
@@ -574,8 +603,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const characterColor = (index: number) => {
const base = foreground()
const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color
const position = index - (visibleTitleParts().length - FADE_WIDTH)
if (!titleFades() || index < displayedParts().length - FADE_WIDTH) return color
const position = index - (displayedParts().length - FADE_WIDTH)
return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
}
// The running sweep's level under the number cell, reported by the pulse renderable.
@@ -648,8 +677,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
selectable={false}
attributes={bold()}
>
<Show when={glows() || titleFades()} fallback={visibleTitle()}>
<For each={visibleTitleParts()}>
<Show when={glows() || titleFades()} fallback={displayedParts().join("")}>
<For each={displayedParts()}>
{(character, index) => <span style={{ fg: characterColor(index()) }}>{character}</span>}
</For>
</Show>
-20
View File
@@ -125,7 +125,6 @@ type ToolName =
| "webfetch"
| "websearch"
| "skill"
| "plan_exit"
type ToolRule = {
view: ToolView
@@ -516,15 +515,6 @@ function runLsp(p: ToolProps): ToolInline {
}
}
function runPlanExit(p: ToolProps): ToolInline {
return {
icon: "→",
title: "Switching to build agent",
mode: "block",
body: p.frame.status === "completed" ? p.frame.output : undefined,
}
}
function patchTitle(file: PatchFile, directory?: string): string {
if (file.status === "added") {
return `# Created ${toolPath(file.file, { directory })}`
@@ -1077,16 +1067,6 @@ const TOOL_RULES = {
start: scrollSkillStart,
},
},
plan_exit: {
view: {
output: true,
final: false,
},
run: runPlanExit,
scroll: {
start: () => "",
},
},
} as const satisfies ToolRegistry
function key(name: string): name is ToolName {
@@ -81,8 +81,7 @@ current built-in actions use these resources:
| `<server>_<tool>` | `*` for an MCP tool; unsupported characters in both names become `_` |
| `execute` | `*`; controls availability of the Code Mode dispatcher, while each nested tool still enforces its own permission |
Built-in agent policy also reserves `plan_enter` and `plan_exit` for plan-mode
transitions. `doom_loop` and `lsp` are not current V2 Core permission actions.
`doom_loop` and `lsp` are not current V2 Core permission actions.
## External directories
@@ -132,7 +131,9 @@ matching, so authorize only trusted directory boundaries.
## Defaults
The evaluator's fallback is `ask`, but shipped agents include ordered defaults:
Every agent, including custom agents, starts with ordered defaults that allow
tools, ask for external directories, ask for `.env` reads, and allow
`.env.example` reads. Shipped agents then add their own policies:
| Agent | Effective default policy |
| --- | --- |
@@ -153,8 +154,11 @@ The base read rules are ordered as follows:
]
```
OpenCode also permits its managed tool-output and temporary directories where
needed. These exceptions do not grant general external-directory access.
OpenCode also permits its managed tool-output, shell-output, temporary, global
configuration, configured reference, and discovered skill directories. These
exceptions apply only to the external-directory boundary for every agent; the
underlying action still uses its own permission rules. Later global and
agent-specific rules can override them.
## Agent overrides