From 23adaaaeab653b3d046cc6a7a1d3591a7fcf4b75 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 30 Jun 2026 01:14:44 -0400 Subject: [PATCH] feat(core): add native skill activation --- AGENTS.md | 1 + packages/cli/src/services/daemon.ts | 10 +- packages/cli/test/daemon.test.ts | 30 +++++ .../client/src/generated-effect/client.ts | 108 ++++++++------- packages/client/src/generated/client.ts | 14 ++ packages/client/src/generated/types.ts | 103 +++++++++++++++ packages/core/src/plugin/skill.ts | 90 ++++++++++++- packages/core/src/plugin/skill/report.md | 125 ++++++++++++++++++ packages/core/src/session.ts | 25 +++- packages/core/src/session/compaction.ts | 1 + packages/core/src/session/message-updater.ts | 11 ++ packages/core/src/session/projector.ts | 9 ++ .../core/src/session/runner/to-llm-message.ts | 2 + packages/core/test/plugin/skill.test.ts | 33 ++++- packages/core/test/session-create.test.ts | 1 - packages/protocol/src/errors.ts | 9 ++ packages/protocol/src/groups/session.ts | 21 +++ packages/schema/src/session-event.ts | 16 +++ packages/schema/src/session-message.ts | 11 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 41 ++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 125 ++++++++++++++++++ packages/server/src/handlers/session.ts | 25 ++++ packages/tui/src/component/dialog-skill.tsx | 16 ++- .../tui/src/component/prompt/autocomplete.tsx | 24 +++- packages/tui/src/component/prompt/index.tsx | 18 ++- packages/tui/src/routes/session/index.tsx | 21 ++- 26 files changed, 814 insertions(+), 76 deletions(-) create mode 100644 packages/cli/test/daemon.test.ts create mode 100644 packages/core/src/plugin/skill/report.md diff --git a/AGENTS.md b/AGENTS.md index cd2327e8881..649f0ae0555 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ - To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`. - After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly. - Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server. +- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required. - The default branch in this repo is `dev`. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. diff --git a/packages/cli/src/services/daemon.ts b/packages/cli/src/services/daemon.ts index 9cf18222ea0..582df7464fa 100644 --- a/packages/cli/src/services/daemon.ts +++ b/packages/cli/src/services/daemon.ts @@ -56,9 +56,11 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FileSystem.FileSystem - const directory = Global.Path.state - const file = path.join(directory, InstallationChannel === "local" ? "service-local.json" : "service.json") - const configFile = path.join(Global.Path.config, "service.json") + const global = yield* Global.Service + const directory = global.state + const filename = InstallationChannel === "local" ? "service-local.json" : "service.json" + const file = path.join(directory, filename) + const configFile = path.join(global.config, filename) const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration)) const decodeServiceConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(ServiceConfig)) @@ -311,7 +313,7 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer +export const defaultLayer = layer.pipe(Layer.provide(Global.defaultLayer)) function serviceURL(config: ServiceConfig) { const hostname = config.hostname ?? "127.0.0.1" diff --git a/packages/cli/test/daemon.test.ts b/packages/cli/test/daemon.test.ts new file mode 100644 index 00000000000..138544fdf44 --- /dev/null +++ b/packages/cli/test/daemon.test.ts @@ -0,0 +1,30 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { Global } from "@opencode-ai/core/global" +import { expect, test } from "bun:test" +import { Effect } from "effect" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { Daemon } from "../src/services/daemon" + +test("local channel stores service config with the local service filename", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-daemon-")) + try { + await Effect.runPromise( + Effect.gen(function* () { + const daemon = yield* Daemon.Service + yield* daemon.set("autostart", "false") + }).pipe( + Effect.provide(Daemon.layer), + Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })), + Effect.provide(NodeFileSystem.layer), + ), + ) + expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({ + autostart: false, + }) + expect(await Bun.file(path.join(root, "config", "service.json")).exists()).toBe(false) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 59f12e225d6..465a245d1a9 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -144,23 +144,36 @@ const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Inp Effect.map((value) => value.data), ) -type Endpoint3_9Request = Parameters[0] -type Endpoint3_9Input = { readonly sessionID: Endpoint3_9Request["params"]["sessionID"] } +type Endpoint3_9Request = Parameters[0] +type Endpoint3_9Input = { + readonly sessionID: Endpoint3_9Request["params"]["sessionID"] + readonly id?: Endpoint3_9Request["payload"]["id"] + readonly skill: Endpoint3_9Request["payload"]["skill"] + readonly resume?: Endpoint3_9Request["payload"]["resume"] +} const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) => - raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.skill"]({ + params: { sessionID: input["sessionID"] }, + payload: { id: input["id"], skill: input["skill"], resume: input["resume"] }, + }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_10Request = Parameters[0] +type Endpoint3_10Request = Parameters[0] type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] } const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) => + raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_11Request = Parameters[0] +type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] } +const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) => raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_11Request = Parameters[0] -type Endpoint3_11Input = { - readonly sessionID: Endpoint3_11Request["params"]["sessionID"] - readonly messageID: Endpoint3_11Request["payload"]["messageID"] - readonly files?: Endpoint3_11Request["payload"]["files"] +type Endpoint3_12Request = Parameters[0] +type Endpoint3_12Input = { + readonly sessionID: Endpoint3_12Request["params"]["sessionID"] + readonly messageID: Endpoint3_12Request["payload"]["messageID"] + readonly files?: Endpoint3_12Request["payload"]["files"] } -const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) => +const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) => raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -169,42 +182,42 @@ const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11I Effect.map((value) => value.data), ) -type Endpoint3_12Request = Parameters[0] -type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] } -const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) => - raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint3_13Request = Parameters[0] +type Endpoint3_13Request = Parameters[0] type Endpoint3_13Input = { readonly sessionID: Endpoint3_13Request["params"]["sessionID"] } const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) => - raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_14Request = Parameters[0] +type Endpoint3_14Request = Parameters[0] type Endpoint3_14Input = { readonly sessionID: Endpoint3_14Request["params"]["sessionID"] } const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) => + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_15Request = Parameters[0] +type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] } +const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) => raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint3_15Request = Parameters[0] -type Endpoint3_15Input = { - readonly sessionID: Endpoint3_15Request["params"]["sessionID"] - readonly limit?: Endpoint3_15Request["query"]["limit"] - readonly after?: Endpoint3_15Request["query"]["after"] +type Endpoint3_16Request = Parameters[0] +type Endpoint3_16Input = { + readonly sessionID: Endpoint3_16Request["params"]["sessionID"] + readonly limit?: Endpoint3_16Request["query"]["limit"] + readonly after?: Endpoint3_16Request["query"]["after"] } -const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) => +const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) => raw["session.history"]({ params: { sessionID: input["sessionID"] }, query: { limit: input["limit"], after: input["after"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_16Request = Parameters[0] -type Endpoint3_16Input = { - readonly sessionID: Endpoint3_16Request["params"]["sessionID"] - readonly after?: Endpoint3_16Request["query"]["after"] +type Endpoint3_17Request = Parameters[0] +type Endpoint3_17Input = { + readonly sessionID: Endpoint3_17Request["params"]["sessionID"] + readonly after?: Endpoint3_17Request["query"]["after"] } -const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) => +const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) => Stream.unwrap( raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe( Effect.mapError(mapClientError), @@ -212,17 +225,17 @@ const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16I ), ) -type Endpoint3_17Request = Parameters[0] -type Endpoint3_17Input = { readonly sessionID: Endpoint3_17Request["params"]["sessionID"] } -const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) => +type Endpoint3_18Request = Parameters[0] +type Endpoint3_18Input = { readonly sessionID: Endpoint3_18Request["params"]["sessionID"] } +const Endpoint3_18 = (raw: RawClient["server.session"]) => (input: Endpoint3_18Input) => raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_18Request = Parameters[0] -type Endpoint3_18Input = { - readonly sessionID: Endpoint3_18Request["params"]["sessionID"] - readonly messageID: Endpoint3_18Request["params"]["messageID"] +type Endpoint3_19Request = Parameters[0] +type Endpoint3_19Input = { + readonly sessionID: Endpoint3_19Request["params"]["sessionID"] + readonly messageID: Endpoint3_19Request["params"]["messageID"] } -const Endpoint3_18 = (raw: RawClient["server.session"]) => (input: Endpoint3_18Input) => +const Endpoint3_19 = (raw: RawClient["server.session"]) => (input: Endpoint3_19Input) => raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -238,16 +251,17 @@ const adaptGroup3 = (raw: RawClient["server.session"]) => ({ switchModel: Endpoint3_6(raw), rename: Endpoint3_7(raw), prompt: Endpoint3_8(raw), - compact: Endpoint3_9(raw), - wait: Endpoint3_10(raw), - stage: Endpoint3_11(raw), - clear: Endpoint3_12(raw), - commit: Endpoint3_13(raw), - context: Endpoint3_14(raw), - history: Endpoint3_15(raw), - events: Endpoint3_16(raw), - interrupt: Endpoint3_17(raw), - message: Endpoint3_18(raw), + skill: Endpoint3_9(raw), + compact: Endpoint3_10(raw), + wait: Endpoint3_11(raw), + stage: Endpoint3_12(raw), + clear: Endpoint3_13(raw), + commit: Endpoint3_14(raw), + context: Endpoint3_15(raw), + history: Endpoint3_16(raw), + events: Endpoint3_17(raw), + interrupt: Endpoint3_18(raw), + message: Endpoint3_19(raw), }) type Endpoint4_0Request = Parameters[0] diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index a3402e0c4c5..7ea6525fae5 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -21,6 +21,8 @@ import type { SessionRenameOutput, SessionPromptInput, SessionPromptOutput, + SessionSkillInput, + SessionSkillOutput, SessionCompactInput, SessionCompactOutput, SessionWaitInput, @@ -427,6 +429,18 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), + skill: (input: SessionSkillInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/skill`, + body: { id: input["id"], skill: input["skill"], resume: input["resume"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), compact: (input: SessionCompactInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index d09c3bdcfd2..79234985d70 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -50,6 +50,14 @@ export type ConflictError = { export const isConflictError = (value: unknown): value is ConflictError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError" +export type SkillNotFoundError = { + readonly _tag: "SkillNotFoundError" + readonly skill: string + readonly message: string +} +export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError" + export type SessionBusyError = { readonly _tag: "SessionBusyError" readonly sessionID: string @@ -540,6 +548,27 @@ export type SessionPromptOutput = { } }["data"] +export type SessionSkillInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly id?: { + readonly id?: string | undefined + readonly skill: string + readonly resume?: boolean | undefined + }["id"] + readonly skill: { + readonly id?: string | undefined + readonly skill: string + readonly resume?: boolean | undefined + }["skill"] + readonly resume?: { + readonly id?: string | undefined + readonly skill: string + readonly resume?: boolean | undefined + }["resume"] +} + +export type SessionSkillOutput = void + export type SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionCompactOutput = void @@ -629,6 +658,14 @@ export type SessionContextOutput = { readonly type: "system" readonly text: string } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "skill" + readonly name: string + readonly text: string + } | { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } @@ -882,6 +919,20 @@ export type SessionHistoryOutput = { readonly text: string } } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.skill.activated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly name: string + readonly text: string + } + } | { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } @@ -1361,6 +1412,20 @@ export type SessionEventsOutput = readonly text: string } } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.skill.activated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly name: string + readonly text: string + } + } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } @@ -1749,6 +1814,14 @@ export type SessionMessageOutput = { readonly type: "system" readonly text: string } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "skill" + readonly name: string + readonly text: string + } | { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } @@ -1921,6 +1994,14 @@ export type MessageListOutput = { readonly type: "system" readonly text: string } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "skill" + readonly name: string + readonly text: string + } | { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } @@ -2711,6 +2792,14 @@ export type EventSubscribeOutput = readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "agent.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: {} + } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } @@ -3392,6 +3481,20 @@ export type EventSubscribeOutput = readonly text: string } } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.skill.activated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly name: string + readonly text: string + } + } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index ea723dd89de..c9eca991184 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -6,26 +6,112 @@ import { define } from "./internal" import { Effect } from "effect" import { AbsolutePath } from "../schema" import { SkillV2 } from "../skill" +import { InstallationChannel, InstallationVersion } from "../installation/version" +import { Config } from "../config" +import { Location } from "../location" +import { FSUtil } from "../fs-util" +import os from "os" +import path from "path" +import { fileURLToPath } from "url" import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" } +import reportContent from "./skill/report.md" with { type: "text" } export const CustomizeOpencodeContent = customizeOpencodeContent +export const ReportContent = reportContent + +const CUSTOMIZE_OPENCODE_DESCRIPTION = + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." +const REPORT_DESCRIPTION = + "Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI." export const Plugin = define({ id: "skill", effect: Effect.fn(function* (ctx) { + const reportContent = yield* reportContentWithDiagnostics() yield* ctx.skill.transform((draft) => { draft.source( SkillV2.EmbeddedSource.make({ type: "embedded", skill: SkillV2.Info.make({ name: "customize-opencode", - description: - "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.", + description: CUSTOMIZE_OPENCODE_DESCRIPTION, location: AbsolutePath.make("/builtin/customize-opencode.md"), content: CustomizeOpencodeContent, }), }), ) + draft.source( + SkillV2.EmbeddedSource.make({ + type: "embedded", + skill: SkillV2.Info.make({ + name: "report", + description: REPORT_DESCRIPTION, + slash: true, + location: AbsolutePath.make("/builtin/report.md"), + content: reportContent, + }), + }), + ) }) }), }) + +const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* () { + const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"])) + return [ + ReportContent, + "", + "## Runtime Diagnostics Snapshot", + "", + "These values were captured when the built-in report skill was registered. Verify them before publishing.", + "", + `- opencode version: ${InstallationVersion}`, + `- install/channel: ${InstallationChannel}`, + `- OS: ${os.type()} ${os.release()} (${os.platform()} ${os.arch()})`, + `- Terminal: ${terminal()}`, + `- Shell: ${shell()}`, + `- Active plugins: ${plugins.length === 0 ? "None found in config" : plugins.join(", ")}`, + ].join("\n") +}) + +const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () { + const config = yield* Config.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + return yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") { + const directory = entry.path ? path.dirname(entry.path) : location.directory + return Effect.succeed( + (entry.info.plugins ?? []).map((item) => { + const ref = typeof item === "string" ? { package: item } : item + if (ref.package.startsWith("file://")) return fileURLToPath(ref.package) + if (ref.package.startsWith("./") || ref.package.startsWith("../")) return path.resolve(directory, ref.package) + return ref.package + }), + ) + } + return fs + .glob("{plugin,plugins}/*.{ts,js}", { + cwd: entry.path, + absolute: true, + include: "file", + dot: true, + symlink: true, + }) + .pipe(Effect.orElseSucceed(() => [])) + }).pipe(Effect.map((items) => items.flat().toSorted())) +}) + +function terminal() { + return [ + process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined, + process.env.TERM ? `TERM=${process.env.TERM}` : undefined, + process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined, + ] + .filter((item): item is string => item !== undefined) + .join(", ") || "Unavailable: terminal environment variables are not set" +} + +function shell() { + return process.env.SHELL ?? process.env.ComSpec ?? process.env.COMSPEC ?? "Unavailable: shell environment variable is not set" +} diff --git a/packages/core/src/plugin/skill/report.md b/packages/core/src/plugin/skill/report.md new file mode 100644 index 00000000000..af5aa26b486 --- /dev/null +++ b/packages/core/src/plugin/skill/report.md @@ -0,0 +1,125 @@ + + +# Report an opencode Issue + +Use this skill when the user wants to report an opencode issue or bug. Your job +is to turn the user's problem into a useful GitHub issue with standard +diagnostics plus the context needed to reproduce and resolve it. + +## Workflow + +1. Collect the standard diagnostics below. +2. Ask only for missing details that are necessary to reproduce or understand + impact. +3. Draft the issue in the standard format below. +4. Publish it with GitHub CLI after the user confirms the title and body. + +Do not publish an issue without user confirmation. If GitHub CLI is not +installed or not authenticated, explain the blocker and provide the exact issue +title/body for the user. + +## Standard Diagnostics + +Collect these values when possible: + +- opencode version: run `opencode --version` or `opencode2 --version`, + depending on the executable in use. +- Operating system: run `uname -a` on Unix-like systems, or `ver` on Windows. +- Terminal: inspect `$TERM`, `$TERM_PROGRAM`, `$COLORTERM`, and any obvious + terminal app context the user provides. +- Shell: inspect `$SHELL` on Unix-like systems, or `%COMSPEC%`/`$ComSpec` on + Windows when relevant. +- Install/channel context: include whether this appears to be local, dev, beta, + or release if the version output or environment reveals it. +- Active plugins: inspect opencode config for configured plugins when possible. + Check likely config locations such as `opencode.json`, `opencode.jsonc`, + `.opencode/opencode.json`, and `~/.config/opencode/opencode.json`. Record + configured plugin entries, local plugin files under `.opencode/plugin/` or + `.opencode/plugins/`, and note if plugin status could not be determined. + +If a diagnostic command fails, include `Unavailable` with the reason instead of +guessing. + +## User-Specific Context + +Capture the details that make the issue actionable: + +- What the user was trying to do. +- What happened. +- What the user expected to happen. +- Reproduction steps, ideally minimal and numbered. +- Relevant logs, stack traces, screenshots, terminal output, or config snippets. +- Whether the issue is reproducible consistently, intermittently, or only once. +- Recent changes that may be related, such as updating opencode, changing + config, installing a plugin, changing terminal, or switching workspace. +- Workarounds tried and whether they helped. + +Avoid pasting secrets. Redact tokens, API keys, private URLs, usernames, and +project-specific confidential data unless the user explicitly says it is safe. + +## Issue Format + +Use this exact structure unless the repository issue template requires +otherwise: + +```markdown +## Summary + + + +## Environment + +- opencode version: +- OS: +- Terminal: +- Shell: +- Install/channel: +- Active plugins: + +## Reproduction + +1. +2. +3. + +## Expected Behavior + + + +## Actual Behavior + + + +## Additional Context + + +``` + +Keep the title short and searchable. Prefer the form: + +```text +: +``` + +Examples: `tui: skills dialog crashes outside location provider`, +`cli: local service config writes release filename`. + +## Publishing With GitHub CLI + +Use GitHub CLI from the repository checkout when available: + +```sh +gh issue create --title "" --body-file <file> +``` + +Write the body to a temporary markdown file first so quoting, newlines, logs, +and code fences are preserved. If the issue belongs in a specific repository, +use `--repo owner/name`. If labels are obvious and the repo accepts them, add +`--label bug`; otherwise omit labels rather than guessing. + +After publishing, report the created issue URL to the user and mention any +diagnostics that were unavailable. diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 83cc9ffca1e..252f9ca0b22 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -38,6 +38,7 @@ import { SessionRevert } from "./session/revert" import { Revert } from "@opencode-ai/schema/revert" import { FSUtil } from "./fs-util" import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest" +import { SkillV2 } from "./skill" export const RevertState = Revert.State export type RevertState = Revert.State @@ -115,6 +116,9 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", { sessionID: SessionSchema.ID, }) {} +export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", { + skill: Schema.String, +}) {} export const MessageNotFoundError = SessionRevert.MessageNotFoundError export type MessageNotFoundError = SessionRevert.MessageNotFoundError @@ -124,6 +128,7 @@ export type Error = | OperationUnavailableError | PromptConflictError | BusyError + | SkillNotFoundError | MessageNotFoundError export interface Interface { @@ -176,11 +181,11 @@ export interface Interface { resume?: boolean }) => Effect.Effect<void, OperationUnavailableError> readonly skill: (input: { - id?: EventV2.ID + id?: SessionMessage.ID sessionID: SessionSchema.ID skill: string resume?: boolean - }) => Effect.Effect<void, OperationUnavailableError> + }) => Effect.Effect<void, NotFoundError | SkillNotFoundError> readonly compact: ( input: CompactInput, ) => Effect.Effect<void, NotFoundError | BusyError | MessageDecodeError | OperationUnavailableError> @@ -440,8 +445,20 @@ export const layer = Layer.effect( shell: Effect.fn("V2Session.shell")(function* () { return yield* new OperationUnavailableError({ operation: "shell" }) }), - skill: Effect.fn("V2Session.skill")(function* () { - return yield* new OperationUnavailableError({ operation: "skill" }) + skill: Effect.fn("V2Session.skill")(function* (input) { + const session = yield* result.get(input.sessionID) + const skills = yield* SkillV2.Service.pipe(Effect.provide(locations.get(session.location))) + const skill = (yield* skills.list()).find((item) => item.name === input.skill) + if (!skill) return yield* new SkillNotFoundError({ skill: input.skill }) + yield* events.publish(SessionEvent.Skill.Activated, { + sessionID: input.sessionID, + messageID: input.id ?? SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + name: skill.name, + text: skill.content, + }) + if (input.resume !== false) + yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) }), switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { yield* result.get(input.sessionID) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index de12ef50edb..99c45cbb520 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -130,6 +130,7 @@ const serialize = (message: SessionMessage.Message) => { } if (message.type === "system") return `[System update]: ${message.text}` if (message.type === "synthetic") return `[Synthetic context]: ${message.text}` + if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}` if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output)}` return "" } diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index cfb424620d4..1e124980478 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -159,6 +159,17 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }), ) }, + "session.next.skill.activated": (event) => { + return adapter.appendMessage( + SessionMessage.Skill.make({ + id: event.data.messageID, + type: "skill", + name: event.data.name, + text: event.data.text, + time: { created: event.data.timestamp }, + }), + ) + }, "session.next.shell.started": (event) => { return adapter.appendMessage( SessionMessage.Shell.make({ diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index a9a069cf6ac..52f2554dd01 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -584,6 +584,15 @@ export const layer = Layer.effectDiscard( ) yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event)) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) + yield* events.project(SessionEvent.Skill.Activated, (event) => + insertMessage(db, event, { + id: event.data.messageID, + type: "skill", + name: event.data.name, + text: event.data.text, + time: { created: event.data.timestamp }, + }), + ) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Step.Started, (event) => run(db, event)) diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index b2b1af5d30f..17e13dc7671 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -131,6 +131,8 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] ] case "synthetic": return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })] + case "skill": + return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })] case "system": return [Message.system(message.text)] case "shell": diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 61bee856b9f..c1a94dc60a8 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -1,25 +1,50 @@ import { describe, expect } from "bun:test" -import { Effect } from "effect" +import { NodeFileSystem } from "@effect/platform-node" +import { Config } from "@opencode-ai/core/config" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { Location } from "@opencode-ai/core/location" import { SkillPlugin } from "@opencode-ai/core/plugin/skill" +import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" +import { Effect } from "effect" +import { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { host } from "./host" const it = testEffect(AppNodeBuilder.build(SkillV2.node)) describe("SkillPlugin.Plugin", () => { - it.effect("registers the built-in customize-opencode skill", () => + it.effect("registers built-in skills", () => Effect.gen(function* () { const skill = yield* SkillV2.Service - yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })) + yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })).pipe( + Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })), + Effect.provideService( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), + ), + Effect.provide(FSUtil.defaultLayer), + Effect.provide(NodeFileSystem.layer), + ) + const skills = yield* skill.list() + const report = skills.find((item) => item.name === "report") - expect(yield* skill.list()).toContainEqual( + expect(skills).toContainEqual( expect.objectContaining({ name: "customize-opencode", description: expect.stringContaining("opencode's own configuration"), }), ) + expect(skills).toContainEqual( + expect.objectContaining({ + name: "report", + description: expect.stringContaining("opencode issue"), + }), + ) + expect(report?.slash).toBe(true) + expect(report?.content).toContain(`- opencode version: ${InstallationVersion}`) }), ) }) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 16bdfd68b43..aaff515d12f 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -444,7 +444,6 @@ describe("SessionV2.create", () => { ) expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell") - expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill") }), ) diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index 27478af786e..7f6893bae02 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -80,6 +80,15 @@ export class MessageNotFoundError extends Schema.TaggedErrorClass<MessageNotFoun { httpApiStatus: 404 }, ) {} +export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()( + "SkillNotFoundError", + { + skill: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + export class InvalidCursorError extends Schema.TaggedErrorClass<InvalidCursorError>()( "InvalidCursorError", { message: Schema.String }, diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 5706e14dbfb..291a75f7f03 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -15,6 +15,7 @@ import { ServiceUnavailableError, SessionBusyError, SessionNotFoundError, + SkillNotFoundError, UnknownError, } from "../errors" import { Agent } from "@opencode-ai/schema/agent" @@ -256,6 +257,26 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo }), ), ) + .add( + HttpApiEndpoint.post("session.skill", "/api/session/:sessionID/skill", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + id: SessionMessage.ID.pipe(Schema.optional), + skill: Schema.String, + resume: Schema.Boolean.pipe(Schema.optional), + }), + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, SkillNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.skill", + summary: "Activate skill", + description: "Activate a skill for a session by appending a skill message and resuming execution.", + }), + ), + ) .add( HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { params: { sessionID: Session.ID }, diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index a66881a8ad8..a38b240e07f 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -141,6 +141,20 @@ export const Synthetic = Event.define({ }) export type Synthetic = typeof Synthetic.Type +export namespace Skill { + export const Activated = Event.define({ + type: "session.next.skill.activated", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + name: Schema.String, + text: Schema.String, + }, + }) + export type Activated = typeof Activated.Type +} + export namespace Shell { export const Started = Event.define({ type: "session.next.shell.started", @@ -476,6 +490,7 @@ export const DurableDefinitions = Event.inventory( PromptAdmitted, ContextUpdated, Synthetic, + Skill.Activated, Shell.Started, Shell.Ended, Step.Started, @@ -509,6 +524,7 @@ export const Definitions = Event.inventory( PromptAdmitted, ContextUpdated, Synthetic, + Skill.Activated, Shell.Started, Shell.Ended, Step.Started, diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 58ff532063a..63bdf5e7aaa 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -65,6 +65,14 @@ export const System = Schema.Struct({ text: Schema.String, }).annotate({ identifier: "Session.Message.System" }) +export interface Skill extends Schema.Schema.Type<typeof Skill> {} +export const Skill = Schema.Struct({ + ...Base, + type: Schema.Literal("skill"), + name: Schema.String, + text: Schema.String, +}).annotate({ identifier: "Session.Message.Skill" }) + export interface Shell extends Schema.Schema.Type<typeof Shell> {} export const Shell = Schema.Struct({ ...Base, @@ -203,11 +211,12 @@ export const Message = Schema.Union([ User, Synthetic, System, + Skill, Shell, Assistant, Compaction, ]) .pipe(Schema.toTaggedUnion("type")) .annotate({ identifier: "Session.Message" }) -export type Message = AgentSwitched | ModelSwitched | User | Synthetic | System | Shell | Assistant | Compaction +export type Message = AgentSwitched | ModelSwitched | User | Synthetic | System | Skill | Shell | Assistant | Compaction export type Type = Message["type"] diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 0a261778bf0..f567f48ed1c 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -387,6 +387,8 @@ import type { V2SessionRevertCommitResponses, V2SessionRevertStageErrors, V2SessionRevertStageResponses, + V2SessionSkillErrors, + V2SessionSkillResponses, V2SessionSwitchAgentErrors, V2SessionSwitchAgentResponses, V2SessionSwitchModelErrors, @@ -5745,6 +5747,45 @@ export class Session3 extends HeyApiClient { }) } + /** + * Activate skill + * + * Activate a skill for a session by appending a skill message and resuming execution. + */ + public skill<ThrowOnError extends boolean = false>( + parameters: { + sessionID: string + id?: string + skill?: string + resume?: boolean + }, + options?: Options<never, ThrowOnError>, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "id" }, + { in: "body", key: "skill" }, + { in: "body", key: "resume" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post<V2SessionSkillResponses, V2SessionSkillErrors, ThrowOnError>({ + url: "/api/session/{sessionID}/skill", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Compact session * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 3902f04c995..57487cf2f77 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -26,6 +26,7 @@ export type Event = | EventSessionNextPromptAdmitted | EventSessionNextContextUpdated | EventSessionNextSynthetic + | EventSessionNextSkillActivated | EventSessionNextShellStarted | EventSessionNextShellEnded | EventSessionNextStepStarted @@ -940,6 +941,17 @@ export type GlobalEvent = { text: string } } + | { + id: string + type: "session.next.skill.activated" + properties: { + timestamp: number + sessionID: string + messageID: string + name: string + text: string + } + } | { id: string type: "session.next.shell.started" @@ -1690,6 +1702,7 @@ export type GlobalEvent = { | SyncEventSessionNextPromptAdmitted | SyncEventSessionNextContextUpdated | SyncEventSessionNextSynthetic + | SyncEventSessionNextSkillActivated | SyncEventSessionNextShellStarted | SyncEventSessionNextShellEnded | SyncEventSessionNextStepStarted @@ -2794,6 +2807,12 @@ export type ConflictError = { resource?: string } +export type SkillNotFoundError = { + _tag: "SkillNotFoundError" + skill: string + message: string +} + export type ServiceUnavailableError = { _tag: "ServiceUnavailableError" message: string @@ -2816,6 +2835,7 @@ export type SessionDurableEvent = | SessionNextPromptAdmitted | SessionNextContextUpdated | SessionNextSynthetic + | SessionNextSkillActivated | SessionNextShellStarted | SessionNextShellEnded | SessionNextStepStarted @@ -2970,6 +2990,7 @@ export type V2Event = | SessionNextPromptAdmitted | SessionNextContextUpdated | SessionNextSynthetic + | SessionNextSkillActivated | SessionNextShellStarted | SessionNextShellEnded | SessionNextStepStarted @@ -3578,6 +3599,24 @@ export type SyncEventSessionNextSynthetic = { } } +export type SyncEventSessionNextSkillActivated = { + type: "sync" + id: string + syncEvent: { + type: "session.next.skill.activated.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + name: string + text: string + } + } +} + export type SyncEventSessionNextShellStarted = { type: "sync" id: string @@ -4170,6 +4209,19 @@ export type SessionMessageSystem = { text: string } +export type SessionMessageSkill = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "skill" + name: string + text: string +} + export type SessionMessageShell = { id: string metadata?: { @@ -4319,6 +4371,7 @@ export type SessionMessage = | SessionMessageUser | SessionMessageSynthetic | SessionMessageSystem + | SessionMessageSkill | SessionMessageShell | SessionMessageAssistant | SessionMessageCompaction @@ -4504,6 +4557,27 @@ export type SessionNextSynthetic = { } } +export type SessionNextSkillActivated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.skill.activated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + name: string + text: string + } +} + export type SessionNextShellStarted = { id: string metadata?: { @@ -6631,6 +6705,18 @@ export type EventSessionNextSynthetic = { } } +export type EventSessionNextSkillActivated = { + id: string + type: "session.next.skill.activated" + properties: { + timestamp: number + sessionID: string + messageID: string + name: string + text: string + } +} + export type EventSessionNextShellStarted = { id: string type: "session.next.shell.started" @@ -12002,6 +12088,45 @@ export type V2SessionPromptResponses = { export type V2SessionPromptResponse = V2SessionPromptResponses[keyof V2SessionPromptResponses] +export type V2SessionSkillData = { + body: { + id?: string + skill: string + resume?: boolean + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/skill" +} + +export type V2SessionSkillErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | SkillNotFoundError + */ + 404: SkillNotFoundError | SessionNotFoundError +} + +export type V2SessionSkillError = V2SessionSkillErrors[keyof V2SessionSkillErrors] + +export type V2SessionSkillResponses = { + /** + * <No Content> + */ + 204: void +} + +export type V2SessionSkillResponse = V2SessionSkillResponses[keyof V2SessionSkillResponses] + export type V2SessionCompactData = { body?: never path: { diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 9be10c57bae..d026ca8d16c 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -10,6 +10,7 @@ import { ServiceUnavailableError, SessionBusyError, SessionNotFoundError, + SkillNotFoundError, UnknownError, } from "@opencode-ai/protocol/errors" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -214,6 +215,30 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.skill", + Effect.fn(function* (ctx) { + yield* session.skill({ + sessionID: ctx.params.sessionID, + id: ctx.payload.id, + skill: ctx.payload.skill, + resume: ctx.payload.resume, + }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + Effect.catchTag("Session.SkillNotFoundError", (error) => + Effect.fail(new SkillNotFoundError({ skill: error.skill, message: `Skill not found: ${error.skill}` })), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.compact", Effect.fn(function* (ctx) { diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index e962a6e7c3e..61c173a3842 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -2,26 +2,32 @@ import { TextAttributes } from "@opentui/core" import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" import { createResource, createMemo, createSignal } from "solid-js" import { useDialog } from "../ui/dialog" -import { useSDK } from "../context/sdk" import { useTheme } from "../context/theme" import { errorMessage } from "../util/error" +import { useData } from "../context/data" +import type { LocationRef } from "@opencode-ai/sdk/v2" export type DialogSkillProps = { + location?: LocationRef onSelect: (skill: string) => void } export function DialogSkill(props: DialogSkillProps) { const dialog = useDialog() - const sdk = useSDK() + const data = useData() const { theme } = useTheme() dialog.setSize("large") const [loadError, setLoadError] = createSignal<unknown>() const [skills] = createResource(() => - sdk.client.app - .skills({}, { throwOnError: true }) - .then((result) => result.data ?? []) + Promise.resolve() + .then(async () => { + const current = data.location.skill.list(props.location) + if (current) return current + await data.location.skill.refresh(props.location) + return data.location.skill.list(props.location) ?? [] + }) // Catch so the rejected resource never reaches the memo below: reading // skills() in an errored state re-throws and tears down the dialog. .catch((error) => { diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 6d3e3412509..fb9d1537e92 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -443,12 +443,12 @@ export function Autocomplete(props: { const commands = createMemo((): AutocompleteOption[] => { const results: AutocompleteOption[] = [...slashes()] + const commandNames = new Set<string>() - for (const serverCommand of sync.data.command) { - if (serverCommand.source === "skill") continue - const label = serverCommand.source === "mcp" ? ":mcp" : "" + for (const serverCommand of data.location.command.list(location()) ?? []) { + commandNames.add(serverCommand.name) results.push({ - display: "/" + serverCommand.name + label, + display: "/" + serverCommand.name, description: serverCommand.description, onSelect: () => { const newText = "/" + serverCommand.name + " " @@ -460,6 +460,22 @@ export function Autocomplete(props: { }) } + for (const skill of data.location.skill + .list(location()) + ?.filter((skill) => skill.slash === true && !commandNames.has(skill.name)) ?? []) { + results.push({ + display: "/" + skill.name, + description: skill.description, + onSelect: () => { + const newText = "/" + skill.name + " " + const cursor = props.input().logicalCursor + props.input().deleteRange(0, 0, cursor.row, cursor.col) + props.input().insertText(newText) + props.input().cursorOffset = Bun.stringWidth(newText) + }, + }) + } + results.sort((a, b) => a.display.localeCompare(b.display)) const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 288fdaf710f..e5d2670a007 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -56,6 +56,7 @@ import { usePromptWorkspace } from "./workspace" import { usePromptMove } from "./move" import { readLocalAttachment } from "./local-attachment" import { useData } from "../../context/data" +import { useLocation } from "../../context/location" export type PromptProps = { sessionID?: string @@ -154,6 +155,7 @@ export function Prompt(props: PromptProps) { const project = useProject() const sync = useSync() const data = useData() + const currentLocation = useLocation() const tuiConfig = useTuiConfig() const dialog = useDialog() const toast = useToast() @@ -533,6 +535,7 @@ export function Prompt(props: PromptProps) { run: () => { dialog.replace(() => ( <DialogSkill + location={currentLocation()} onSelect={(skill) => { input.setText(`/${skill} `) setStore("prompt", { @@ -1088,7 +1091,9 @@ export function Prompt(props: PromptProps) { setStore("mode", "normal") } else if ( inputText.startsWith("/") && - sync.data.command.some((x) => x.name === inputText.split("\n")[0].split(" ")[0].slice(1)) + (data.location.command.list(currentLocation()) ?? []).some( + (command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1), + ) ) { move.startSubmit() // Parse command from first line, preserve multi-line content in arguments @@ -1107,6 +1112,17 @@ export function Prompt(props: PromptProps) { variant, parts: nonTextParts.filter((x) => x.type === "file"), }) + } else if ( + inputText.startsWith("/") && + (data.location.skill.list(currentLocation()) ?? []).some( + (skill) => skill.slash === true && skill.name === inputText.split("\n")[0].split(" ")[0].slice(1), + ) + ) { + move.startSubmit() + void sdk.api.session.skill({ + sessionID, + skill: inputText.split("\n")[0].split(" ")[0].slice(1), + }) } else { move.startSubmit() if (!session) { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index b7ff581709d..92a24ffcbbc 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1049,8 +1049,10 @@ function SessionMessageView(props: { message: SessionMessage }) { <Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}> <SessionSwitchMessageV2 message={props.message} /> </Match> - <Match when={props.message.type === "system" || props.message.type === "synthetic"}> - <SessionNoticeMessageV2 message={props.message} /> + <Match when={props.message.type === "system" || props.message.type === "synthetic" || props.message.type === "skill"}> + <Show when={props.message.type === "skill"} fallback={<SessionNoticeMessageV2 message={props.message} />}> + <SessionSkillMessage message={props.message as Extract<SessionMessage, { type: "skill" }>} /> + </Show> </Match> <Match when={props.message.type === "compaction"}> <CompactionMessage /> @@ -1211,13 +1213,26 @@ function SessionSwitchMessageV2(props: { message: SessionMessage }) { function SessionNoticeMessageV2(props: { message: SessionMessage }) { const { theme } = useTheme() + const text = () => { + if (props.message.type === "system" || props.message.type === "synthetic") return props.message.text + return "" + } return ( <text fg={theme.textMuted}> - {props.message.type === "system" || props.message.type === "synthetic" ? props.message.text : ""} + {text()} </text> ) } +function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "skill" }> }) { + const { theme } = useTheme() + return ( + <InlineToolRow icon="→" color={theme.textMuted} pending="Skill" complete={true}> + Skill {props.message.name} + </InlineToolRow> + ) +} + function CompactionMessage() { const { theme } = useTheme() return <box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive} />