feat(core): add native skill activation

This commit is contained in:
Dax Raad 2026-06-30 01:14:44 -04:00
parent 524ee8fc03
commit 23adaaaeab
26 changed files with 814 additions and 76 deletions

View file

@ -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.

View file

@ -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"

View file

@ -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 })
}
})

View file

@ -144,23 +144,36 @@ const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Inp
Effect.map((value) => value.data),
)
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint3_9Input = { readonly sessionID: Endpoint3_9Request["params"]["sessionID"] }
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.skill"]>[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<RawClient["server.session"]["session.wait"]>[0]
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.compact"]>[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<RawClient["server.session"]["session.wait"]>[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<RawClient["server.session"]["session.revert.stage"]>[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<RawClient["server.session"]["session.revert.stage"]>[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<RawClient["server.session"]["session.revert.clear"]>[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<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[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<RawClient["server.session"]["session.context"]>[0]
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[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<RawClient["server.session"]["session.context"]>[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<RawClient["server.session"]["session.history"]>[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<RawClient["server.session"]["session.history"]>[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<RawClient["server.session"]["session.events"]>[0]
type Endpoint3_16Input = {
readonly sessionID: Endpoint3_16Request["params"]["sessionID"]
readonly after?: Endpoint3_16Request["query"]["after"]
type Endpoint3_17Request = Parameters<RawClient["server.session"]["session.events"]>[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<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint3_17Input = { readonly sessionID: Endpoint3_17Request["params"]["sessionID"] }
const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) =>
type Endpoint3_18Request = Parameters<RawClient["server.session"]["session.interrupt"]>[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<RawClient["server.session"]["session.message"]>[0]
type Endpoint3_18Input = {
readonly sessionID: Endpoint3_18Request["params"]["sessionID"]
readonly messageID: Endpoint3_18Request["params"]["messageID"]
type Endpoint3_19Request = Parameters<RawClient["server.session"]["session.message"]>[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<RawClient["server.message"]["session.messages"]>[0]

View file

@ -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<SessionSkillOutput>(
{
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<SessionCompactOutput>(
{

View file

@ -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 }

View file

@ -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"
}

View file

@ -0,0 +1,125 @@
<!--
Built-in skill. Name and description are registered in code at
packages/core/src/plugin/skill.ts. The body below becomes the skill's
content.
-->
# 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
<!-- One or two sentences describing the bug and impact. -->
## Environment
- opencode version: <!-- value or Unavailable: reason -->
- OS: <!-- value or Unavailable: reason -->
- Terminal: <!-- value or Unavailable: reason -->
- Shell: <!-- value or Unavailable: reason -->
- Install/channel: <!-- value or Unavailable: reason -->
- Active plugins: <!-- list, none found, or Unavailable: reason -->
## Reproduction
1. <!-- step -->
2. <!-- step -->
3. <!-- step -->
## Expected Behavior
<!-- What should have happened. -->
## Actual Behavior
<!-- What happened instead. Include exact errors when available. -->
## Additional Context
<!-- Logs, config snippets, screenshots, frequency, workarounds, related notes. -->
```
Keep the title short and searchable. Prefer the form:
```text
<area>: <specific failure or symptom>
```
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 "<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.

View file

@ -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)

View file

@ -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 ""
}

View file

@ -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({

View file

@ -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))

View file

@ -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":

View file

@ -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}`)
}),
)
})

View file

@ -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")
}),
)

View file

@ -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 },

View file

@ -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 },

View file

@ -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,

View file

@ -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"]

View file

@ -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
*

View file

@ -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: {

View file

@ -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) {

View file

@ -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) => {

View file

@ -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

View file

@ -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) {

View file

@ -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} />