Compare commits

..

28 Commits

Author SHA1 Message Date
opencode 13d4a802ac release: v0.4.36 2025-08-12 18:31:12 +00:00
adamdotdevin c4ae3e429c fix: markdown lists 2025-08-12 13:22:28 -05:00
Dax Raad e9cb360cb7 ci: sync 2025-08-12 14:21:34 -04:00
Dax Raad 596d4e4490 ci: sync 2025-08-12 14:17:18 -04:00
Dax Raad 45451095f7 ci: sync 2025-08-12 14:11:10 -04:00
adamdotdevin aae354c951 fix: word wrapping with hyphens 2025-08-12 13:03:35 -05:00
Dax Raad 4cddda3e16 ci: sync 2025-08-12 14:00:30 -04:00
Dax Raad 834aa036a4 ci: sync 2025-08-12 13:58:51 -04:00
Dax Raad 6d1b6a6fb1 ci: ignore 2025-08-12 13:54:53 -04:00
Dax Raad e78fe8dcba ci: tweak 2025-08-12 13:53:32 -04:00
Dax Raad f62d826037 ci: sync 2025-08-12 13:42:46 -04:00
Dax Raad 342f4239e4 ci: softer language 2025-08-12 13:39:53 -04:00
Dax Raad 5141fe8d7d ci: sync 2025-08-12 17:36:48 +00:00
opencode 3a9dd306db release: v0.4.35 2025-08-12 17:36:48 +00:00
Dax Raad e32b6e2cc2 ci: ignore 2025-08-12 13:31:23 -04:00
Dax Raad fab0e5de04 fix issue when @ tagging fiels throwing error 2025-08-12 13:30:52 -04:00
Dax Raad 61105b487f ci: ignore 2025-08-12 13:25:05 -04:00
Dax 6f584ec641 ci: improve guideline check (#1867) 2025-08-12 13:23:19 -04:00
opencode 3a2b2f13f2 release: v0.4.34 2025-08-12 17:14:23 +00:00
Dax Raad be13e71fb9 ci: sync 2025-08-12 13:09:24 -04:00
Dax Raad dd0c049119 ci: tweak 2025-08-12 13:01:45 -04:00
Dax Raad ee2b57958d ci: disable aur 2025-08-12 12:58:18 -04:00
Dax Raad 4178b3c6ae ci: sync 2025-08-12 12:53:55 -04:00
Dax Raad 2c2752ee02 ci: ignore 2025-08-12 12:33:10 -04:00
Dax Raad 5a17f44da4 support OPENCODE_PERMISSION json env variable 2025-08-12 12:28:08 -04:00
Dax Raad 354e55ecef ci: ignore 2025-08-12 12:12:54 -04:00
Dax 10735f93ca Add agent-level permissions with whitelist/blacklist support (#1862) 2025-08-12 11:39:39 -04:00
adamdotdevin ccaebdcd16 fix: long word and attachment wrapping in editor 2025-08-12 10:34:54 -05:00
33 changed files with 575 additions and 78 deletions
+50
View File
@@ -0,0 +1,50 @@
name: Duplicate Issue Detection
on:
issues:
types: [opened]
jobs:
check-duplicates:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Check for duplicate issues
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: '{ "bash": { "gh*": "allow", "*": "deny" } }'
run: |
opencode run -m anthropic/claude-sonnet-4-20250514 "A new issue has been created: '${{ github.event.issue.title }}'
Issue body:
${{ github.event.issue.body }}
Please search through existing issues in this repository to find any potential duplicates of this new issue. Consider:
1. Similar titles or descriptions
2. Same error messages or symptoms
3. Related functionality or components
4. Similar feature requests
If you find any potential duplicates, please comment on the new issue with:
- A brief explanation of why it might be a duplicate
- Links to the potentially duplicate issues
- A suggestion to check those issues first
Use this format for the comment:
'👋 This issue might be a duplicate of existing issues. Please check:
- #[issue_number]: [brief description of similarity]
If none of these address your specific case, please let us know how this issue differs.'
If no clear duplicates are found, do not comment."
+52
View File
@@ -0,0 +1,52 @@
name: Guidelines Check
on:
pull_request_target:
types: [opened, synchronize]
jobs:
check-guidelines:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Check PR guidelines compliance
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: '{ "bash": { "gh*": "allow", "gh review*": "deny", "*": "deny" } }'
run: |
opencode run -m anthropic/claude-sonnet-4-20250514 "A new pull request has been created: '${{ github.event.pull_request.title }}'
<pr-number>
${{ github.event.pull_request.number }}
</pr-number>
<pr-description>
${{ github.event.pull_request.body }}
</pr-description>
Please check all the code changes in this pull request against the guidelines in AGENTS.md file in this repository. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do
Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
Command MUST be like this.
```
gh api \
--method POST \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/comments \
-f 'body=[summary of issue]' -f 'commit_id=${{ github.event.pull_request.head.sha }}' -f 'path=[path-to-file]' -F "line=[line]" -f 'side=RIGHT'
```
Only create comments for actual violations. If the code follows all guidelines, don't run any gh commands."
+6 -6
View File
@@ -48,20 +48,20 @@ jobs:
restore-keys: |
${{ runner.os }}-bun-
- name: Install makepkg
run: |
sudo apt-get update
sudo apt-get install -y pacman-package-manager
# Temporarily disabled AUR steps
# - name: Install makepkg
# run: |
# sudo apt-get update
# sudo apt-get install -y pacman-package-manager
- name: Setup SSH for AUR
run: |
mkdir -p ~/.ssh
echo "${{ secrets.AUR_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts
git config --global user.email "opencode@sst.dev"
git config --global user.name "opencode"
# ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts
- name: Install dependencies
run: bun install
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cloud-core",
"version": "0.4.29",
"version": "0.4.36",
"private": true,
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/cloud-function",
"version": "0.4.29",
"version": "0.4.36",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/cloud-web",
"version": "0.4.29",
"version": "0.4.36",
"private": true,
"description": "",
"type": "module",
-3
View File
@@ -1,8 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
"agent": {
"build": {}
},
"mcp": {
"context7": {
"type": "remote",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "0.4.29",
"version": "0.4.36",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.4.29",
"version": "0.4.36",
"name": "opencode",
"type": "module",
"private": true,
+2
View File
@@ -97,6 +97,7 @@ if (!snapshot) {
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
/*
// AUR package
const pkgbuild = [
"# Maintainer: dax",
@@ -136,6 +137,7 @@ if (!snapshot) {
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${version}"`
if (!dry) await $`cd ./dist/aur-${pkg} && git push`
}
*/
// Homebrew formula
const homebrewFormula = [
+48 -9
View File
@@ -5,6 +5,7 @@ import { Provider } from "../provider/provider"
import { generateObject, type ModelMessage } from "ai"
import PROMPT_GENERATE from "./generate.txt"
import { SystemPrompt } from "../session/system"
import { mergeDeep } from "remeda"
export namespace Agent {
export const Info = z
@@ -14,6 +15,11 @@ export namespace Agent {
mode: z.union([z.literal("subagent"), z.literal("primary"), z.literal("all")]),
topP: z.number().optional(),
temperature: z.number().optional(),
permission: z.object({
edit: Config.Permission,
bash: z.record(z.string(), Config.Permission),
webfetch: Config.Permission.optional(),
}),
model: z
.object({
modelID: z.string(),
@@ -31,6 +37,13 @@ export namespace Agent {
const state = App.state("agent", async () => {
const cfg = await Config.get()
const defaultPermission: Info["permission"] = {
edit: "allow",
bash: {
"*": "allow",
},
webfetch: "allow",
}
const result: Record<string, Info> = {
general: {
name: "general",
@@ -41,17 +54,20 @@ export namespace Agent {
todowrite: false,
},
options: {},
permission: defaultPermission,
mode: "subagent",
},
build: {
name: "build",
tools: {},
options: {},
permission: defaultPermission,
mode: "primary",
},
plan: {
name: "plan",
options: {},
permission: defaultPermission,
tools: {
write: false,
edit: false,
@@ -70,25 +86,48 @@ export namespace Agent {
item = result[key] = {
name: key,
mode: "all",
permission: defaultPermission,
options: {},
tools: {},
}
const { model, prompt, tools, description, temperature, top_p, mode, ...extra } = value
const { model, prompt, tools, description, temperature, top_p, mode, permission, ...extra } = value
item.options = {
...item.options,
...extra,
}
if (value.model) item.model = Provider.parseModel(value.model)
if (value.prompt) item.prompt = value.prompt
if (value.tools)
if (model) item.model = Provider.parseModel(model)
if (prompt) item.prompt = prompt
if (tools)
item.tools = {
...item.tools,
...value.tools,
...tools,
}
if (value.description) item.description = value.description
if (value.temperature != undefined) item.temperature = value.temperature
if (value.top_p != undefined) item.topP = value.top_p
if (value.mode) item.mode = value.mode
if (description) item.description = description
if (temperature != undefined) item.temperature = temperature
if (top_p != undefined) item.topP = top_p
if (mode) item.mode = mode
if (permission ?? cfg.permission) {
const merged = mergeDeep(cfg.permission ?? {}, permission ?? {})
if (merged.edit) item.permission.edit = merged.edit
if (merged.webfetch) item.permission.webfetch = merged.webfetch
if (merged.bash) {
if (typeof merged.bash === "string") {
item.permission.bash = {
"*": merged.bash,
}
}
// if granular permissions are provided, default to "ask"
if (typeof merged.bash === "object") {
item.permission.bash = mergeDeep(
{
"*": "ask",
},
merged.bash,
)
}
}
}
}
return result
})
+14 -3
View File
@@ -110,6 +110,10 @@ export namespace Config {
].map((x) => "file://" + x),
)
if (Flag.OPENCODE_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
}
// Handle migration from autoshare to share field
if (result.autoshare === true && !result.share) {
result.share = "auto"
@@ -164,6 +168,9 @@ export namespace Config {
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
export type Mcp = z.infer<typeof Mcp>
export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")])
export type Permission = z.infer<typeof Permission>
export const Agent = z
.object({
model: z.string().optional(),
@@ -174,6 +181,13 @@ export namespace Config {
disable: z.boolean().optional(),
description: z.string().optional().describe("Description of when to use the agent"),
mode: z.union([z.literal("subagent"), z.literal("primary"), z.literal("all")]).optional(),
permission: z
.object({
edit: Permission.optional(),
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
})
.optional(),
})
.catchall(z.any())
.openapi({
@@ -243,9 +257,6 @@ export namespace Config {
})
export type Layout = z.infer<typeof Layout>
export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")])
export type Permission = z.infer<typeof Permission>
export const Info = z
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
+1
View File
@@ -3,6 +3,7 @@ export namespace Flag {
export const OPENCODE_DISABLE_WATCHER = truthy("OPENCODE_DISABLE_WATCHER")
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
+3
View File
@@ -21,6 +21,9 @@ import { GithubCommand } from "./cli/cmd/github"
const cancel = new AbortController()
try {
} catch (e) {}
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
e: e instanceof Error ? e.message : e,
+1 -1
View File
@@ -155,7 +155,7 @@ export namespace Permission {
public readonly permissionID: string,
public readonly toolCallID?: string,
) {
super(`The user rejected permission to use this functionality`)
super(`The user rejected permission to use this specific tool call. You may try again with different parameters.`)
}
}
}
+3 -1
View File
@@ -523,6 +523,7 @@ export namespace Session {
t.execute(args, {
sessionID: input.sessionID,
abort: new AbortController().signal,
agent: input.agent!,
messageID: userMsg.id,
metadata: async () => {},
}),
@@ -765,7 +766,7 @@ export namespace Session {
const enabledTools = pipe(
agent.tools,
mergeDeep(await ToolRegistry.enabled(input.providerID, input.modelID)),
mergeDeep(await ToolRegistry.enabled(input.providerID, input.modelID, agent)),
mergeDeep(input.tools ?? {}),
)
for (const item of await ToolRegistry.tools(input.providerID, input.modelID)) {
@@ -791,6 +792,7 @@ export namespace Session {
abort: options.abortSignal!,
messageID: assistantMsg.id,
callID: options.toolCallId,
agent: agent.name,
metadata: async (val) => {
const match = processor.partFromToolCall(options.toolCallId)
if (match && match.state.status === "running") {
+4 -23
View File
@@ -5,12 +5,12 @@ import { Tool } from "./tool"
import DESCRIPTION from "./bash.txt"
import { App } from "../app/app"
import { Permission } from "../permission"
import { Config } from "../config/config"
import { Filesystem } from "../util/filesystem"
import { lazy } from "../util/lazy"
import { Log } from "../util/log"
import { Wildcard } from "../util/wildcard"
import { $ } from "bun"
import { Agent } from "../agent/agent"
const MAX_OUTPUT_LENGTH = 30000
const DEFAULT_TIMEOUT = 1 * 60 * 1000
@@ -40,20 +40,8 @@ export const BashTool = Tool.define("bash", {
async execute(params, ctx) {
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
const app = App.info()
const cfg = await Config.get()
const tree = await parser().then((p) => p.parse(params.command))
const permissions = (() => {
const value = cfg.permission?.bash
if (!value)
return {
"*": "allow",
}
if (typeof value === "string")
return {
"*": value,
}
return value
})()
const permissions = await Agent.get(ctx.agent).then((x) => x.permission.bash)
let needsAsk = false
for (const node of tree.rootNode.descendantsOfType("command")) {
@@ -93,17 +81,10 @@ export const BashTool = Tool.define("bash", {
// always allow cd if it passes above check
if (!needsAsk && command[0] !== "cd") {
const action = (() => {
for (const [pattern, value] of Object.entries(permissions)) {
const match = Wildcard.match(node.text, pattern)
log.info("checking", { text: node.text.trim(), pattern, match })
if (match) return value
}
return "ask"
})()
const action = Wildcard.all(node.text, permissions)
if (action === "deny") {
throw new Error(
"The user has specifically restricted access to this command, you are not allowed to execute it.",
`The user has specifically restricted access to this command, you are not allowed to execute it. Here is the configuration: ${JSON.stringify(permissions)}`,
)
}
if (action === "ask") needsAsk = true
+4 -4
View File
@@ -14,8 +14,8 @@ import { App } from "../app/app"
import { File } from "../file"
import { Bus } from "../bus"
import { FileTime } from "../file/time"
import { Config } from "../config/config"
import { Filesystem } from "../util/filesystem"
import { Agent } from "../agent/agent"
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
@@ -40,7 +40,7 @@ export const EditTool = Tool.define("edit", {
throw new Error(`File ${filePath} is not in the current working directory`)
}
const cfg = await Config.get()
const agent = await Agent.get(ctx.agent)
let diff = ""
let contentOld = ""
let contentNew = ""
@@ -48,7 +48,7 @@ export const EditTool = Tool.define("edit", {
if (params.oldString === "") {
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
if (cfg.permission?.edit === "ask") {
if (agent.permission.edit === "ask") {
await Permission.ask({
type: "edit",
sessionID: ctx.sessionID,
@@ -77,7 +77,7 @@ export const EditTool = Tool.define("edit", {
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
if (cfg.permission?.edit === "ask") {
if (agent.permission.edit === "ask") {
await Permission.ask({
type: "edit",
sessionID: ctx.sessionID,
+9 -6
View File
@@ -11,7 +11,7 @@ import { TodoWriteTool, TodoReadTool } from "./todo"
import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
import { Config } from "../config/config"
import type { Agent } from "../agent/agent"
export namespace ToolRegistry {
const ALL = [
@@ -66,20 +66,23 @@ export namespace ToolRegistry {
return result
}
export async function enabled(_providerID: string, _modelID: string): Promise<Record<string, boolean>> {
const cfg = await Config.get()
export async function enabled(
_providerID: string,
_modelID: string,
agent: Agent.Info,
): Promise<Record<string, boolean>> {
const result: Record<string, boolean> = {}
result["patch"] = false
if (cfg.permission?.edit === "deny") {
if (agent.permission.edit === "deny") {
result["edit"] = false
result["patch"] = false
result["write"] = false
}
if (cfg?.permission?.bash === "deny") {
if (agent.permission.bash["*"] === "deny" && Object.keys(agent.permission.bash).length === 1) {
result["bash"] = false
}
if (cfg?.permission?.webfetch === "deny") {
if (agent.permission.webfetch === "deny") {
result["webfetch"] = false
}
+1
View File
@@ -7,6 +7,7 @@ export namespace Tool {
export type Context<M extends Metadata = Metadata> = {
sessionID: string
messageID: string
agent: string
callID?: string
abort: AbortSignal
metadata(input: { title?: string; metadata?: M }): void
+3 -3
View File
@@ -8,8 +8,8 @@ import { App } from "../app/app"
import { Bus } from "../bus"
import { File } from "../file"
import { FileTime } from "../file/time"
import { Config } from "../config/config"
import { Filesystem } from "../util/filesystem"
import { Agent } from "../agent/agent"
export const WriteTool = Tool.define("write", {
description: DESCRIPTION,
@@ -28,8 +28,8 @@ export const WriteTool = Tool.define("write", {
const exists = await file.exists()
if (exists) await FileTime.assert(ctx.sessionID, filepath)
const cfg = await Config.get()
if (cfg.permission?.edit === "ask")
const agent = await Agent.get(ctx.agent)
if (agent.permission.edit === "ask")
await Permission.ask({
type: "write",
sessionID: ctx.sessionID,
+2 -1
View File
@@ -8,6 +8,7 @@ const ctx = {
sessionID: "test",
messageID: "",
toolCallID: "",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
}
@@ -33,7 +34,7 @@ describe("tool.bash", () => {
test("cd ../ should fail outside of project root", async () => {
await App.provide({ cwd: projectRoot }, async () => {
await expect(
expect(
bash.execute(
{
command: "cd ../",
+1
View File
@@ -8,6 +8,7 @@ const ctx = {
sessionID: "test",
messageID: "",
toolCallID: "",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "0.4.29",
"version": "0.4.36",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "0.4.29",
"version": "0.4.36",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
+25
View File
@@ -912,6 +912,15 @@ export type AgentConfig = {
*/
description?: string
mode?: string
permission?: {
edit?: string
bash?:
| string
| {
[key: string]: string
}
webfetch?: string
}
[key: string]:
| unknown
| string
@@ -921,6 +930,15 @@ export type AgentConfig = {
}
| boolean
| string
| {
edit?: string
bash?:
| string
| {
[key: string]: string
}
webfetch?: string
}
| undefined
}
@@ -1061,6 +1079,13 @@ export type Agent = {
mode: string
topP?: number
temperature?: number
permission: {
edit: string
bash: {
[key: string]: string
}
webfetch?: string
}
model?: {
modelID: string
providerID: string
@@ -325,7 +325,9 @@ func renderText(
// wrap styled text
styledText := result.String()
wrappedText := ansi.WordwrapWc(styledText, width-6, " -")
styledText = strings.ReplaceAll(styledText, "-", "\u2011")
wrappedText := ansi.WordwrapWc(styledText, width-6, " ")
wrappedText = strings.ReplaceAll(wrappedText, "\u2011", "-")
content = base.Width(width - 6).Render(wrappedText)
}
@@ -2045,6 +2045,109 @@ func itemWidth(item any) int {
return 0
}
// forceWrapAttachment splits an attachment's display text across multiple lines
func forceWrapAttachment(att *attachment.Attachment, width int) [][]any {
if width <= 0 {
return [][]any{{att}}
}
display := att.Display
displayRunes := []rune(display)
if len(displayRunes) <= width {
return [][]any{{att}}
}
var lines [][]any
start := 0
for start < len(displayRunes) {
// Calculate how many runes fit in this line
end := start + width
if end > len(displayRunes) {
end = len(displayRunes)
}
// Create a wrapped attachment for this segment
wrappedAtt := &attachment.Attachment{
ID: att.ID,
Type: att.Type,
Display: string(displayRunes[start:end]),
URL: att.URL,
Filename: att.Filename,
MediaType: att.MediaType,
Source: att.Source,
}
lines = append(lines, []any{wrappedAtt})
start = end
}
return lines
}
// forceWrapWord splits a word that's too long to fit within the given width
func forceWrapWord(word []any, width int) [][]any {
if width <= 0 || len(word) == 0 {
return [][]any{word}
}
var lines [][]any
currentLine := []any{}
currentWidth := 0
for _, item := range word {
if att, ok := item.(*attachment.Attachment); ok {
// Handle attachment that might be too wide
attWidth := uniseg.StringWidth(att.Display)
// If the attachment display is too wide, split it
if attWidth > width {
// Finish current line if it has content
if len(currentLine) > 0 {
lines = append(lines, currentLine)
currentLine = []any{}
currentWidth = 0
}
// Split the attachment display across multiple lines
wrappedAttachment := forceWrapAttachment(att, width)
lines = append(lines, wrappedAttachment...)
continue
}
// If adding this attachment would exceed the width, start a new line
if currentWidth+attWidth > width && len(currentLine) > 0 {
lines = append(lines, currentLine)
currentLine = []any{}
currentWidth = 0
}
currentLine = append(currentLine, item)
currentWidth += attWidth
} else if r, ok := item.(rune); ok {
itemWidth := rw.RuneWidth(r)
// If adding this rune would exceed the width, start a new line
if currentWidth+itemWidth > width && len(currentLine) > 0 {
lines = append(lines, currentLine)
currentLine = []any{}
currentWidth = 0
}
currentLine = append(currentLine, item)
currentWidth += itemWidth
}
}
// Add the last line if it has content
if len(currentLine) > 0 {
lines = append(lines, currentLine)
}
return lines
}
func wrapInterfaces(content []any, width int) [][]any {
if width <= 0 {
return [][]any{content}
@@ -2076,11 +2179,49 @@ func wrapInterfaces(content []any, width int) [][]any {
if !inSpaces {
// End of a word
if lineW > 0 && lineW+wordW > width {
lines = append(lines, word)
lineW = wordW
// If the word itself is too long to fit on a line, force-wrap it
if wordW > width {
wrappedLines := forceWrapWord(word, width)
lines = append(lines, wrappedLines...)
// Calculate width of the last wrapped line
lastLine := wrappedLines[len(wrappedLines)-1]
lineW = 0
for _, item := range lastLine {
if r, ok := item.(rune); ok {
lineW += rw.RuneWidth(r)
} else if att, ok := item.(*attachment.Attachment); ok {
lineW += uniseg.StringWidth(att.Display)
}
}
} else {
lines = append(lines, word)
lineW = wordW
}
} else {
lines[len(lines)-1] = append(lines[len(lines)-1], word...)
lineW += wordW
// Check if the word needs to be force-wrapped even when it fits on the current line
if wordW > width {
currentLine := lines[len(lines)-1]
wrappedWord := forceWrapWord(word, width-lineW)
if len(wrappedWord) > 0 {
lines[len(lines)-1] = append(currentLine, wrappedWord[0]...)
for i := 1; i < len(wrappedWord); i++ {
lines = append(lines, wrappedWord[i])
}
// Calculate width of the last wrapped line
lastLine := wrappedWord[len(wrappedWord)-1]
lineW = 0
for _, item := range lastLine {
if r, ok := item.(rune); ok {
lineW += rw.RuneWidth(r)
} else if att, ok := item.(*attachment.Attachment); ok {
lineW += uniseg.StringWidth(att.Display)
}
}
}
} else {
lines[len(lines)-1] = append(lines[len(lines)-1], word...)
lineW += wordW
}
}
word = nil
wordW = 0
@@ -2110,11 +2251,49 @@ func wrapInterfaces(content []any, width int) [][]any {
// Handle any remaining word/spaces at the end of the content.
if wordW > 0 {
if lineW > 0 && lineW+wordW > width {
lines = append(lines, word)
lineW = wordW
// If the word itself is too long to fit on a line, force-wrap it
if wordW > width {
wrappedLines := forceWrapWord(word, width)
lines = append(lines, wrappedLines...)
// Calculate width of the last wrapped line
lastLine := wrappedLines[len(wrappedLines)-1]
lineW = 0
for _, item := range lastLine {
if r, ok := item.(rune); ok {
lineW += rw.RuneWidth(r)
} else if att, ok := item.(*attachment.Attachment); ok {
lineW += uniseg.StringWidth(att.Display)
}
}
} else {
lines = append(lines, word)
lineW = wordW
}
} else {
lines[len(lines)-1] = append(lines[len(lines)-1], word...)
lineW += wordW
// Check if the word needs to be force-wrapped even when it fits on the current line
if wordW > width {
currentLine := lines[len(lines)-1]
wrappedWord := forceWrapWord(word, width-lineW)
if len(wrappedWord) > 0 {
lines[len(lines)-1] = append(currentLine, wrappedWord[0]...)
for i := 1; i < len(wrappedWord); i++ {
lines = append(lines, wrappedWord[i])
}
// Calculate width of the last wrapped line
lastLine := wrappedWord[len(wrappedWord)-1]
lineW = 0
for _, item := range lastLine {
if r, ok := item.(rune); ok {
lineW += rw.RuneWidth(r)
} else if att, ok := item.(*attachment.Attachment); ok {
lineW += uniseg.StringWidth(att.Display)
}
}
}
} else {
lines[len(lines)-1] = append(lines[len(lines)-1], word...)
lineW += wordW
}
}
}
if spaceW > 0 {
+4
View File
@@ -3,6 +3,7 @@ package util
import (
"fmt"
"path/filepath"
"regexp"
"strings"
"unicode"
@@ -85,6 +86,8 @@ func Extension(path string) string {
func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
r := styles.GetMarkdownRenderer(width-6, backgroundColor)
content = strings.ReplaceAll(content, RootPath+"/", "")
hyphenRegex := regexp.MustCompile(`-([^ ]|$)`)
content = hyphenRegex.ReplaceAllString(content, "\u2011$1")
rendered, _ := r.Render(content)
lines := strings.Split(rendered, "\n")
@@ -105,5 +108,6 @@ func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor)
}
}
content = strings.Join(lines, "\n")
content = strings.ReplaceAll(content, "\u2011", "-")
return strings.TrimSuffix(content, "\n")
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/web",
"type": "module",
"version": "0.4.29",
"version": "0.4.36",
"scripts": {
"dev": "astro dev",
"dev:remote": "sst shell --stage=dev --target=Web astro dev",
@@ -358,6 +358,147 @@ Here are all the tools can be controlled through the agent config.
---
### Permissions
Permissions control what actions an agent can take.
- edit, bash, webfetch
Each permission can be set to allow, ask, or deny.
- allow, ask, deny
Configure permissions globally in opencode.json.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"edit": "ask",
"bash": "allow",
"webfetch": "deny"
}
}
```
You can override permissions per agent in JSON.
```json title="opencode.json" {7-18}
{
"$schema": "https://opencode.ai/config.json",
"agent": {
"build": {
"permission": {
"edit": "allow",
"bash": {
"*": "allow",
"git push": "ask",
"terraform *": "deny"
},
"webfetch": "ask"
}
}
}
}
```
You can also set permissions in Markdown agents.
```markdown title="~/.config/opencode/agent/review.md"
---
description: Code review without edits
mode: subagent
permission:
edit: deny
bash: ask
webfetch: deny
---
Only analyze code and suggest changes.
```
Bash permissions support granular patterns for fine-grained control.
```json title="Allow most, ask for risky, deny terraform"
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"bash": {
"*": "allow",
"git push": "ask",
"terraform *": "deny"
}
}
}
```
If you provide a granular bash map, the default becomes ask unless you set \* explicitly.
```json title="Granular defaults to ask"
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"bash": {
"git status": "allow"
}
}
}
```
Agent-level permissions merge over global settings.
- Global sets defaults; agent overrides when specified
Specific bash rules can override a global default.
```json title="Global ask, agent allows safe commands"
{
"$schema": "https://opencode.ai/config.json",
"permission": { "bash": "ask" },
"agent": {
"build": {
"permission": {
"bash": { "git status": "allow", "*": "ask" }
}
}
}
}
```
Permissions affect tool availability and prompts differently.
- deny hides tools (edit also hides write/patch); ask prompts; allow runs
For quick reference, here are common setups.
```json title="Read-only reviewer"
{
"$schema": "https://opencode.ai/config.json",
"agent": {
"review": {
"permission": { "edit": "deny", "bash": "deny", "webfetch": "allow" }
}
}
}
```
```json title="Planning agent that can browse but cannot change code"
{
"$schema": "https://opencode.ai/config.json",
"agent": {
"plan": {
"permission": { "edit": "deny", "bash": "deny", "webfetch": "ask" }
}
}
}
```
See the full permissions guide for more patterns.
- /docs/permissions
---
### Mode
Control the agent's mode with the `mode` config. The `mode` option is used to determine how the agent can be used.
@@ -21,6 +21,8 @@ Permissions are configured in your `opencode.json` file under the `permission` k
| `bash` | Control bash command execution |
| `webfetch` | Control web content fetching |
They can also be configured per agent, see [Agent Configuration](/docs/agents#agent-configuration) for more details.
---
### edit
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "0.4.29",
"version": "0.4.36",
"publisher": "sst-dev",
"repository": {
"type": "git",