Skip to content
Open
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
7 changes: 4 additions & 3 deletions .github/workflows/compliance-close.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,12 @@ jobs:
const twoHours = 2 * 60 * 60 * 1000;
const orgMemberAssociations = new Set(['OWNER', 'MEMBER']);
const agentLogin = 'opencode-agent[bot]';
const defaultBranch = context.payload.repository?.default_branch || 'main';
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev',
ref: defaultBranch,
});
const teamMembers = new Set(
Buffer.from(file.content, 'base64')
Expand Down Expand Up @@ -93,8 +94,8 @@ jobs:
}

const closeMessage = isPR
? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new pull request that follows our guidelines.'
: 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new issue that follows our issue templates.';
? `This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/${defaultBranch}/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new pull request that follows our guidelines.`
: `This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/${defaultBranch}/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new issue that follows our issue templates.`;

await github.rest.issues.createComment({
owner: context.repo.owner,
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/duplicate-issues.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ jobs:

[If not compliant:]
<!-- issue-compliance -->
This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md).
This issue doesn't fully meet our [contributing guidelines](../blob/${{ github.event.repository.default_branch || 'main' }}/CONTRIBUTING.md).

**What needs to be fixed:**
- [specific reasons]
Expand Down
14 changes: 8 additions & 6 deletions .github/workflows/pr-standards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ jobs:

// Check if author is a team member or bot
if (login === 'opencode-agent[bot]') return;
const defaultBranch = context.payload.repository.default_branch || 'main';
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev'
ref: defaultBranch
});
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
if (members.includes(login)) {
Expand Down Expand Up @@ -102,7 +103,7 @@ jobs:

Where \`scope\` is the package name (e.g., \`app\`, \`desktop\`, \`opencode\`).

See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#pr-titles) for details.`);
See [CONTRIBUTING.md](../blob/${defaultBranch}/CONTRIBUTING.md#pr-titles) for details.`);
return;
}

Expand Down Expand Up @@ -145,7 +146,7 @@ jobs:
1. Open an issue describing the bug/feature (if one doesn't exist)
2. Add \`Fixes #<number>\` or \`Closes #<number>\` to this PR description

See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#issue-first-policy) for details.`);
See [CONTRIBUTING.md](../blob/${defaultBranch}/CONTRIBUTING.md#issue-first-policy) for details.`);
return;
}

Expand Down Expand Up @@ -175,11 +176,12 @@ jobs:

