diff --git a/.github/workflows/compliance-close.yml b/.github/workflows/compliance-close.yml index a83824e5cf60..1b4bc79685e8 100644 --- a/.github/workflows/compliance-close.yml +++ b/.github/workflows/compliance-close.yml @@ -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') @@ -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, diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index 3972247dafc6..299859723d96 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -119,7 +119,7 @@ jobs: [If not compliant:] - 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] diff --git a/.github/workflows/pr-standards.yml b/.github/workflows/pr-standards.yml index 06838089d354..c4682430072d 100644 --- a/.github/workflows/pr-standards.yml +++ b/.github/workflows/pr-standards.yml @@ -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)) { @@ -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; } @@ -145,7 +146,7 @@ jobs: 1. Open an issue describing the bug/feature (if one doesn't exist) 2. Add \`Fixes #\` or \`Closes #\` 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; } @@ -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)) { @@ -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) @@ -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')} diff --git a/packages/core/src/background-job.ts b/packages/core/src/background-job.ts index cdffd212bc4e..829edf8522ac 100644 --- a/packages/core/src/background-job.ts +++ b/packages/core/src/background-job.ts @@ -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" } /** diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 1945024b54e8..4cd3704eddfe 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -57,6 +57,8 @@ export interface Interface extends State.Transformable { readonly default: () => Effect.Effect readonly small: (providerID: ProviderV2.ID) => Effect.Effect } + /** Waits for all initial catalog-producing plugins (models-dev, providers, etc.) to settle. */ + readonly readiness: Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Catalog") {} @@ -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() const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => { if (provider.disabled) return false @@ -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 = { @@ -287,7 +293,10 @@ const layer = Layer.effect( }, } - return Service.of(result) + return Service.of({ + ...result, + readiness: ready ? Effect.void : Deferred.await(readyDeferred), + }) }), ) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 6f6e0528da73..a16f74fcabb2 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -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 + } + } + } } } }), diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index 1b547570783a..b3b30941ee3f 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -66,6 +66,9 @@ export class Info extends Schema.Class("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), }) {} diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index d61adf047eac..6b93a48a5aad 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -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 } diff --git a/packages/core/src/fs-util.ts b/packages/core/src/fs-util.ts index 93a551c9b0a2..4b022e803f4f 100644 --- a/packages/core/src/fs-util.ts +++ b/packages/core/src/fs-util.ts @@ -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) diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index a192a4b4684f..25701bc23d68 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -61,7 +61,8 @@ export function make(input: Partial = {}): 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, diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 3f28632a034d..ea1824d73134 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -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) { diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index a89cdfda5f0a..c5faea25cc9a 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -24,6 +24,8 @@ export interface Interface { readonly add: (id: ID, effect: PluginRuntime["effect"]) => Effect.Effect readonly remove: (id: ID) => Effect.Effect readonly wait: (id: ID) => Effect.Effect + /** Waits for all plugins added via `add` to finish loading. Used as an initial readiness barrier. */ + readonly flush: Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Plugin") {} @@ -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 diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 72c761e10d93..33a18007488f 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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. * @@ -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() let needsContinuation = false @@ -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 @@ -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) diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index b2b1af5d30f1..0123c62abdda 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -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, diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 1c2dfe7ab459..69726d1ccb99 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -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" @@ -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 = {} diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index 1d9a82e9522d..27cacad4243c 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -155,6 +155,11 @@ function runtimeOf(tool: AnyTool) { return runtime } +export const validateTool = (tool: AnyTool): Effect.Effect => + 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 diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts index 098cad84939b..0d97d099c01b 100644 --- a/packages/llm/src/providers/openai.ts +++ b/packages/llm/src/providers/openai.ts @@ -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, diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 55fbc9681df3..390b87784bc9 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -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 === "" ? 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[] diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3927f615a080..76886ab812fc 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -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, diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 95ffac7ea51d..b1443f8a6f73 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -120,6 +120,12 @@ export const TuiThreadCommand = cmd({ hidden: true, default: false, }) + .option("full-yolo", { + type: "boolean", + hidden: true, + default: false, + describe: "bypass all permissions including deny rules (ultra-dangerous!)", + }) .option("mini", { type: "boolean", describe: "start the minimal interactive interface", @@ -292,6 +298,7 @@ export const TuiThreadCommand = cmd({ prompt, fork: args.fork, auto: args.auto || args.yolo || args["dangerously-skip-permissions"], + fullYolo: args["full-yolo"], }, }), ) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 13540a73a36f..39e87927ef25 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -77,7 +77,14 @@ const cli = yargs(args) process.env.OPENCODE_PID = String(process.pid) }) .usage("") - .completion("completion", "generate shell completion script") + .completion("completion", "generate shell completion script", async (_co) => { + const fishScript = `# fish completion for opencode + +complete -c opencode -f +complete -c opencode -n '__fish_seen_subcommand_from opencode' -x -a '(opencode --get-yargs-completions (commandline -o)[2..-1])' +` + return fishScript + }) .command(AcpCommand) .command(McpCommand) .command(TuiThreadCommand) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index d68e8b34f94f..46eb239a6578 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -102,7 +102,11 @@ function getLegacyPlugins(mod: Record) { if (seen.has(entry)) continue seen.add(entry) const plugin = getServerPlugin(entry) - if (!plugin) throw new TypeError("Plugin export is not a function") + if (!plugin) { + // Skip exports that don't match the plugin shape (e.g. constants, types, helpers). + // A single non-function named export should not silently disable the entire plugin. + continue + } result.push(plugin) } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 9b3f2c46f405..3ef520a68e4d 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -145,6 +145,8 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( // Only apply this workaround if the model actually supports that media input - // otherwise unsupportedParts() will turn it into a user-visible error. const supportsMediaInToolResult = (attachment: { mime: string }) => { + const hasAttachment = model.capabilities.input.some((c) => c === "attachment") + if (!hasAttachment) return false if (model.api.npm === "@ai-sdk/anthropic") return true if (model.api.npm === "@ai-sdk/openai") return true if (model.api.npm === "@ai-sdk/amazon-bedrock/mantle") return true diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 5a04ec213994..9be755680ff1 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -7,6 +7,7 @@ import { EventV2Bridge } from "@/event-v2-bridge" import { InstanceState } from "@/effect/instance-state" import { Global } from "@opencode-ai/core/global" import { SkillPlugin } from "@opencode-ai/core/plugin/skill" +import { SkillV2 } from "@opencode-ai/core/skill" import { Permission } from "@/permission" import { FSUtil } from "@opencode-ai/core/fs-util" import { Config } from "@/config/config" @@ -282,6 +283,20 @@ const layer = Layer.effect( content: CUSTOMIZE_OPENCODE_SKILL_BODY, } yield* loadSkills(s, yield* InstanceState.get(discovered), events) + // Merge skills registered via V2 plugins (ctx.skill.transform) + // These use EmbeddedSource and don't come from filesystem discovery + const v2Sources = yield* SkillV2.Service.sources() + for (const source of v2Sources) { + if (source.type !== "embedded") continue + const skill = source.skill + if (s.skills[skill.name]) continue // don't override discovered skills + s.skills[skill.name] = { + name: skill.name, + description: skill.description, + location: skill.location, + content: skill.content, + } + } return s }), ) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 15acc757f3d4..277d71d3a01b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -323,7 +323,7 @@ const layer = Layer.effect( tool.id === "execute" ? codeModeDescription : undefined, ] .filter(Boolean) - .join("\n"), + .join("\n\n"), parameters: output.parameters, jsonSchema, execute: tool.execute, diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 1384e5d19725..0ca300a0827f 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -14,6 +14,7 @@ import { Effect, Exit, Schema, Scope } from "effect" import { EffectBridge } from "@/effect/bridge" import { RuntimeFlags } from "@/effect/runtime-flags" import { Database } from "@opencode-ai/core/database/database" +import { PermissionV2 } from "@opencode-ai/core/permission" export interface TaskPromptOps { cancel(sessionID: SessionID): Effect.Effect @@ -43,7 +44,10 @@ const BACKGROUND_UPDATED = [ const BaseParameterFields = { description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }), prompt: Schema.String.annotate({ description: "The task for the agent to perform" }), - subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }), + subagent_type: Schema.String.annotate({ + description: + "The type of specialized agent to use for this task. Use one of the available agent types listed in the tool description.", + }), task_id: Schema.optional(Schema.String).annotate({ description: "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", @@ -133,6 +137,15 @@ export const TaskTool = Tool.define( return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) } + const permission = yield* PermissionV2.Service + yield* permission.assert({ + action: "subagent", + resources: [params.subagent_type], + sessionID: ctx.sessionID, + agent: ctx.agent, + metadata: { description: params.description }, + }) + const session = params.task_id ? yield* sessions.get(SessionID.make(params.task_id)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) : undefined diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index 36639ae7b1e6..db8ac62c54d0 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -10,6 +10,7 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) "model.list", Effect.fn(function* () { const catalog = yield* Catalog.Service + yield* catalog.readiness return yield* response(catalog.model.available()) }), ) diff --git a/packages/server/src/handlers/provider.ts b/packages/server/src/handlers/provider.ts index c3f25ab0dfc3..c2bb427b349d 100644 --- a/packages/server/src/handlers/provider.ts +++ b/packages/server/src/handlers/provider.ts @@ -12,6 +12,7 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han "provider.list", Effect.fn(function* () { const catalog = yield* Catalog.Service + yield* catalog.readiness return yield* response(catalog.provider.available()) }), ) @@ -19,6 +20,7 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han "provider.get", Effect.fn(function* (ctx) { const catalog = yield* Catalog.Service + yield* catalog.readiness const provider = yield* catalog.provider.get(ctx.params.providerID) if (!provider) return yield* new ProviderNotFoundError({