// Check if author is a team member or bot
if (login === 'opencode-agent[bot]') return;
const defaultBranch = context.payload.repository.default_branch || 'main';
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev'
ref: defaultBranch
});
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
if (members.includes(login)) {
Expand All @@ -201,7 +203,7 @@ jobs:
const hasIssueSection = /### Issue for this PR/.test(body);

if (!hasWhatSection || !hasTypeSection || !hasVerifySection || !hasChecklistSection || !hasIssueSection) {
issues.push('PR description is missing required template sections. Please use the [PR template](../blob/dev/.github/pull_request_template.md).');
issues.push(`PR description is missing required template sections. Please use the [PR template](../blob/${defaultBranch}/.github/pull_request_template.md).`);
}

// Check: "What does this PR do?" has real content (not just placeholder text)
Expand Down Expand Up @@ -293,7 +295,7 @@ jobs:
const existing = comments.find(c => c.body.includes(marker));

const body_text = `${marker}
This PR doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) and [PR template](../blob/dev/.github/pull_request_template.md).
This PR doesn't fully meet our [contributing guidelines](../blob/${defaultBranch}/CONTRIBUTING.md) and [PR template](../blob/${defaultBranch}/.github/pull_request_template.md).

**What needs to be fixed:**
${issues.map(i => `- ${i}`).join('\n')}
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/background-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,13 @@ function snapshot(job: Active): Info {
}

function errorText(error: unknown) {
if (error instanceof Error) return error.message
return String(error)
if (error instanceof Error) {
const msg = error.message ?? ""
if (msg.trim()) return msg
return `${error.constructor.name}: ${String(error)}`
}
const s = String(error)
return s.trim() || "unknown error"
}

/**
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export interface Interface extends State.Transformable<Draft> {
readonly default: () => Effect.Effect<ModelV2.Info | undefined>
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
}
/** Waits for all initial catalog-producing plugins (models-dev, providers, etc.) to settle. */
readonly readiness: Effect.Effect<void>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
Expand All @@ -67,6 +69,8 @@ const layer = Layer.effect(
const events = yield* EventV2.Service
const policy = yield* Policy.Service
const integrations = yield* Integration.Service
let ready = false
const readyDeferred = yield* Deferred.make<void>()

const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
if (provider.disabled) return false
Expand Down Expand Up @@ -166,6 +170,8 @@ const layer = Layer.effect(
}
}
yield* events.publish(Event.Updated, {})
ready = true
Deferred.succeed(readyDeferred, undefined).pipe(Effect.ignore)
}),
})
const result: Interface = {
Expand Down Expand Up @@ -287,7 +293,10 @@ const layer = Layer.effect(
},
}

return Service.of(result)
return Service.of({
...result,
readiness: ready ? Effect.void : Deferred.await(readyDeferred),
})
}),
)

Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/config/plugin/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ export const Plugin = define({
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
}
if (item.npm !== undefined) {
for (const [modelID, model] of catalog.provider.get(providerID)?.models ?? []) {
if (item.models?.[modelID] !== undefined) continue
if (model.api.type === "aisdk") {
model.api.package = item.npm
}
}
}
}
}
}),
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/config/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ export class Info extends Schema.Class<Info>("ConfigV2.Provider")({
name: Schema.String.pipe(Schema.optional),
env: Schema.String.pipe(Schema.Array, Schema.optional),
api: ProviderV2.Api.pipe(Schema.optional),
npm: Schema.String.pipe(Schema.optional).annotate({
description: "Override the npm package for all models under this provider, including inherited models",
}),
request: Request.pipe(Schema.optional),
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
}) {}
3 changes: 3 additions & 0 deletions packages/core/src/database/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const layer = Layer.effect(
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
yield* db.run("PRAGMA auto_vacuum = INCREMENTAL")
yield* db.run("PRAGMA page_size = 4096")
yield* db.run("PRAGMA optimize")
yield* DatabaseMigration.apply(db)

return { db }
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/fs-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,16 @@ export namespace FSUtil {
let current = options.start
while (true) {
for (const target of options.targets) {
const search = join(current, target)
if (yield* fs.exists(search)) result.push(search)
const isWildcard = target.includes("*") || target.includes("?")
if (isWildcard) {
const matches = yield* glob(target, { cwd: current, absolute: true, include: "all", dot: true }).pipe(
Effect.catch(() => Effect.succeed([] as string[])),
)
if (matches.length > 0) result.push(matches[0])
} else {
const search = join(current, target)
if (yield* fs.exists(search)) result.push(search)
}
}
if (options.stop === current) break
const parent = dirname(current)
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ export function make(input: Partial<Interface> = {}): Interface {
home: Path.home,
data: Path.data,
cache: Path.cache,
config: Flag.OPENCODE_CONFIG_DIR ?? Path.config,
// OPENCODE_CONFIG_DIR is additive to the default config path, not a replacement
config: Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR, Path.config].join(":") : Path.config,
state: Path.state,
tmp: Path.tmp,
bin: Path.bin,
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,11 @@ const layer = Layer.effect(
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionV2.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent)
return agent?.permissions ?? missingAgentPermissions
const agentPermissions = agent?.permissions ?? missingAgentPermissions
// Merge session-level permissions (V1 ruleset) with agent permissions
// Session permissions override agent permissions for session-specific restrictions
const sessionPermissions: Permission.Ruleset = (session.permission ?? []) as Permission.Ruleset
return PermissionV2.merge(agentPermissions, sessionPermissions)
})

function denied(input: AssertInput, rules: Permission.Ruleset) {
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface Interface {
readonly add: (id: ID, effect: PluginRuntime["effect"]) => Effect.Effect<void>
readonly remove: (id: ID) => Effect.Effect<void>
readonly wait: (id: ID) => Effect.Effect<void>
/** Waits for all plugins added via `add` to finish loading. Used as an initial readiness barrier. */
readonly flush: Effect.Effect<void>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {}
Expand Down Expand Up @@ -136,6 +138,11 @@ const layer = Layer.effect(
add,
remove,
wait,
flush: Effect.sync(() => {
const pending = Array.from(waiters.values()).flat()
if (pending.length === 0) return Effect.void
return Deferred.all(pending.map((d) => Deferred.await(d))).pipe(Effect.ignore)
}),
})
host = yield* PluginHost.make(service)
return service
Expand Down
20 changes: 18 additions & 2 deletions packages/core/src/session/runner/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ import { Snapshot } from "../../snapshot"
import { makeLocationNode } from "../../effect/app-node"
import { llmClient } from "../../effect/app-node-platform"

/** Strip HTML tags from provider error messages to avoid rendering raw markup in retry notices. */
const sanitizeProviderErrorMessage = (message: string) =>
message.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim()

/**
* Runs one durable coding-agent Session until it settles.
*
Expand Down Expand Up @@ -180,6 +184,19 @@ const layer = Layer.effect(
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt
const agent = yield* agents.select(session.agent)
let model
try {
model = yield* models.resolve(session)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
yield* events.publish(SessionEvent.Step.Failed, {
sessionID: session.id,
timestamp: yield* DateTime.now,
assistantMessageID: undefined,
error: { type: "model_resolution", message: errorMessage },
})
return yield* Effect.fail(`Model resolution failed: ${errorMessage}`)
}
const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id)
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
let needsContinuation = false
Expand All @@ -196,7 +213,6 @@ const layer = Layer.effect(
}
const system =
initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id))
const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message)
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
Expand Down Expand Up @@ -290,7 +306,7 @@ const layer = Layer.effect(
const llmFailure = failure instanceof LLMError ? failure : undefined
if (llmFailure && !publisher.hasProviderError()) {
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* withPublication(publisher.failAssistant(llmFailure.reason.message))
yield* withPublication(publisher.failAssistant(sanitizeProviderErrorMessage(llmFailure.reason.message)))
}
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/session/runner/to-llm-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
? [{ type: "text", text: item.text }]
: []
const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined)
if (item.provider?.executed !== true) return [call]
if (item.provider?.executed !== true) return reuseProviderMetadata ? [call] : []
const result = toolResult(
item,
reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined,
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard"
import { ApplicationTools } from "./application-tools"
import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool"
import { definition, permission, settle, validateName, validateTool, type AnyTool, type RegistrationError } from "./tool"
import { Tools } from "./tools"
import { makeLocationNode } from "../effect/app-node"

Expand Down Expand Up @@ -86,6 +86,17 @@ const registryLayer = Layer.effect(
const entries = Object.entries(tools)
if (entries.length === 0) return
yield* Effect.forEach(entries, ([name]) => validateName(name), { discard: true })
const invalid: Array<[string, AnyTool]> = []
yield* Effect.forEach(
entries,
([name, tool]) =>
validateTool(tool).pipe(
Effect.tapError(() => {
invalid.push([name, tool])
}),
),
)
if (invalid.length > 0) return
yield* Effect.uninterruptible(
Effect.gen(function* () {
const token = {}
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/tool/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ function runtimeOf(tool: AnyTool) {
return runtime
}

export const validateTool = (tool: AnyTool): Effect.Effect<void, RegistrationError> =>
runtimes.has(tool)
? Effect.void
: Effect.fail(new RegistrationError({ name: "", message: "Invalid Tool value" }))

function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema {
const document = Schema.toJsonSchemaDocument(schema)
if (Object.keys(document.definitions).length === 0) return document.schema
Expand Down
15 changes: 10 additions & 5 deletions packages/llm/src/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,16 @@ export const configure = (input: Config = {}) => {
const responsesWebSocketRoute = configuredRoute(OpenAIResponses.webSocketRoute, input)
const chatRoute = configuredRoute(OpenAIChat.route, input)
const modelDefaults = defaults(input)
const responses = (id: string | ModelID) =>
responsesRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
const responsesWebSocket = (id: string | ModelID) =>
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })

// Chat API only supports HTTP/SSE — reject WebSocket selection explicitly
const chat = (id: string | ModelID) => {
if (input.providerOptions?.transport === "websocket") {
throw new Error(
"OpenAI Chat does not support WebSocket transport. Use `responsesWebSocket()` for WebSocket mode, or omit the transport option.",
)
}
return chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
}

return {
id,
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/acp/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,12 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
name: skill.name,
description: skill.description,
source: "skill" as const,
template: skill.content,
template: skill.location === "<built-in>" ? skill.content : [
skill.content,
"",
`Base directory for this skill: ${skill.location}`,
"Relative paths in this skill (e.g., scripts/, references/) are relative to this base directory.",
].join("\n"),
hints: [],
})),
] as Command.Info[]
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,12 @@ export const RunCommand = effectCmd({
hidden: true,
default: false,
})
.option("full-yolo", {
type: "boolean",
hidden: true,
default: false,
describe: "bypass all permissions including deny rules (ultra-dangerous!)",
})
.option("demo", {
type: "boolean",
default: false,
Expand Down
Loading
Loading