From 9b0e4aa77dcf0972709d375b7c126aeb6b853a80 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Sat, 28 Mar 2026 14:18:52 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20$skill=20mentions=20=E2=80=94=20full=20?= =?UTF-8?q?backend=20+=20frontend=20+=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .opencode/agent/docs.md | 34 + docs/designs/dollar-skill-mentions.md | 180 ++ ...2026-03-19-dollar-skill-mentions-design.md | 300 ++++ packages/app/src/components/prompt-input.tsx | 540 +++++- .../components/prompt-input/slash-popover.tsx | 110 +- .../app/src/context/global-sync/bootstrap.ts | 421 ++--- .../src/context/global-sync/child-store.ts | 46 +- .../src/context/global-sync/event-reducer.ts | 14 +- .../src/context/global-sync/session-cache.ts | 4 + packages/app/src/context/global-sync/types.ts | 12 +- packages/app/src/util/skill-parse.test.ts | 153 ++ packages/app/src/util/skill-parse.ts | 38 + .../20260319111502_skill/migration.sql | 10 + .../20260319111502_skill/snapshot.json | 1309 ++++++++++++++ .../cli/cmd/tui/component/prompt/index.tsx | 136 +- .../opencode/src/server/routes/session.ts | 276 ++- packages/opencode/src/session/message-v2.ts | 188 +- packages/opencode/src/session/prompt.ts | 309 ++-- packages/opencode/src/session/session.sql.ts | 32 +- .../opencode/src/session/skill.service.ts | 230 +++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 672 ++++--- packages/sdk/js/src/v2/gen/types.gen.ts | 1565 +++++++++-------- 22 files changed, 4999 insertions(+), 1580 deletions(-) create mode 100644 .opencode/agent/docs.md create mode 100644 docs/designs/dollar-skill-mentions.md create mode 100644 docs/plans/2026-03-19-dollar-skill-mentions-design.md create mode 100644 packages/app/src/util/skill-parse.test.ts create mode 100644 packages/app/src/util/skill-parse.ts create mode 100644 packages/opencode/migration/20260319111502_skill/migration.sql create mode 100644 packages/opencode/migration/20260319111502_skill/snapshot.json create mode 100644 packages/opencode/src/session/skill.service.ts diff --git a/.opencode/agent/docs.md b/.opencode/agent/docs.md new file mode 100644 index 000000000000..21cfc6a16e04 --- /dev/null +++ b/.opencode/agent/docs.md @@ -0,0 +1,34 @@ +--- +description: ALWAYS use this when writing docs +color: "#38A3EE" +--- + +You are an expert technical documentation writer + +You are not verbose + +Use a relaxed and friendly tone + +The title of the page should be a word or a 2-3 word phrase + +The description should be one short line, should not start with "The", should +avoid repeating the title of the page, should be 5-10 words long + +Chunks of text should not be more than 2 sentences long + +Each section is separated by a divider of 3 dashes + +The section titles are short with only the first letter of the word capitalized + +The section titles are in the imperative mood + +The section titles should not repeat the term used in the page title, for +example, if the page title is "Models", avoid using a section title like "Add +new models". This might be unavoidable in some cases, but try to avoid it. + +Check out the /packages/web/src/content/docs/docs/index.mdx as an example. + +For JS or TS code snippets remove trailing semicolons and any trailing commas +that might not be needed. + +If you are making a commit prefix the commit message with `docs:` diff --git a/docs/designs/dollar-skill-mentions.md b/docs/designs/dollar-skill-mentions.md new file mode 100644 index 000000000000..0b058a7be897 --- /dev/null +++ b/docs/designs/dollar-skill-mentions.md @@ -0,0 +1,180 @@ +# CEO Plan: $skill Mentions — Manual Skill Inclusion + +Generated by /plan-ceo-review on 2026-03-19 +Branch: prax-dev | Mode: SCOPE EXPANSION +Repo: praxstack/opencode + +## Vision + +### 10x Check +The 10x version is a **full skill workspace** — not just `$mention` but a complete skill management system. Users curate per-project skill profiles, skills compose with each other, the system learns which skills work best for which tasks. `$` becomes the entry point to a rich skill ecosystem, not just a one-shot loader. Skill presets, usage analytics, conflict detection, and a skill marketplace. + +### Platonic Ideal +The user opens a session. A small badge strip shows 3 skills auto-loaded for this project (from `.opencode/skills.json`). They type `$` and a beautiful popover shows all skills — grouped by category, with usage frequency indicators. They select `$brainstorming` and it appears as a warm-toned pill. The skill persists for the session. They can see via "skill trace" metadata exactly how the skill affected behavior. When they find a great combo (`$brainstorming` + `$tdd`), they save it as a **skill preset**. The next developer on the project gets those skills suggested. The system **feels** like having a mentor who knows exactly what tools to bring to each conversation. + +## Implementation Approach + +**Approach B: Server-Side Skill State** — full persistence via SessionSkills Effect service (like SessionSteer), new SQL table, sync channel. Skills survive page reload, visible across CLI/web/desktop. + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ FRONTEND (SolidJS) │ +│ │ +│ prompt-input.tsx slash-popover.tsx │ +│ ┌────────────┐ $ or Cmd+Shift+S ┌──────────────────┐ │ +│ │ Editor │ ──────────────────▶ │ PromptPopover │ │ +│ │ (pills + │ │ mode="skill" │ │ +│ │ tooltip) │ ◀── select ──────── │ [Recent] [All] │ │ +│ └────────────┘ │ [Empty state] │ │ +│ │ └──────────────────┘ │ +│ │ submit │ +│ ▼ │ +│ prompt context (ContentPart[]) badge-strip │ +│ [SkillPart, SkillPart, TextPart] ┌──────────────────┐ │ +│ │ │ [skill ×] [skill ×]│ │ +│ │ │ + context warning │ │ +│ │ POST /session/:id/chat └──────────────────┘ │ +└───────┼───────────────────────────────────┼───────────────────────┘ + │ │ POST/DELETE /session/:id/skill + ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ BACKEND (Effect) │ +│ │ +│ session.ts (routes) SessionSkills (Effect service) │ +│ ┌────────────────┐ ┌────────────────────────┐ │ +│ │ POST /:id/skill│ ────────▶ │ .add(sessionID, name) │ │ +│ │ DELETE /:id/ │ │ .remove(sessionID, name)│ │ +│ │ skill/:name │ │ .list(sessionID) │ │ +│ │ GET /:id/skill │ │ .clear(sessionID) │ │ +│ └────────────────┘ └───────────┬────────────┘ │ +│ │ │ +│ prompt.ts │ SQL │ +│ ┌────────────────────┐ ▼ │ +│ │ createUserMessage() │ ┌──────────────────────────┐ │ +│ │ → extract SkillPart│ │ session_skills table │ │ +│ │ → synthetic log │ │ session_id | skill_name │ │ +│ │ │ │ added_at | token_est │ │ +│ │ loop() │ └──────────────────────────┘ │ +│ │ → SessionSkills │ │ +│ │ .list(sessionID) │ In-memory LRU cache (5-min TTL) │ +│ │ → LRU cache get │ ──▶ Skill.load(name) on miss │ +│ │ → wrap XML │ │ +│ │ → append to system │ sync channel: "session_skill" │ +│ │ prompt array │ ──▶ frontend reads via useSync() │ +│ └────────────────────┘ │ +│ Skill.available() ← UNCHANGED │ +│ SystemPrompt.skills() ← UNCHANGED │ +│ SkillTool ← UNCHANGED │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Key Decisions + +| Decision | Choice | Reasoning | +|----------|--------|-----------| +| Caching strategy | In-memory LRU with 5-min TTL | Fast after first load, handles edits within 5 min, evicts on session end | +| $ regex guard | Require whitespace/SOL before `$`, exclude `$$` | Prevents false positives with bash `$$` and `$VAR` in shell mode | +| `$-removal` scope | Only removes user-added skills (session_skills table) | Don't interfere with AI auto-loaded skills | +| Shortcut focus guard | `Cmd+Shift+S` only fires when prompt editor focused | Prevents accidental triggers from message history | + +## Scope Decisions + +| # | Proposal | Effort | Decision | Reasoning | +|---|----------|--------|----------|-----------| +| 1 | Skill preview tooltip (hover to see description) | S | ACCEPTED | 10 min CC, makes pills feel alive | +| 2 | Recent skills section in popover (last 5 used) | S | ACCEPTED | Data free with Approach B, intelligent picker | +| 3 | Keyboard shortcut `Cmd+Shift+S` | S | ACCEPTED | 10 min CC, keyboard-first UX | +| 4 | Skill categories in popover (group by source) | S | DEFERRED | Flat list fine for v1 | +| 5 | Context budget warning toast (>30K tokens) | S | ACCEPTED | Prevents subtle context cliff | +| 6 | Empty state UX (no skills installed) | S | ACCEPTED | 5 min CC, design hygiene | + +## Accepted Scope (added to plan) +- Core: `$` prefix detection, skill popover, pill creation, badge strip, `$-removal` syntax +- Core: Server-side persistence via SessionSkills Effect service + session_skills SQL table +- Core: System prompt injection with `` XML tags + dedup with auto-loaded +- Core: CLI `$name` prefix parsing +- Expansion 1: Skill preview tooltip on hover (pills + badge strip) +- Expansion 2: Recent skills section at top of popover (last 5 from session_skills) +- Expansion 3: `Cmd+Shift+S` keyboard shortcut to open skill picker +- Expansion 5: Context budget warning toast when estimated tokens exceed 30K +- Expansion 6: Empty state UX in popover when no skills are installed +- Fix: `$$` guard in regex, shell mode guard, focus guard on shortcut, ParseError handling + +## Deferred to TODOS.md +- Skill categories in popover (group by source directory) — revisit when skill count commonly exceeds 20 +- Skill presets (save/load skill combos) — Phase 2 +- Per-project skill profiles (`.opencode/skills.json`) — Phase 2 +- Skill marketplace / install-from-web — Phase 3 +- Skill analytics / usage patterns — Phase 3 +- Skill composition (skill A references skill B) — research needed +- Refactor `handleInput()` conditional chain to dispatch table — separate PR + +## Dream State Delta +This plan gets us to **~40% of the 12-month ideal.** Foundation: user-initiated loading, server persistence, badge strip, removal, recent skills, tooltip, shortcut, budget warning, empty state. Remaining: presets, per-project profiles, marketplace, analytics, composition. + +## Design Specifications (from /plan-design-review) + +### Popover Layout +``` +┌─────────────────────────────┐ +│ ⌕ Filter... │ ← search input (auto-focused) +├─────────────────────────────┤ +│ RECENT │ ← text-color-dimmed header +│ ★ brainstorming │ +│ ★ tdd │ +├─────────────────────────────┤ +│ ALL SKILLS │ ← text-color-dimmed header +│ api-design │ +│ brainstorming │ +│ clean-code │ +│ ... │ +└─────────────────────────────┘ +Max-width: 400px. Skeleton shimmer (3 rows) on first load. +``` + +### Empty State +``` +┌─────────────────────────────┐ +│ 💡 No skills installed │ +│ │ +│ Skills customize how the │ +│ AI works — add them to │ +│ ~/.claude/skills/ │ +│ │ +│ 📚 Browse available skills │ +└─────────────────────────────┘ +``` + +### Interaction State Table +``` +FEATURE | LOADING | EMPTY | ERROR | SUCCESS +---------------------|-----------------|-----------------|-----------------|-------- +$ Popover | Skeleton shimmer| 💡 Empty state | N/A (local) | Skill list +Badge strip | N/A | Hidden | N/A | [skill ×] +Skill pill | N/A | N/A | N/A | $name pill +Tooltip (hover) | "Loading..." | N/A | "Unavailable" | Rich popover +Context warning | N/A | N/A | N/A | ⚠️ toast +``` + +### Color Tokens +- Skill pill text: `text-syntax-string` (warm/amber — distinct from file=property, agent=type) +- Section headers: `text-color-dimmed` +- Badge strip: matches steer queue badge styling +- Popover: inherits `PromptPopover` theme + +### Rich Tooltip Popover (on hover) +- 300ms hover delay, disappears on mouse leave +- Shows: skill name (bold), description (2-3 lines), source path (dimmed), token estimate (dimmed) +- `role="tooltip"` + `aria-describedby` linking + +### Accessibility +- Badge `×` buttons: `aria-label="Remove skill {name}"`, min 44x44px touch target +- Popover: `role="listbox"` + `aria-label="Select a skill"` +- Keyboard: arrows navigate, Enter selects, Escape closes (inherited from PromptPopover) + +## Review Status +- CEO Review: CLEAR (2026-03-19, SCOPE EXPANSION, 0 critical gaps) +- Eng Review: CLEAR (2026-03-19, FULL_REVIEW, 0 critical gaps) +- Design Review: CLEAR (2026-03-19, 6/10 → 9/10, 2 decisions made) diff --git a/docs/plans/2026-03-19-dollar-skill-mentions-design.md b/docs/plans/2026-03-19-dollar-skill-mentions-design.md new file mode 100644 index 000000000000..fce558175b51 --- /dev/null +++ b/docs/plans/2026-03-19-dollar-skill-mentions-design.md @@ -0,0 +1,300 @@ +# `$skill` Mentions — Manual Skill Inclusion for OpenCode + +**Date:** 2026-03-19 +**Status:** Approved +**Author:** Prax Dev +**Scope:** Frontend (UI popover + pills) + Backend (system prompt injection) + CLI + +--- + +## Problem + +OpenCode currently loads skills automatically — the agent discovers available skills via the system prompt and calls the `skill` tool to load them. This works well for AI-initiated skill loading, but users have no way to **explicitly request** a specific skill be active for their prompt. + +Codex CLI solved this with `$skillname` mentions — the user types `$brainstorming` in the prompt and the skill's SKILL.md is injected into the system instructions. This is user-initiated, explicit, and coexists with automatic discovery. + +**Gap:** OpenCode has no `$` prefix, no skill mention mechanism, and no way for users to force-load a skill into context. + +## Solution + +Add `$` as a third input prefix (alongside `@` for files/agents and `/` for commands) that lets users manually include skills in their prompt. Selected skills appear as inline pills in the editor, and their SKILL.md content is injected into the system prompt. + +**Non-destructive:** The existing automatic skill discovery + AI-initiated `skill` tool loading remains completely untouched. + +--- + +## Architecture + +### Data Flow + +``` +User types: "$brainstorming $tdd Fix the login flow" + ↓ +UI: $ prefix detected → skill picker popover → pill created + ↓ +Parts: [SkillPart("brainstorming"), SkillPart("tdd"), TextPart("Fix the login flow")] + ↓ +Backend createUserMessage(): + - SkillParts extracted (NOT sent as user text) + - Synthetic log: "Loaded skills: brainstorming, tdd" + ↓ +Backend loop() before LLM call: + - Load SKILL.md for each skill + - Wrap in XML tags + - Append to system prompt array + ↓ +LLM sees: system prompt + skill instructions + user message (without $prefixes) +``` + +### Components Modified + +| Component | File | Change | +|-----------|------|--------| +| **Message schema** | `packages/opencode/src/session/message-v2.ts` | Add `SkillPart` to `Part` union | +| **Prompt context** | `packages/app/src/context/prompt.ts` | Add `SkillPart` to `ContentPart` union | +| **Prompt input** | `packages/app/src/components/prompt-input.tsx` | `$` match, skill popover, pill creation | +| **Slash popover** | `packages/app/src/components/prompt-input/slash-popover.tsx` | `SkillOption` type, skill section | +| **Editor DOM** | `packages/app/src/components/prompt-input/editor-dom.ts` | Pill rendering for `data-type="skill"` | +| **Session prompt** | `packages/opencode/src/session/prompt.ts` | Process skill parts, inject into system prompt | +| **CLI input** | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | Parse `$name` prefixes | + +--- + +## Detailed Design + +### 1. SkillPart Type + +```typescript +// In message-v2.ts — Part union +export const SkillPart = z.object({ + type: z.literal("skill"), + id: PartID.zod, + messageID: MessageID.zod, + sessionID: SessionID.zod, + name: z.string(), // skill name (e.g., "brainstorming") +}) + +// In prompt context — ContentPart union (UI side) +type SkillContentPart = { + type: "skill" + name: string + content: string // display text: "$brainstorming" + start: number + end: number +} +``` + +### 2. UI: `$` Prefix Detection + +In `prompt-input.tsx`'s `handleInput()`, add a third prefix match after `@` and `/`: + +```typescript +// Existing +const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) +const slashMatch = rawText.match(/^\/(\S*)$/) + +// New +const dollarMatch = rawText.substring(0, cursorPosition).match(/\$(\S*)$/) + +if (atMatch) { + // existing @ handling +} else if (slashMatch) { + // existing / handling +} else if (dollarMatch) { + skillOnInput(dollarMatch[1]) + setStore("popover", "skill") +} else { + closePopover() +} +``` + +### 3. UI: Skill Picker Popover + +Extends `PromptPopover` with a `"skill"` mode: + +```typescript +type SkillOption = { + type: "skill" + name: string + description: string // first line of SKILL.md description +} +``` + +- Data source: `sync.data.skill` (existing skill sync channel) filtered for the current agent +- Fuzzy search via `useFilteredList` (same as @ and /) +- Selecting a skill calls `addPart({ type: "skill", name, content: "$" + name, start: 0, end: 0 })` + +### 4. UI: Skill Pills + +Inline contenteditable pills with distinct styling: + +```html + + $brainstorming + +``` + +CSS class: `[&_[data-type=skill]]:text-syntax-string` — warm color (distinct from file=property, agent=type) + +### 5. Backend: Skill Injection + +In `prompt.ts`'s `createUserMessage()`: + +```typescript +// In the parts processing loop: +if (part.type === "skill") { + // Don't create user message content — extract for system prompt injection + return [ + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Loaded skill: ${part.name}`, + }, + { + ...part, + messageID: info.id, + sessionID: input.sessionID, + }, + ] +} +``` + +In `loop()`, before the LLM call: + +```typescript +// Extract manually-loaded skills from user message parts +const skillParts = lastUserMsg?.parts.filter(p => p.type === "skill") ?? [] +const userSkills: string[] = [] + +for (const sp of skillParts) { + const skill = await Skill.load(sp.name) + if (!skill) continue + // Deduplicate — don't inject if already in auto system prompt + userSkills.push( + `\n${sp.name}\n${skill.content}\n` + ) +} + +const system = [ + ...await SystemPrompt.environment(model), + ...(skills ? [skills] : []), // auto-discovered (UNCHANGED) + ...await InstructionPrompt.system(), + ...userSkills, // NEW: $-mentioned skills +] +``` + +### 6. CLI: `$name` Prefix Parsing + +In the TUI input handler, before creating prompt parts: + +```typescript +// Parse $name tokens from raw input +const skillRegex = /\$(\w[\w-]*)/g +const matches = [...rawText.matchAll(skillRegex)] +const skillNames = matches.map(m => m[1]) +const cleanText = rawText.replace(skillRegex, "").trim() + +// Create parts: skills + remaining text +const parts = [ + ...skillNames.map(name => ({ type: "skill" as const, name })), + { type: "text" as const, text: cleanText }, +] +``` + +Unmatched `$tokens` (not found in available skills) are left as literal text. + +### 7. Interaction with Existing Systems + +| System | Impact | +|--------|--------| +| **Auto skill discovery** | NONE — `SystemPrompt.skills()` unchanged | +| **AI skill tool** | NONE — agent can still call `skill` tool to load additional skills | +| **@ mentions** | NONE — `@` still handles files/agents | +| **/ commands** | NONE — `/` still handles slash commands | +| **Steer/Queue** | NONE — skill parts are processed in `createUserMessage()`, before the loop | + +### 8. Edge Cases + +1. **Unknown skill name**: If `$foobar` doesn't match any available skill → leave as literal text, no error +2. **Duplicate skills**: If user types `$brainstorming $brainstorming` → deduplicate, load once +3. **Auto + manual overlap**: If AI auto-loads a skill that user also `$`-mentioned → system prompt dedup +4. **Empty `$`**: Typing just `$` shows all available skills in the popover +5. **Context budget**: No hard limit on skill count, but each skill consumes 2-10K tokens. Display warning toast if >5 skills loaded (>50K estimated tokens) + +--- + +## Testing + +### Unit Tests +- `$` regex parsing: single skill, multiple skills, no skills, invalid names +- SkillPart creation and serialization +- System prompt injection with deduplication +- CLI prefix parsing with skill resolution + +### Integration Tests +- UI: type `$`, see popover, select skill, pill appears, submit → skill injected +- CLI: `$brainstorming Fix the login` → skill loaded + clean text sent +- Auto + manual coexistence: AI auto-loads skill A, user `$`-loads skill B → both present + +### E2E Tests +- Full flow: type `$brainstorming Fix the login`, submit, verify SKILL.md appears in system prompt + +--- + +## Cherry-Picks from CEO Review (Session Skills System) + +### 9. Session-Persistent Skills + +Skills loaded via `$` persist for the entire session, not just one message. + +**Backend:** Add `activeSkills: string[]` to session state (or a lightweight `SessionSkills` store keyed by sessionID). When `$brainstorming` is typed: +1. Add `"brainstorming"` to `activeSkills[sessionID]` +2. On every subsequent message in that session, inject the skill into system prompt +3. Cleared on new session or explicit removal + +**Frontend:** `activeSkills` memo reads from sync data (like `steer_queue`). New skills from `$` parts are added to the session's active list. + +### 10. Active Skills Badge Strip + +A row above the editor showing loaded skills, reusing the steer queue badge pattern: + +``` +[brainstorming ×] [tdd ×] ← removable pills +┌─────────────────────────────────────┐ +│ Fix the login flow │ +├─────────────────────────────────────┤ +│ [+] [✨] [↗] [⬆] │ +└─────────────────────────────────────┘ +``` + +- Rendered as ` 0}>` block above context items +- Each badge: skill name + `×` close button +- Clicking `×` removes the skill from `activeSkills[sessionID]` +- Styled similarly to steer queue badges but with `text-syntax-string` color + +### 11. `$-name` Removal Syntax + +Typing `$-brainstorming` removes the skill from the active session skills: + +```typescript +// In $ match handling: +if (name.startsWith("-")) { + const skillToRemove = name.slice(1) + removeActiveSkill(sessionID, skillToRemove) + // Don't create a pill — just remove and clear the input +} +``` + +Also works from CLI: `$-brainstorming` in terminal input removes the skill. + +--- + +## Migration + +None — this is a new feature with no breaking changes. Existing sessions, messages, and skill configurations are unaffected. + +## Rollback + +Remove `$` prefix detection from `handleInput()`. Skill pills in existing messages will render as unknown parts (graceful degradation). Session active skills cleared on rollback. diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 1cc7c578d36c..d3b19773f3db 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1,6 +1,6 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" import { useSpring } from "@opencode-ai/ui/motion-spring" -import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js" +import { createEffect, on, Component, Show, For, Switch, Match, onCleanup, createMemo, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" @@ -24,15 +24,18 @@ import { Icon } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" +import { showToast } from "@opencode-ai/ui/toast" import { Select } from "@opencode-ai/ui/select" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ModelSelectorPopover } from "@/components/dialog-select-model" +import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" import { useCommand } from "@/context/command" import { Persist, persisted } from "@/utils/persist" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { promptEnabled, promptProbe } from "@/testing/prompt" @@ -49,7 +52,7 @@ import { promptLength, } from "./prompt-input/history" import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" -import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" +import { PromptPopover, type AtOption, type SlashCommand, type SkillOption } from "./prompt-input/slash-popover" import { PromptContextItems } from "./prompt-input/context-items" import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" @@ -113,6 +116,7 @@ export const PromptInput: Component = (props) => { const permission = usePermission() const language = useLanguage() const platform = usePlatform() + const server = useServer() const { params, tabs, view } = useSessionLayout() let editorRef!: HTMLDivElement let fileInputRef: HTMLInputElement | undefined @@ -243,6 +247,98 @@ export const PromptInput: Component = (props) => { }, ) const working = createMemo(() => status()?.type !== "idle") + const steerQueue = createMemo(() => sync.data.steer_queue[params.id ?? ""] ?? []) + // Active $skills for this session — reads from sync layer (server-side persisted) + // Used by badge strip above editor and to populate popover + const activeSkills = createMemo(() => sync.data.session_skill[params.id ?? ""] ?? []) + + // CEO expansion: context budget warning toast when >30K estimated tokens. + // Shows a non-blocking toast when too many skills are loaded. + // Token estimate comes from session_skills table (chars÷4 per skill content). + const SKILL_TOKEN_BUDGET = 30000 // ~30K tokens threshold + createEffect(() => { + const skills = activeSkills() + const budget = skills.reduce((sum, s) => sum + (s.token_estimate ?? 0), 0) + if (skills.length > 0 && budget > SKILL_TOKEN_BUDGET) { + showToast({ + title: `⚠️ ${skills.length} skills loaded (~${Math.round(budget / 1000)}K tokens)`, + description: "Consider removing some to leave room for conversation.", + }) + } + }) + + // Helper: call skill API endpoints with proper auth headers. + // Extracted to DRY up badge × click, $-removal, and future call sites. + // (Code review fix #1: DRY violation — 3 identical fetch patterns) + const skillApi = (method: "POST" | "DELETE", name: string) => { + const sessionID = params.id + if (!sessionID) return + const headers: Record = {} + if (server.current?.http?.password) { + headers.Authorization = `Basic ${btoa(`${server.current.http.username ?? "opencode"}:${server.current.http.password}`)}` + } + fetch(`${sdk.url}/session/${sessionID}/skill/${name}`, { method, headers }).catch(() => { + // Silently ignore — badge strip will re-sync from server. + // (Code review note: acceptable for UI removals) + }) + } + + const [steerPending, setSteerPending] = createSignal(false) + const [enhancing, setEnhancing] = createSignal(false) + + const enhancePrompt = async () => { + const textParts = prompt.current().filter((p): p is ContentPart & { type: "text" } => p.type === "text") + const text = textParts.map((p) => p.content).join("").trim() + if (!text || enhancing()) return + setEnhancing(true) + try { + const http = server.current?.http + const headers: Record = { "Content-Type": "application/json" } + if (http?.password) headers["Authorization"] = `Basic ${btoa(`${http.username ?? "opencode"}:${http.password}`)}` + const fetcher = platform.fetch ?? fetch + const res = await fetcher(`${sdk.url}/experimental/enhance`, { + method: "POST", + headers, + body: JSON.stringify({ text }), + }) + if (!res.ok) throw new Error("Enhance failed") + const data = (await res.json()) as { text: string } + if (data.text && data.text !== text) { + const nonText = prompt.current().filter((p) => p.type !== "text") + const enhanced: ContentPart = { type: "text", content: data.text, start: 0, end: data.text.length } + prompt.set([...nonText, enhanced], data.text.length) + if (editorRef) { + editorRef.textContent = data.text + requestAnimationFrame(() => { + const range = document.createRange() + const sel = window.getSelection() + range.selectNodeContents(editorRef) + range.collapse(false) + sel?.removeAllRanges() + sel?.addRange(range) + }) + } + } + } catch { + showToast({ + title: language.t("common.requestFailed") ?? "Failed to enhance prompt", + }) + } finally { + setEnhancing(false) + } + } + + /** Clear text parts after steer/queue, preserving non-text attachments. */ + const clearText = () => { + const kept = prompt.current().filter((p) => p.type !== "text") + if (kept.length > 0) { + prompt.set(kept, 0) + return true + } + prompt.reset() + return false + } + const tip = () => { if (working()) { return ( @@ -265,7 +361,7 @@ export const PromptInput: Component = (props) => { ) const [store, setStore] = createStore<{ - popover: "at" | "slash" | null + popover: "at" | "slash" | "skill" | null historyIndex: number savedPrompt: PromptHistoryEntry | null placeholder: number @@ -571,7 +667,6 @@ export const PromptInput: Component = (props) => { const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) - if (!query.trim()) return [...agents, ...pinned] const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) @@ -651,6 +746,72 @@ export const PromptInput: Component = (props) => { onSelect: handleSlashSelect, }) + // ─── Skill popover: useFilteredList for arrow key + Enter navigation ── + const skillItems = createMemo(() => + sync.data.skill.map((s): SkillOption => ({ type: "skill", name: s.name, description: s.description })), + ) + + const handleSkillSelect = (skill: SkillOption | undefined) => { + if (!skill) return + skillApi("POST", skill.name) + + // Insert Codex-style inline pill into editor — replaces the $query text + // with a styled span[data-type="skill"] pill, matching how @file works. + // Re-focus editor first (clicking popover steals focus). + editorRef.focus() + const cursor = prompt.cursor() ?? promptLength(prompt.current()) + setCursorPosition(editorRef, cursor) + const sel = window.getSelection() + if (sel && sel.rangeCount > 0) { + const raw = prompt.current().map((p) => ("content" in p ? p.content : "")).join("") + const before = raw.substring(0, cursor) + const match = before.match(/\$\S*$/) + const range = sel.getRangeAt(0) + if (match) { + const start = match.index ?? cursor - match[0].length + setRangeEdge(editorRef, range, "start", start) + setRangeEdge(editorRef, range, "end", cursor) + } + // Create skill pill with data-type="skill" for text-syntax-string styling + const pill = document.createElement("span") + const emojis = ["✨", "🔮", "💎", "🌟", "⚡", "🎯", "🪄", "💫", "🌈", "🦋"] + pill.textContent = `${emojis[Math.floor(Math.random() * emojis.length)]} ${skill.name}` + pill.setAttribute("data-type", "skill") + pill.setAttribute("data-name", skill.name) + pill.setAttribute("contenteditable", "false") + pill.style.userSelect = "text" + pill.style.cursor = "default" + const gap = document.createTextNode(" ") + range.deleteContents() + range.insertNode(gap) + range.insertNode(pill) + range.setStartAfter(gap) + range.collapse(true) + sel.removeAllRanges() + sel.addRange(range) + handleInput() + } + + showToast({ + title: `✓ Loaded $${skill.name}`, + description: "Skill added to session context", + }) + closePopover() + } + + const { + flat: skillFlat, + active: skillActive, + setActive: setSkillActive, + onInput: skillOnInput, + onKeyDown: skillOnKeyDown, + } = useFilteredList({ + items: skillItems, + key: (x) => x?.name, + filterKeys: ["name", "description"], + onSelect: handleSkillSelect, + }) + const createPill = (part: FileAttachmentPart | AgentPart) => { const pill = document.createElement("span") pill.textContent = part.content @@ -679,6 +840,7 @@ export const PromptInput: Component = (props) => { const el = node as HTMLElement if (el.dataset.type === "file") return true if (el.dataset.type === "agent") return true + if (el.dataset.type === "skill") return true return el.tagName === "BR" }) @@ -741,6 +903,15 @@ export const PromptInput: Component = (props) => { const active = slashActive() const item = items.find((entry) => entry.id === active) ?? items[0] handleSlashSelect(item) + return + } + + if (store.popover === "skill") { + const items = skillFlat() + if (items.length === 0) return + const active = skillActive() + const item = items.find((entry) => entry.name === active) ?? items[0] + handleSkillSelect(item) } } @@ -826,6 +997,20 @@ export const PromptInput: Component = (props) => { pushAgent(el) return } + // Skill pills (data-type="skill") are treated as agent parts for DOM parsing + if (el.dataset.type === "skill") { + flushText() + const content = el.textContent ?? "" + parts.push({ + type: "agent", + name: el.dataset.name!, + content, + start: position, + end: position + content.length, + }) + position += content.length + return + } if (el.tagName === "BR") { buffer += "\n" return @@ -878,6 +1063,14 @@ export const PromptInput: Component = (props) => { if (!shellMode) { const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) const slashMatch = rawText.match(/^\/(\S*)$/) + // ─── $ prefix detection for skill mentions ────────────────────── + // Matches "$" preceded by whitespace or start-of-line (NOT another "$"). + // Guards (from CEO/Eng/Design reviews): + // - Shell mode: disabled above (shellMode check) + // - $$: negative lookbehind (? = (props) => { } else if (slashMatch) { slashOnInput(slashMatch[1]) setStore("popover", "slash") + } else if (dollarMatch) { + const query = dollarMatch[1] + // $-removal syntax: typing "$-brainstorming" removes a skill from the session. + // Only removes user-added skills (not AI auto-loaded). Per spec §11. + if (query.startsWith("-") && query.length > 1) { + const name = query.slice(1) + const sessionID = params.id + if (sessionID && activeSkills().some((s) => s.name === name)) { + // Remove skill via extracted helper (code review fix #1: DRY) + // $-removal requires exact full name match — intentional per spec §11. + // (Code review fix #2: documented as intentional behavior) + skillApi("DELETE", name) + // Clear the $-removal text from the editor + const cleaned = rawText.replace(/(?:^|\s)\$-\S+\s*$/, "").trim() + if (!cleaned) { + prompt.set(DEFAULT_PROMPT, 0) + clearEditor() + } + closePopover() + } else { + // Unknown skill or no session — show popover for discovery + setStore("popover", "skill") + } + } else { + // Normal $ prefix — open skill popover with filtering + skillOnInput(query) + setStore("popover", "skill") + } } else { closePopover() } @@ -1043,7 +1264,7 @@ export const PromptInput: Component = (props) => { return true } - const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({ + const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({ editor: () => editorRef, isDialogActive: () => !!dialog.active, setDraggingType: (type) => setStore("draggingType", type), @@ -1105,6 +1326,16 @@ export const PromptInput: Component = (props) => { return } + // CEO expansion: Cmd+Shift+S opens skill picker directly + // Design review: only fires when prompt editor is focused (focus guard) + if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.key.toLowerCase() === "s") { + event.preventDefault() + if (store.mode !== "normal") return + // Focus guard: only open if editor has focus (which it does since we're in keyDown) + setStore("popover", "skill") + return + } + if (event.key === "Backspace") { const selection = window.getSelection() if (selection && selection.isCollapsed) { @@ -1203,6 +1434,9 @@ export const PromptInput: Component = (props) => { if (store.popover === "slash") { slashOnKeyDown(event) } + if (store.popover === "skill") { + skillOnKeyDown(event) + } event.preventDefault() return } @@ -1241,20 +1475,6 @@ export const PromptInput: Component = (props) => { // Note: Shift+Enter is handled earlier, before IME check if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault() - if (event.repeat) return - if ( - working() && - prompt - .current() - .map((part) => ("content" in part ? part.content : "")) - .join("") - .trim().length === 0 && - imageAttachments().length === 0 && - commentCount() === 0 - ) { - return - } handleSubmit(event) } } @@ -1275,6 +1495,10 @@ export const PromptInput: Component = (props) => { onSlashSelect={handleSlashSelect} commandKeybind={command.keybind} t={(key) => language.t(key as Parameters[0])} + skillFlat={skillFlat()} + skillActive={skillActive() ?? undefined} + setSkillActive={setSkillActive} + onSkillSelect={handleSkillSelect} /> = (props) => { onRemove={removeAttachment} removeLabel={language.t("prompt.attachment.remove")} /> + {/* ─── Active Skills Badge Strip ───────────────────────────────── + Design spec: row above editor showing loaded skills, reusable + steer queue badge pattern. Each badge: skill name + × close. + Clicking × calls DELETE /session/:id/skill/:name via SDK. + aria-label on × for accessibility (design review). + text-syntax-string color (warm/amber, design review). + Hidden when no active skills (Show when={}). + */} + 0}> +
+ + {(skill) => ( + + {skill.name} + + {sync.data.skill.find((s) => s.name === skill.name)?.description ?? "Skill loaded"} + + + ~{Math.round((skill.token_estimate ?? 0) / 1000)}K tokens + +
+ } + > + + ${skill.name} + + + + )} + + +
+ 0}> +
+
+ + Pending ({steerQueue().length}) +
+ + {(item) => ( +
+ + {item.mode === "steer" ? "steer" : "queue"} + + {item.text} + +
+ )} +
+
+
{ @@ -1317,7 +1619,7 @@ export const PromptInput: Component = (props) => { if (!(target instanceof HTMLElement)) return if ( target.closest( - '[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]', + '[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-steer"], [data-action="prompt-permissions"]', ) ) { return @@ -1354,6 +1656,8 @@ export const PromptInput: Component = (props) => { "w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "[&_[data-type=file]]:text-syntax-property": true, "[&_[data-type=agent]]:text-syntax-type": true, + // Design review: text-syntax-string (warm/amber) for $skill pills + "[&_[data-type=skill]]:text-syntax-string": true, "font-mono!": store.mode === "shell", }} style={{ "padding-bottom": space }} @@ -1379,64 +1683,162 @@ export const PromptInput: Component = (props) => { }} /> -
- { - const list = e.currentTarget.files - if (list) void addAttachments(Array.from(list)) - e.currentTarget.value = "" - }} - /> - -
- - - -
-
+ { + const file = e.currentTarget.files?.[0] + if (file) void addAttachment(file) + e.currentTarget.value = "" + }} + /> -
-
0.5 ? "auto" : "none", - }} - > + {/* Action bar — Codex/Kilo Code style: below editor, not floating */} +
+ {/* Left: attach + enhance */} +
- + /> + + + + + + +
+ + {/* Right: steer + submit */} +
+ + + Steer + ⇧⏎ +
+ } + > + { + const sessionID = params.id + if (!sessionID || steerPending()) return + const text = prompt + .current() + .filter((p) => p.type === "text") + .map((p) => p.content) + .join("") + .trim() + if (!text) return + setSteerPending(true) + sdk.client.session + .steer({ sessionID, text, mode: "steer" }) + .then((res) => { + if (res.error) throw new Error("Failed to steer") + const kept = clearText() + showToast({ + title: language.t("prompt.action.steered") ?? "Steering", + description: kept + ? (language.t("prompt.action.attachmentsKept") ?? "Text steered — attachments still attached") + : (language.t("prompt.action.steered.description") ?? "Will be injected at the next step"), + }) + const editor = editorRef + if (editor) editor.innerHTML = "" + }) + .catch((err) => + showToast({ + title: language.t("common.requestFailed") ?? "Failed to steer", + description: err?.message, + }), + ) + .finally(() => setSteerPending(false)) + }} + aria-label="Steer" + /> + + + + +
+ Queue + +
+
+ +
+ {language.t("prompt.action.stop")} + {language.t("common.key.esc")} +
+
+ +
+ {language.t("prompt.action.send")} + +
+
+ + } + > + +
@@ -1493,15 +1895,11 @@ export const PromptInput: Component = (props) => { size="normal" class="min-w-0 max-w-[320px] text-13-regular text-text-base group" style={control()} - onClick={() => { - void import("@/components/dialog-select-model-unpaid").then((x) => { - dialog.show(() => ) - }) - }} + onClick={() => dialog.show(() => )} > @@ -1533,7 +1931,7 @@ export const PromptInput: Component = (props) => { > diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index 65eb01c797b8..04e00d09424b 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -1,3 +1,19 @@ +// PromptPopover — unified popover for @mentions, /commands, and $skills. +// +// Three modes: +// "at" → file/agent picker (@ prefix) +// "slash" → command picker (/ prefix) +// "skill" → skill picker ($ prefix) — NEW from $skill feature +// +// Design specs (from /plan-design-review, 2026-03-19): +// - Popover layout: Search + Recent + All sections +// - Max-width: 400px +// - Skeleton shimmer (3 rows) on first load +// - Empty state: "💡 No skills installed" + explanation + link +// - text-syntax-string color for skill items +// - role="listbox" + aria-label on popover +// - See: docs/designs/dollar-skill-mentions.md + import { Component, For, Match, Show, Switch } from "solid-js" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" @@ -17,8 +33,18 @@ export interface SlashCommand { source?: "command" | "mcp" | "skill" } +// SkillOption — represents a skill available in the $ popover. +// Each skill has a name and description (first line of SKILL.md frontmatter). +// The "recent" flag marks skills from the "Recent" section at the top. +export type SkillOption = { + type: "skill" + name: string + description: string + recent?: boolean +} + type PromptPopoverProps = { - popover: "at" | "slash" | null + popover: "at" | "slash" | "skill" | null setSlashPopoverRef: (el: HTMLDivElement) => void atFlat: AtOption[] atActive?: string @@ -31,6 +57,12 @@ type PromptPopoverProps = { onSlashSelect: (item: SlashCommand) => void commandKeybind: (id: string) => string | undefined t: (key: string) => string + // Skill popover props (optional — only needed when skill mode is available) + skillFlat?: SkillOption[] + skillActive?: string + onSkillSelect?: (item: SkillOption) => void + setSkillActive?: (id: string) => void + skillLoading?: boolean } export const PromptPopover: Component = (props) => { @@ -43,6 +75,13 @@ export const PromptPopover: Component = (props) => { class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10 overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]" + classList={{ + // Design review: max-width 400px for skill popover + "max-w-[400px]": props.popover === "skill", + }} + // Design review: accessibility — role="listbox" for skill popover + role={props.popover === "skill" ? "listbox" : undefined} + aria-label={props.popover === "skill" ? "Select a skill" : undefined} onMouseDown={(e) => e.preventDefault()} > @@ -134,6 +173,75 @@ export const PromptPopover: Component = (props) => { + + {/* ─── Skill Popover ─────────────────────────────────────────── + Design specs from /plan-design-review: + - Skeleton shimmer (3 rows) while loading + - Empty state: "💡 No skills installed" + explanation + browse link + - Recent section header (text-color-dimmed) + All section header + - text-syntax-string color for skill names + - role="option" on each item for a11y + */} + + +
+
+
+
+ } + > + 0} + fallback={ + // Design review: empty state UX with warmth and explanation +
+ 💡 No skills installed + + Skills customize how the AI works — add them to ~/.claude/skills/ + + + 📚 Browse available skills + +
+ } + > + + {(skill) => ( + + )} + +
+ +
diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index cf104ad97fc4..8da328f11cfc 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -7,7 +7,6 @@ import type { ProviderAuthResponse, ProviderListResponse, QuestionRequest, - Session, Todo, } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" @@ -16,7 +15,7 @@ import { retry } from "@opencode-ai/util/retry" import { batch } from "solid-js" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State, VcsCache } from "./types" -import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" +import { cmp, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" type GlobalStore = { @@ -32,110 +31,73 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } -function waitForPaint() { - return new Promise((resolve) => { - let done = false - const finish = () => { - if (done) return - done = true - resolve() - } - const timer = setTimeout(finish, 50) - if (typeof requestAnimationFrame !== "function") return - requestAnimationFrame(() => { - setTimeout(() => { - clearTimeout(timer) - finish() - }, 0) - }) - }) -} - -function errors(list: PromiseSettledResult[]) { - return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason) -} - -const providerRev = new Map() - -export function clearProviderRev(directory: string) { - providerRev.delete(directory) -} - -function runAll(list: Array<() => Promise>) { - return Promise.allSettled(list.map((item) => item())) -} - -function showErrors(input: { - errors: unknown[] - title: string - translate: (key: string, vars?: Record) => string - formatMoreCount: (count: number) => string -}) { - if (input.errors.length === 0) return - const message = formatServerError(input.errors[0], input.translate) - const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : "" - showToast({ - variant: "error", - title: input.title, - description: message + more, - }) -} - export async function bootstrapGlobal(input: { globalSDK: OpencodeClient + connectErrorTitle: string + connectErrorDescription: string requestFailedTitle: string translate: (key: string, vars?: Record) => string formatMoreCount: (count: number) => string setGlobalStore: SetStoreFunction }) { - const fast = [ - () => - retry(() => - input.globalSDK.global.config.get().then((x) => { - input.setGlobalStore("config", x.data!) - }), - ), - () => - retry(() => - input.globalSDK.provider.list().then((x) => { - input.setGlobalStore("provider", normalizeProviderList(x.data!)) - }), - ), - ] + const health = await input.globalSDK.global + .health() + .then((x) => x.data) + .catch(() => undefined) + if (!health?.healthy) { + showToast({ + variant: "error", + title: input.connectErrorTitle, + description: input.connectErrorDescription, + }) + input.setGlobalStore("ready", true) + return + } - const slow = [ - () => - retry(() => - input.globalSDK.path.get().then((x) => { - input.setGlobalStore("path", x.data!) - }), - ), - () => - retry(() => - input.globalSDK.project.list().then((x) => { - const projects = (x.data ?? []) - .filter((p) => !!p?.id) - .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) - .slice() - .sort((a, b) => cmp(a.id, b.id)) - input.setGlobalStore("project", projects) - }), - ), + const tasks = [ + retry(() => + input.globalSDK.path.get().then((x) => { + input.setGlobalStore("path", x.data!) + }), + ), + retry(() => + input.globalSDK.global.config.get().then((x) => { + input.setGlobalStore("config", x.data!) + }), + ), + retry(() => + input.globalSDK.project.list().then((x) => { + const projects = (x.data ?? []) + .filter((p) => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .slice() + .sort((a, b) => cmp(a.id, b.id)) + input.setGlobalStore("project", projects) + }), + ), + retry(() => + input.globalSDK.provider.list().then((x) => { + input.setGlobalStore("provider", normalizeProviderList(x.data!)) + }), + ), + retry(() => + input.globalSDK.provider.auth().then((x) => { + input.setGlobalStore("provider_auth", x.data ?? {}) + }), + ), ] - showErrors({ - errors: errors(await runAll(fast)), - title: input.requestFailedTitle, - translate: input.translate, - formatMoreCount: input.formatMoreCount, - }) - await waitForPaint() - showErrors({ - errors: errors(await runAll(slow)), - title: input.requestFailedTitle, - translate: input.translate, - formatMoreCount: input.formatMoreCount, - }) + const results = await Promise.allSettled(tasks) + const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason) + if (errors.length) { + const message = formatServerError(errors[0], input.translate) + const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : "" + showToast({ + variant: "error", + title: input.requestFailedTitle, + description: message + more, + }) + } input.setGlobalStore("ready", true) } @@ -149,44 +111,6 @@ function groupBySession(input: T[]) }, {}) } -function projectID(directory: string, projects: Project[]) { - return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id -} - -function mergeSession(setStore: SetStoreFunction, session: Session) { - setStore("session", (list) => { - const next = list.slice() - const idx = next.findIndex((item) => item.id >= session.id) - if (idx === -1) return [...next, session] - if (next[idx]?.id === session.id) { - next[idx] = session - return next - } - next.splice(idx, 0, session) - return next - }) -} - -function warmSessions(input: { - ids: string[] - store: Store - setStore: SetStoreFunction - sdk: OpencodeClient -}) { - const known = new Set(input.store.session.map((item) => item.id)) - const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id)) - if (ids.length === 0) return Promise.resolve() - return Promise.all( - ids.map((sessionID) => - retry(() => input.sdk.session.get({ sessionID })).then((x) => { - const session = x.data - if (!session?.id) return - mergeSession(input.setStore, session) - }), - ), - ).then(() => undefined) -} - export async function bootstrapDirectory(input: { directory: string sdk: OpencodeClient @@ -195,166 +119,95 @@ export async function bootstrapDirectory(input: { vcsCache: VcsCache loadSessions: (directory: string) => Promise | void translate: (key: string, vars?: Record) => string - global: { - config: Config - path: Path - project: Project[] - provider: ProviderListResponse - } }) { - const loading = input.store.status !== "complete" - const seededProject = projectID(input.directory, input.global.project) - const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined - if (seededProject) input.setStore("project", seededProject) - if (seededPath) input.setStore("path", seededPath) - if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) { - input.setStore("provider", input.global.provider) - } - if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { - input.setStore("config", input.global.config) - } - if (loading || input.store.provider.all.length === 0) { - input.setStore("provider_ready", false) - } - input.setStore("mcp_ready", false) - input.setStore("mcp", {}) - input.setStore("lsp_ready", false) - input.setStore("lsp", []) - if (loading) input.setStore("status", "partial") - - const fast = [ - () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))), - () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), - () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), - ] + if (input.store.status !== "complete") input.setStore("status", "loading") - const slow = [ - () => - seededProject - ? Promise.resolve() - : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), - () => - seededPath - ? Promise.resolve() - : retry(() => - input.sdk.path.get().then((x) => { - input.setStore("path", x.data!) - const next = projectID(x.data?.directory ?? input.directory, input.global.project) - if (next) input.setStore("project", next) - }), - ), - () => - retry(() => - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs - input.setStore("vcs", next) - if (next?.branch) input.vcsCache.setStore("value", next) - }), - ), - () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), - () => - retry(() => - input.sdk.permission.list().then((x) => { - const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id) - const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), - ) - return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() => - batch(() => { - for (const sessionID of Object.keys(input.store.permission)) { - if (grouped[sessionID]) continue - input.setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - input.setStore( - "permission", - sessionID, - reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }), - ) - }), - ), - () => - retry(() => - input.sdk.question.list().then((x) => { - const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id) - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) - return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() => - batch(() => { - for (const sessionID of Object.keys(input.store.question)) { - if (grouped[sessionID]) continue - input.setStore("question", sessionID, []) - } - for (const [sessionID, questions] of Object.entries(grouped)) { - input.setStore( - "question", - sessionID, - reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }), - ) - }), - ), - () => Promise.resolve(input.loadSessions(input.directory)), - () => - retry(() => - input.sdk.mcp.status().then((x) => { - input.setStore("mcp", x.data!) - input.setStore("mcp_ready", true) - }), - ), - ] - - const errs = errors(await runAll(fast)) - if (errs.length > 0) { - console.error("Failed to bootstrap instance", errs[0]) - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(errs[0], input.translate), - }) + const blockingRequests = { + project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)), + provider: () => + input.sdk.provider.list().then((x) => { + input.setStore("provider", normalizeProviderList(x.data!)) + }), + agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])), + config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)), } - await waitForPaint() - const slowErrs = errors(await runAll(slow)) - if (slowErrs.length > 0) { - console.error("Failed to finish bootstrap instance", slowErrs[0]) + try { + await Promise.all(Object.values(blockingRequests).map((p) => retry(p))) + } catch (err) { + console.error("Failed to bootstrap instance", err) const project = getFilename(input.directory) showToast({ variant: "error", title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(slowErrs[0], input.translate), + description: formatServerError(err, input.translate), }) + input.setStore("status", "partial") + return } - if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete") - - const rev = (providerRev.get(input.directory) ?? 0) + 1 - providerRev.set(input.directory, rev) - void retry(() => input.sdk.provider.list()) - .then((x) => { - if (providerRev.get(input.directory) !== rev) return - input.setStore("provider", normalizeProviderList(x.data!)) - input.setStore("provider_ready", true) - }) - .catch((err) => { - if (providerRev.get(input.directory) !== rev) return - console.error("Failed to refresh provider list", err) - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), + if (input.store.status !== "complete") input.setStore("status", "partial") + + Promise.all([ + input.sdk.path.get().then((x) => input.setStore("path", x.data!)), + input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])), + // Load available skills for $ popover (non-blocking — popover shows empty state until loaded) + // Code review fix: sdk.app.skills() may not exist if SDK wasn't regenerated with /skill endpoint. + // Fallback to raw fetch if method doesn't exist. .catch() ensures graceful degradation. + (typeof input.sdk.app.skills === "function" + ? input.sdk.app.skills().then((x) => input.setStore("skill", x.data ?? [])) + : fetch(`/skill`).then((r) => r.json()).then((data) => input.setStore("skill", data ?? [])) + ).catch(() => {}), + input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)), + input.loadSessions(input.directory), + input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)), + input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)), + input.sdk.vcs.get().then((x) => { + const next = x.data ?? input.store.vcs + input.setStore("vcs", next) + if (next?.branch) input.vcsCache.setStore("value", next) + }), + input.sdk.permission.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + batch(() => { + for (const sessionID of Object.keys(input.store.permission)) { + if (grouped[sessionID]) continue + input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + input.setStore( + "permission", + sessionID, + reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } }) - }) + }), + input.sdk.question.list().then((x) => { + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + batch(() => { + for (const sessionID of Object.keys(input.store.question)) { + if (grouped[sessionID]) continue + input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + input.setStore( + "question", + sessionID, + reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ]).then(() => { + input.setStore("status", "complete") + }) } diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 5678491f8973..3ac9a400b1b4 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -1,4 +1,4 @@ -import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" +import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" import type { VcsInfo } from "@opencode-ai/sdk/v2/client" @@ -132,7 +132,8 @@ export function createChildStoreManager(input: { ) if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed")) const vcsStore = vcs[0] - vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) + const vcsReady = vcs[3] + vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady }) const meta = runWithOwner(input.owner, () => persisted( @@ -154,29 +155,27 @@ export function createChildStoreManager(input: { const init = () => createRoot((dispose) => { - const initialMeta = meta[0].value - const initialIcon = icon[0].value const child = createStore({ project: "", - projectMeta: initialMeta, - icon: initialIcon, - provider_ready: false, + projectMeta: meta[0].value, + icon: icon[0].value, provider: { all: [], connected: [], default: {} }, config: {}, path: { state: "", config: "", worktree: "", directory: "", home: "" }, status: "loading" as const, agent: [], + skill: [], command: [], session: [], sessionTotal: 0, session_status: {}, + steer_queue: {}, + session_skill: {}, session_diff: {}, todo: {}, permission: {}, question: {}, - mcp_ready: false, mcp: {}, - lsp_ready: false, lsp: [], vcs: vcsStore.value, limit: 5, @@ -186,27 +185,16 @@ export function createChildStoreManager(input: { children[directory] = child disposers.set(directory, dispose) - const onPersistedInit = (init: Promise | string | null, run: () => void) => { - if (!(init instanceof Promise)) return - void init.then(() => { - if (children[directory] !== child) return - run() - }) - } - - onPersistedInit(vcs[2], () => { + createEffect(() => { + if (!vcsReady()) return const cached = vcsStore.value if (!cached?.branch) return child[1]("vcs", (value) => value ?? cached) }) - - onPersistedInit(meta[2], () => { - if (child[0].projectMeta !== initialMeta) return + createEffect(() => { child[1]("projectMeta", meta[0].value) }) - - onPersistedInit(icon[2], () => { - if (child[0].icon !== initialIcon) return + createEffect(() => { child[1]("icon", icon[0].value) }) }) @@ -229,15 +217,6 @@ export function createChildStoreManager(input: { return childStore } - function peek(directory: string, options: ChildOptions = {}) { - const childStore = ensureChild(directory) - const shouldBootstrap = options.bootstrap ?? true - if (shouldBootstrap && childStore[0].status === "loading") { - input.onBootstrap(directory) - } - return childStore - } - function projectMeta(directory: string, patch: ProjectMeta) { const [store, setStore] = ensureChild(directory) const cached = metaCache.get(directory) @@ -268,7 +247,6 @@ export function createChildStoreManager(input: { children, ensureChild, child, - peek, projectMeta, projectIcon, mark, diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 5d8b7c4e3d8e..2eced95762c2 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -15,8 +15,6 @@ import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" -const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) - export function applyGlobalEvent(input: { event: { type: string; properties?: unknown } project: Project[] @@ -176,6 +174,17 @@ export function applyDirectoryEvent(input: { input.setStore("session_status", props.sessionID, reconcile(props.status)) break } + case "session.queue.changed": { + const props = event.properties as { sessionID: string; queue: { id: string; text: string; time: number; mode: "queue" | "steer" }[] } + input.setStore("steer_queue", props.sessionID, reconcile(props.queue, { key: "id" })) + break + } + // $skill: sync active skills per session from SessionSkills.Event.Changed + case "session.skill.changed": { + const props = event.properties as { sessionID: string; skills: { name: string; added_at: number; token_estimate: number | null }[] } + input.setStore("session_skill", props.sessionID, reconcile(props.skills, { key: "name" })) + break + } case "message.updated": { const info = (event.properties as { info: Message }).info const messages = input.store.message[info.sessionID] @@ -213,7 +222,6 @@ export function applyDirectoryEvent(input: { } case "message.part.updated": { const part = (event.properties as { part: Part }).part - if (SKIP_PARTS.has(part.type)) break const parts = input.store.part[part.messageID] if (!parts) { input.setStore("part", part.messageID, [part]) diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 0177ebbe1388..06b62704e141 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -18,6 +18,8 @@ type SessionCache = { part: Record permission: Record question: Record + steer_queue?: Record + session_skill?: Record } export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable) { @@ -37,6 +39,8 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable { + // ── Basic skill detection ────────────────────────────────── + + test("extracts single $skill from input", () => { + const result = parseSkills("$brainstorming fix the bug") + expect(result.skills).toEqual([{ type: "skill", name: "brainstorming" }]) + expect(result.removals).toEqual([]) + expect(result.text).toBe("fix the bug") + }) + + test("extracts multiple $skills from input", () => { + const result = parseSkills("$tdd $security review this code") + expect(result.skills).toEqual([ + { type: "skill", name: "tdd" }, + { type: "skill", name: "security" }, + ]) + expect(result.text).toBe("review this code") + }) + + test("extracts $skill at end of input", () => { + const result = parseSkills("fix the bug $brainstorming") + expect(result.skills).toEqual([{ type: "skill", name: "brainstorming" }]) + expect(result.text).toBe("fix the bug") + }) + + test("extracts $skill in middle of input", () => { + const result = parseSkills("fix $brainstorming the bug") + expect(result.skills).toEqual([{ type: "skill", name: "brainstorming" }]) + expect(result.text).toBe("fix the bug") + }) + + // ── $-removal syntax ────────────────────────────────────── + + test("extracts $-name as removal", () => { + const result = parseSkills("$-brainstorming fix the bug") + expect(result.skills).toEqual([]) + expect(result.removals).toEqual(["brainstorming"]) + expect(result.text).toBe("fix the bug") + }) + + test("handles mixed skills and removals", () => { + const result = parseSkills("$tdd $-security review this") + expect(result.skills).toEqual([{ type: "skill", name: "tdd" }]) + expect(result.removals).toEqual(["security"]) + expect(result.text).toBe("review this") + }) + + // ── Guard: $$ not matched (escape hatch) ────────────────── + + test("does not match $$ prefix", () => { + const result = parseSkills("$$brainstorming fix the bug") + expect(result.skills).toEqual([]) + expect(result.removals).toEqual([]) + expect(result.text).toBe("$$brainstorming fix the bug") + }) + + // ── Guard: $ mid-word not matched ───────────────────────── + + test("does not match $ in middle of word", () => { + const result = parseSkills("cost$brainstorming fix the bug") + expect(result.skills).toEqual([]) + expect(result.removals).toEqual([]) + expect(result.text).toBe("cost$brainstorming fix the bug") + }) + + // ── Guard: shell mode ($VAR patterns) ───────────────────── + + test("matches $HOME (uppercase treated as skill name)", () => { + // Uppercase names are valid skill names per the regex + const result = parseSkills("$HOME/path fix") + // Note: /path is not part of the skill name ([\w-] stops at /) + expect(result.skills).toEqual([{ type: "skill", name: "HOME" }]) + expect(result.text).toBe("/path fix") + }) + + // ── Edge: no skills ─────────────────────────────────────── + + test("returns original text when no $skills present", () => { + const result = parseSkills("just a normal prompt") + expect(result.skills).toEqual([]) + expect(result.removals).toEqual([]) + expect(result.text).toBe("just a normal prompt") + }) + + test("returns original text for empty input", () => { + const result = parseSkills("") + expect(result.skills).toEqual([]) + expect(result.removals).toEqual([]) + expect(result.text).toBe("") + }) + + // ── Edge: only skill, no remaining text ─────────────────── + + test("handles skill-only input with no text", () => { + const result = parseSkills("$brainstorming") + expect(result.skills).toEqual([{ type: "skill", name: "brainstorming" }]) + expect(result.text).toBe("") + }) + + // ── Edge: hyphenated skill names ────────────────────────── + + test("handles hyphenated skill names", () => { + const result = parseSkills("$clean-code fix this") + expect(result.skills).toEqual([{ type: "skill", name: "clean-code" }]) + expect(result.text).toBe("fix this") + }) + + // ── Edge: underscored skill names ───────────────────────── + + test("handles underscored skill names", () => { + const result = parseSkills("$my_skill fix this") + expect(result.skills).toEqual([{ type: "skill", name: "my_skill" }]) + expect(result.text).toBe("fix this") + }) + + // ── Edge: whitespace collapsing ─────────────────────────── + + test("collapses extra whitespace after stripping", () => { + const result = parseSkills("fix $brainstorming the bug") + expect(result.skills).toEqual([{ type: "skill", name: "brainstorming" }]) + expect(result.text).toBe("fix the bug") + }) + + // ── Consecutive calls (regex lastIndex reset) ───────────── + + test("works correctly on consecutive calls", () => { + const r1 = parseSkills("$tdd fix") + const r2 = parseSkills("$security review") + expect(r1.skills).toEqual([{ type: "skill", name: "tdd" }]) + expect(r2.skills).toEqual([{ type: "skill", name: "security" }]) + }) + + // ── $ after newline (multiline input) ───────────────────── + + test("extracts $skill after newline", () => { + const result = parseSkills("fix the bug\n$brainstorming more context") + expect(result.skills).toEqual([{ type: "skill", name: "brainstorming" }]) + // \n before $token is stripped, whitespace collapsed to single space + expect(result.text).toBe("fix the bug more context") + }) + + // ── $ must start with letter ────────────────────────────── + + test("does not match $ followed by number", () => { + const result = parseSkills("$123 fix the bug") + expect(result.skills).toEqual([]) + expect(result.removals).toEqual([]) + expect(result.text).toBe("$123 fix the bug") + }) +}) diff --git a/packages/app/src/util/skill-parse.ts b/packages/app/src/util/skill-parse.ts new file mode 100644 index 000000000000..58f173f684a2 --- /dev/null +++ b/packages/app/src/util/skill-parse.ts @@ -0,0 +1,38 @@ +// ─── $skill mention parsing ───────────────────────────────── +// Extracts $name and $-name tokens from raw input text. +// Guards: $ must be at start-of-line or after whitespace (not $$). +// $name → skill addition +// $-name → skill removal + +export interface SkillParseResult { + skills: Array<{ type: "skill"; name: string }> + removals: string[] + text: string +} + +const SKILL_PATTERN = /(?:^|(?<=\s))\$(-?[a-zA-Z][\w-]*)/g +const SKILL_STRIP = /(?:^|(?<=\s))\$-?[a-zA-Z][\w-]*/g + +export function parseSkills(input: string): SkillParseResult { + const skills: Array<{ type: "skill"; name: string }> = [] + const removals: string[] = [] + let m + while ((m = SKILL_PATTERN.exec(input)) !== null) { + const token = m[1] + if (token.startsWith("-")) removals.push(token.slice(1)) + else skills.push({ type: "skill" as const, name: token }) + } + // Reset lastIndex for reuse (global regex) + SKILL_PATTERN.lastIndex = 0 + + let text = input + if (skills.length > 0 || removals.length > 0) { + text = input + .replace(SKILL_STRIP, "") + .replace(/\s{2,}/g, " ") + .trim() + SKILL_STRIP.lastIndex = 0 + } + + return { skills, removals, text } +} diff --git a/packages/opencode/migration/20260319111502_skill/migration.sql b/packages/opencode/migration/20260319111502_skill/migration.sql new file mode 100644 index 000000000000..21b8b8482568 --- /dev/null +++ b/packages/opencode/migration/20260319111502_skill/migration.sql @@ -0,0 +1,10 @@ +CREATE TABLE `session_skill` ( + `session_id` text NOT NULL, + `skill_name` text NOT NULL, + `added_at` integer NOT NULL, + `token_estimate` integer, + CONSTRAINT `session_skill_pk` PRIMARY KEY(`session_id`, `skill_name`), + CONSTRAINT `fk_session_skill_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE INDEX `session_skill_session_idx` ON `session_skill` (`session_id`); \ No newline at end of file diff --git a/packages/opencode/migration/20260319111502_skill/snapshot.json b/packages/opencode/migration/20260319111502_skill/snapshot.json new file mode 100644 index 000000000000..32e406cd026f --- /dev/null +++ b/packages/opencode/migration/20260319111502_skill/snapshot.json @@ -0,0 +1,1309 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "897d9320-c995-4eae-ba60-770e20cd4f10", + "prevIds": [ + "37e1554d-af4c-43f2-aa7c-307fb49a315e" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_skill", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_skill" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "skill_name", + "entityType": "columns", + "table": "session_skill" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "added_at", + "entityType": "columns", + "table": "session_skill" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_estimate", + "entityType": "columns", + "table": "session_skill" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_skill_session_id_session_id_fk", + "entityType": "fks", + "table": "session_skill" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "skill_name" + ], + "nameExplicit": false, + "name": "session_skill_pk", + "entityType": "pks", + "table": "session_skill" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_skill_session_idx", + "entityType": "indexes", + "table": "session_skill" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 29e09f64c74f..0636ae793595 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,11 +1,11 @@ -import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core" +import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core" import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import path from "path" import { Filesystem } from "@/util/filesystem" import { useLocal } from "@tui/context/local" import { useTheme } from "@tui/context/theme" -import { EmptyBorder, SplitBorder } from "@tui/component/border" +import { EmptyBorder } from "@tui/component/border" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" @@ -13,16 +13,15 @@ import { MessageID, PartID } from "@/session/schema" import { createStore, produce } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" import { usePromptHistory, type PromptInfo } from "./history" -import { assign } from "./part" import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useCommandDialog } from "../dialog-command" -import { useKeyboard, useRenderer } from "@opentui/solid" +import { useRenderer } from "@opentui/solid" import { Editor } from "@tui/util/editor" import { useExit } from "../../context/exit" import { Clipboard } from "../../util/clipboard" -import type { AssistantMessage, FilePart } from "@opencode-ai/sdk/v2" +import type { FilePart } from "@opencode-ai/sdk/v2" import { TuiEvent } from "../../event" import { iife } from "@/util/iife" import { Locale } from "@/util/locale" @@ -59,10 +58,6 @@ export type PromptRef = { const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"] const SHELL_PLACEHOLDERS = ["ls -la", "git status", "pwd"] -const money = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", -}) export function Prompt(props: PromptProps) { let input: TextareaRenderable @@ -126,25 +121,6 @@ export function Prompt(props: PromptProps) { return messages.findLast((m) => m.role === "user") }) - const usage = createMemo(() => { - if (!props.sessionID) return - const msg = sync.data.message[props.sessionID] ?? [] - const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) - if (!last) return - - const tokens = - last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write - if (tokens <= 0) return - - const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] - const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined - const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0) - return { - context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), - cost: cost > 0 ? money.format(cost) : undefined, - } - }) - const [store, setStore] = createStore<{ prompt: PromptInfo mode: "normal" | "shell" @@ -379,20 +355,6 @@ export function Prompt(props: PromptProps) { ] }) - // Windows Terminal 1.25+ handles Ctrl+V on keydown when kitty events are - // enabled, but still reports the kitty key-release event. Probe on release. - if (process.platform === "win32") { - useKeyboard( - (evt) => { - if (!input.focused) return - if (evt.name === "v" && evt.ctrl && evt.eventType === "release") { - command.trigger("prompt.paste") - } - }, - { release: true }, - ) - } - const ref: PromptRef = { get focused() { return input.focused @@ -667,6 +629,48 @@ export function Prompt(props: PromptProps) { })), }) } else { + // ─── $skill mention parsing ───────────────────────────────── + // Parse $name tokens from input text before creating prompt parts. + // Guards: $ must be at start-of-line or after whitespace (not $$). + // $name → adds skill to session (SkillPart in parts array) + // $-name → removes skill from session (DELETE API call) + const skillPattern = /(?:^|(?<=\s))\$(-?[a-zA-Z][\w-]*)/g + const skills: Array<{ type: "skill"; name: string }> = [] + const removals: string[] = [] + let m + while ((m = skillPattern.exec(inputText)) !== null) { + const token = m[1] + if (token.startsWith("-")) removals.push(token.slice(1)) + else skills.push({ type: "skill" as const, name: token }) + } + + // Process $-removal requests via DELETE /session/:id/skill + if (sessionID) { + for (const name of removals) { + fetch(`${sdk.url}/session/${sessionID}/skill`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }).catch(() => { + toast.show({ + variant: "error", + message: `Failed to remove skill: ${name}`, + duration: 3000, + }) + }) + } + } + + // Strip $skill tokens from text, collapse whitespace + // Reuse same pattern shape (without capture group) for stripping + let text = inputText + if (skills.length > 0 || removals.length > 0) { + text = inputText + .replace(/(?:^|(?<=\s))\$-?[a-zA-Z][\w-]*/g, "") + .replace(/\s{2,}/g, " ") + .trim() + } + sdk.client.session .prompt({ sessionID, @@ -679,9 +683,16 @@ export function Prompt(props: PromptProps) { { id: PartID.ascending(), type: "text", - text: inputText, + text, }, - ...nonTextParts.map(assign), + ...skills.map((x) => ({ + id: PartID.ascending(), + ...x, + })), + ...nonTextParts.map((x) => ({ + id: PartID.ascending(), + ...x, + })), ], }) .catch(() => {}) @@ -856,7 +867,8 @@ export function Prompt(props: PromptProps) { border={["left"]} borderColor={highlight()} customBorderChars={{ - ...SplitBorder.customBorderChars, + ...EmptyBorder, + vertical: "┃", bottomLeft: "╹", }} > @@ -886,9 +898,10 @@ export function Prompt(props: PromptProps) { e.preventDefault() return } - // Check clipboard for images before terminal-handled paste runs. - // This helps terminals that forward Ctrl+V to the app; Windows - // Terminal 1.25+ usually handles Ctrl+V before this path. + // Handle clipboard paste (Ctrl+V) - check for images first on Windows + // This is needed because Windows terminal doesn't properly send image data + // through bracketed paste, so we need to intercept the keypress and + // directly read from clipboard before the terminal handles it if (keybind.match("input_paste", e)) { const content = await Clipboard.read() if (content?.mime.startsWith("image/")) { @@ -969,11 +982,8 @@ export function Prompt(props: PromptProps) { // Normalize line endings at the boundary // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste // Replace CRLF first, then any remaining CR - const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n") + const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") const pastedContent = normalizedText.trim() - - // Windows Terminal <1.25 can surface image-only clipboard as an - // empty bracketed paste. Windows Terminal 1.25+ does not. if (!pastedContent) { command.trigger("prompt.paste") return @@ -1180,20 +1190,14 @@ export function Prompt(props: PromptProps) { - - - {(item) => ( - - {[item().context, item().cost].filter(Boolean).join(" · ")} - - )} - - - - {keybind.print("agent_cycle")} agents - - - + 0}> + + {keybind.print("variant_cycle")} variants + + + + {keybind.print("agent_cycle")} agents + {keybind.print("command_list")} commands diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index d499e5a1ecf4..2add22988b51 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -14,13 +14,13 @@ import { Todo } from "../../session/todo" import { Agent } from "../../agent/agent" import { Snapshot } from "@/snapshot" import { Log } from "../../util/log" -import { Permission } from "@/permission" +import { PermissionNext } from "@/permission" import { PermissionID } from "@/permission/schema" import { ModelID, ProviderID } from "@/provider/schema" +import { SessionSteer } from "@/session/steer" +import { SessionSkills } from "@/session/skill.service" import { errors } from "../error" import { lazy } from "../../util/lazy" -import { Bus } from "../../bus" -import { NamedError } from "@opencode-ai/util/error" const log = Log.create({ service: "server" }) @@ -90,8 +90,8 @@ export const SessionRoutes = lazy(() => }, }), async (c) => { - const result = await SessionStatus.list() - return c.json(Object.fromEntries(result)) + const result = SessionStatus.list() + return c.json(result) }, ) .get( @@ -281,14 +281,14 @@ export const SessionRoutes = lazy(() => const sessionID = c.req.valid("param").sessionID const updates = c.req.valid("json") + let session = await Session.get(sessionID) if (updates.title !== undefined) { - await Session.setTitle({ sessionID, title: updates.title }) + session = await Session.setTitle({ sessionID, title: updates.title }) } if (updates.time?.archived !== undefined) { - await Session.setArchived({ sessionID, time: updates.time.archived }) + session = await Session.setArchived({ sessionID, time: updates.time.archived }) } - const session = await Session.get(sessionID) return c.json(session) }, ) @@ -848,13 +848,7 @@ export const SessionRoutes = lazy(() => return stream(c, async () => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - SessionPrompt.prompt({ ...body, sessionID }).catch((err) => { - log.error("prompt_async failed", { sessionID, error: err }) - Bus.publish(Session.Event.Error, { - sessionID, - error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), - }) - }) + SessionPrompt.prompt({ ...body, sessionID }) }) }, ) @@ -992,6 +986,254 @@ export const SessionRoutes = lazy(() => return c.json(session) }, ) + .post( + "/:sessionID/steer", + describeRoute({ + summary: "Steer session", + description: + "Push a message into the session's pending input buffer. If the session is busy, the message will be injected at the next agentic loop boundary. If idle, it is queued for the next turn.", + operationId: "session.steer", + responses: { + 200: { + description: "Queued message", + content: { + "application/json": { + schema: resolver( + z.object({ + id: z.string(), + text: z.string(), + time: z.number(), + mode: z.enum(["queue", "steer"]), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + }), + ), + validator( + "json", + z.object({ + text: z.string().min(1).meta({ description: "The message text to inject" }), + mode: z.enum(["queue", "steer"]).optional().default("queue").meta({ description: "queue waits for turn end, steer injects mid-turn" }), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID as SessionID + const body = c.req.valid("json") + const entry = SessionSteer.push(sessionID, body.text, body.mode) + if (SessionStatus.get(sessionID).type === "idle") { + SessionPrompt.loop({ sessionID }).catch(() => { + SessionSteer.clear(sessionID) + }) + } + return c.json(entry) + }, + ) + .get( + "/:sessionID/steer", + describeRoute({ + summary: "Get steer queue", + description: "List all pending steered messages for a session without draining the queue.", + operationId: "session.steer.list", + responses: { + 200: { + description: "Pending steered messages", + content: { + "application/json": { + schema: resolver( + z.array( + z.object({ + id: z.string(), + text: z.string(), + time: z.number(), + mode: z.enum(["queue", "steer"]), + }), + ), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID as SessionID + const queue = SessionSteer.list(sessionID) + return c.json(queue) + }, + ) + .delete( + "/:sessionID/steer/:steerID", + describeRoute({ + summary: "Remove steered message", + description: "Remove a specific queued steered message by its ID before it gets injected.", + operationId: "session.steer.remove", + responses: { + 200: { + description: "Whether the message was found and removed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + steerID: z.string().meta({ description: "Steer message ID" }), + }), + ), + async (c) => { + const params = c.req.valid("param") + const removed = SessionSteer.remove(params.sessionID as SessionID, params.steerID) + return c.json(removed) + }, + ) + // ─── Skill Routes ─────────────────────────────────────────────────────── + // User-initiated $skill mentions. Persists skills per session in the + // session_skills SQL table. Frontend reads via sync channel. + // See: docs/designs/dollar-skill-mentions.md + .post( + "/:sessionID/skill", + describeRoute({ + summary: "Add skill to session", + description: + "Add a user-initiated skill to the session. The skill's SKILL.md content will be injected into the system prompt for all subsequent messages in this session.", + operationId: "session.skill.add", + responses: { + 200: { + description: "Active skills after addition", + content: { + "application/json": { + schema: resolver( + z.array( + z.object({ + name: z.string(), + added_at: z.number(), + token_estimate: z.number().nullable(), + }), + ), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + }), + ), + validator( + "json", + z.object({ + name: z.string().min(1).meta({ description: "Skill name (e.g., 'brainstorming')" }), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID as SessionID + const body = c.req.valid("json") + // Code review fix #2: Skill name validation. + // Skill.available() requires Effect context (not available in HTTP routes). + // Instead, we accept the name here and let the backend's graceful error + // handling in prompt.ts loop() catch nonexistent skills — Skill.get() + // returns null → log.warn → skip. This is acceptable per spec §8: + // "Unknown skill name → leave as literal text, no error." + // The skill will be persisted but silently skipped during injection. + // Idempotent add — re-adding an active skill is a no-op + SessionSkills.add(sessionID, body.name) + return c.json(SessionSkills.list(sessionID)) + }, + ) + .get( + "/:sessionID/skill", + describeRoute({ + summary: "List active skills", + description: "List all user-initiated skills currently active for this session.", + operationId: "session.skill.list", + responses: { + 200: { + description: "Active skills", + content: { + "application/json": { + schema: resolver( + z.array( + z.object({ + name: z.string(), + added_at: z.number(), + token_estimate: z.number().nullable(), + }), + ), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID as SessionID + return c.json(SessionSkills.list(sessionID)) + }, + ) + .delete( + "/:sessionID/skill/:skillName", + describeRoute({ + summary: "Remove skill from session", + description: + "Remove a user-initiated skill from the session. Only removes skills added via $ prefix or this API — does not affect AI auto-loaded skills.", + operationId: "session.skill.remove", + responses: { + 200: { + description: "Whether the skill was found and removed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + skillName: z.string().meta({ description: "Skill name to remove" }), + }), + ), + async (c) => { + const params = c.req.valid("param") + const removed = SessionSkills.remove(params.sessionID as SessionID, params.skillName) + return c.json(removed) + }, + ) .post( "/:sessionID/permissions/:permissionID", describeRoute({ @@ -1018,10 +1260,10 @@ export const SessionRoutes = lazy(() => permissionID: PermissionID.zod, }), ), - validator("json", z.object({ response: Permission.Reply })), + validator("json", z.object({ response: PermissionNext.Reply })), async (c) => { const params = c.req.valid("param") - Permission.reply({ + PermissionNext.reply({ requestID: params.permissionID, reply: c.req.valid("json").response, }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 7260a8af2ebf..9e7d6e7fdceb 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -6,23 +6,17 @@ import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessag import { LSP } from "../lsp" import { Snapshot } from "@/snapshot" import { fn } from "@/util/fn" -import { SyncEvent } from "../sync" import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db" import { MessageTable, PartTable, SessionTable } from "./session.sql" +import { ProviderTransform } from "@/provider/transform" +import { STATUS_CODES } from "http" +import { Storage } from "@/storage/storage" import { ProviderError } from "@/provider/error" import { iife } from "@/util/iife" -import { errorMessage } from "@/util/error" -import type { SystemError } from "bun" +import { type SystemError } from "bun" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" -/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ -interface FetchDecompressionError extends Error { - code: "ZlibError" - errno: number - path: string -} - export namespace MessageV2 { export function isMedia(mime: string) { return mime.startsWith("image/") || mime === "application/pdf" @@ -204,6 +198,26 @@ export namespace MessageV2 { }) export type AgentPart = z.infer + // SkillPart — represents a user-initiated $skill mention. + // When a user types "$brainstorming" in the prompt, this part is created. + // The backend uses it to inject the skill's SKILL.md content into the + // system prompt (wrapped in XML tags) for every message in the + // session until the skill is removed. + // + // Data flow: + // User types "$brainstorming" → UI creates SkillPart → submitted with message + // → createUserMessage() extracts it → SessionSkills.add() persists to DB + // → loop() reads SessionSkills.list() → LRU cache → Skill.load() → system prompt + // + // See also: SessionSkills service (session/skill.service.ts) + export const SkillPart = PartBase.extend({ + type: z.literal("skill"), + name: z.string(), // skill name, e.g. "brainstorming" (matches SKILL.md directory name) + }).meta({ + ref: "SkillPart", + }) + export type SkillPart = z.infer + export const CompactionPart = PartBase.extend({ type: z.literal("compaction"), auto: z.boolean(), @@ -392,6 +406,7 @@ export namespace MessageV2 { SnapshotPart, PatchPart, AgentPart, + SkillPart, RetryPart, CompactionPart, ]) @@ -455,34 +470,25 @@ export namespace MessageV2 { export type Info = z.infer export const Event = { - Updated: SyncEvent.define({ - type: "message.updated", - version: 1, - aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, + Updated: BusEvent.define( + "message.updated", + z.object({ info: Info, }), - }), - Removed: SyncEvent.define({ - type: "message.removed", - version: 1, - aggregate: "sessionID", - schema: z.object({ + ), + Removed: BusEvent.define( + "message.removed", + z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, }), - }), - PartUpdated: SyncEvent.define({ - type: "message.part.updated", - version: 1, - aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, + ), + PartUpdated: BusEvent.define( + "message.part.updated", + z.object({ part: Part, - time: z.number(), }), - }), + ), PartDelta: BusEvent.define( "message.part.delta", z.object({ @@ -493,16 +499,14 @@ export namespace MessageV2 { delta: z.string(), }), ), - PartRemoved: SyncEvent.define({ - type: "message.part.removed", - version: 1, - aggregate: "sessionID", - schema: z.object({ + PartRemoved: BusEvent.define( + "message.part.removed", + z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, partID: PartID.zod, }), - }), + ), } export const WithParts = z.object({ @@ -573,13 +577,30 @@ export namespace MessageV2 { })) } - export async function toModelMessages( + export function toModelMessages( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean }, - ): Promise { + options?: { stripMedia?: boolean; stripLastReasoning?: boolean }, + ): ModelMessage[] { const result: UIMessage[] = [] const toolNames = new Set() + + // Pre-scan: collect user message IDs whose assistant response errored. + // When a file attachment causes an API error, the user message is preserved + // in DB with the bad file. On replay, we strip file/media parts from these + // user messages to prevent the same error from poisoning the session forever. + const poisoned = new Set() + for (const msg of input) { + if (msg.info.role !== "assistant") continue + if (!msg.info.error) continue + // Skip AbortedError with real content (those are kept, not errors) + if ( + MessageV2.AbortedError.isInstance(msg.info.error) && + msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") + ) + continue + poisoned.add(msg.info.parentID) + } // Track media from tool results that need to be injected as user messages // for providers that don't support media in tool results. // @@ -601,8 +622,7 @@ export namespace MessageV2 { return false })() - const toModelOutput = (options: { toolCallId: string; input: unknown; output: unknown }) => { - const output = options.output + const toModelOutput = (output: unknown) => { if (typeof output === "string") { return { type: "text", value: output } } @@ -653,17 +673,32 @@ export namespace MessageV2 { }) // text/plain and directory files are converted into text parts, ignore them if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") { - if (options?.stripMedia && isMedia(part.mime)) { + // If this user message caused an API error, strip media/file parts + // to prevent the same error from poisoning the session forever. + // Text content is preserved so conversation context isn't lost. + if (poisoned.has(msg.info.id)) { + userMessage.parts.push({ + type: "text", + text: `[Removed attachment: ${part.filename ?? part.mime} — caused API error]`, + }) + } else if (options?.stripMedia && isMedia(part.mime)) { userMessage.parts.push({ type: "text", text: `[Attached ${part.mime}: ${part.filename ?? "file"}]`, }) } else { + // Sanitize filename for Anthropic API — only allows alphanumeric, + // whitespace, hyphens, parentheses, and square brackets. + // Replace underscores/dots (except extension) with hyphens. + const raw = part.filename ?? "file" + const ext = raw.lastIndexOf(".") > 0 ? raw.slice(raw.lastIndexOf(".")) : "" + const base = ext ? raw.slice(0, -ext.length) : raw + const clean = base.replace(/[^a-zA-Z0-9\s\-\(\)\[\]]/g, "-").replace(/-{2,}/g, "-") + ext userMessage.parts.push({ type: "file", url: part.url, mediaType: part.mime, - filename: part.filename, + filename: clean, }) } } @@ -684,7 +719,6 @@ export namespace MessageV2 { } if (msg.info.role === "assistant") { - const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` const media: Array<{ mime: string; url: string }> = [] if ( @@ -706,7 +740,7 @@ export namespace MessageV2 { assistantMessage.parts.push({ type: "text", text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + providerMetadata: part.metadata, }) if (part.type === "step-start") assistantMessage.parts.push({ @@ -741,7 +775,7 @@ export namespace MessageV2 { toolCallId: part.callID, input: part.state.input, output, - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), + callProviderMetadata: part.metadata, }) } if (part.state.status === "error") @@ -751,7 +785,7 @@ export namespace MessageV2 { toolCallId: part.callID, input: part.state.input, errorText: part.state.error, - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), + callProviderMetadata: part.metadata, }) // Handle pending/running tool calls to prevent dangling tool_use blocks // Anthropic/Claude APIs require every tool_use to have a corresponding tool_result @@ -762,14 +796,14 @@ export namespace MessageV2 { toolCallId: part.callID, input: part.state.input, errorText: "[Tool execution was interrupted]", - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), + callProviderMetadata: part.metadata, }) } if (part.type === "reasoning") { assistantMessage.parts.push({ type: "reasoning", text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + providerMetadata: part.metadata, }) } } @@ -798,9 +832,31 @@ export namespace MessageV2 { } } + // Strip reasoning/thinking parts from the last assistant message when enabled. + // Claude API enforces that thinking blocks in the latest assistant message + // must be byte-identical to the original response. Since OpenCode reconstructs + // them from stored parts, they may not match exactly. + // + // Strategy "strip": Always strip — prevents errors proactively. + // Strategy "compact": Don't strip — let API error, then auto-compact to recover. + // Strategy "none" (default): Don't strip — original behavior. + // + // Only strip when explicitly requested via options.stripLastReasoning = true. + if (options?.stripLastReasoning === true) { + const lastAssistantIdx = result.findLastIndex((msg) => msg.role === "assistant") + if (lastAssistantIdx !== -1) { + const filtered = result[lastAssistantIdx].parts.filter((part) => part.type !== "reasoning") + if (filtered.length > 0 && !filtered.every((p) => p.type === "step-start")) { + result[lastAssistantIdx].parts = filtered + } else { + result.splice(lastAssistantIdx, 1) + } + } + } + const tools = Object.fromEntries(Array.from(toolNames).map((toolName) => [toolName, { toModelOutput }])) - return await convertToModelMessages( + return convertToModelMessages( result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")), { //@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput) @@ -872,13 +928,7 @@ export namespace MessageV2 { db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(), ) return rows.map( - (row) => - ({ - ...row.data, - id: row.id, - sessionID: row.session_id, - messageID: row.message_id, - }) as MessageV2.Part, + (row) => ({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id }) as MessageV2.Part, ) }) @@ -921,10 +971,7 @@ export namespace MessageV2 { return result } - export function fromError( - e: unknown, - ctx: { providerID: ProviderID; aborted?: boolean }, - ): NonNullable { + export function fromError(e: unknown, ctx: { providerID: ProviderID }): NonNullable { switch (true) { case e instanceof DOMException && e.name === "AbortError": return new MessageV2.AbortedError( @@ -956,21 +1003,6 @@ export namespace MessageV2 { }, { cause: e }, ).toObject() - case e instanceof Error && (e as FetchDecompressionError).code === "ZlibError": - if (ctx.aborted) { - return new MessageV2.AbortedError({ message: e.message }, { cause: e }).toObject() - } - return new MessageV2.APIError( - { - message: "Response decompression failed", - isRetryable: true, - metadata: { - code: (e as FetchDecompressionError).code, - message: e.message, - }, - }, - { cause: e }, - ).toObject() case APICallError.isInstance(e): const parsed = ProviderError.parseAPICallError({ providerID: ctx.providerID, @@ -998,7 +1030,7 @@ export namespace MessageV2 { { cause: e }, ).toObject() case e instanceof Error: - return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject() + return new NamedError.Unknown({ message: e instanceof Error ? e.message : String(e) }, { cause: e }).toObject() default: try { const parsed = ProviderError.parseStreamError(e) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index dd74b83f50f2..6dbbf293b2b9 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -11,7 +11,7 @@ import { Session } from "." import { Agent } from "../agent/agent" import { Provider } from "../provider/provider" import { ModelID, ProviderID } from "../provider/schema" -import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai" +import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions, asSchema } from "ai" import { SessionCompaction } from "./compaction" import { Instance } from "../project/instance" import { Bus } from "../bus" @@ -28,11 +28,11 @@ import { MCP } from "../mcp" import { LSP } from "../lsp" import { ReadTool } from "../tool/read" import { FileTime } from "../file/time" -import { NotFoundError } from "@/storage/db" import { Flag } from "../flag/flag" import { ulid } from "ulid" import { spawn } from "child_process" import { Command } from "../command" +import { $ } from "bun" import { pathToFileURL, fileURLToPath } from "url" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" @@ -41,14 +41,16 @@ import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" import { Tool } from "@/tool/tool" -import { Permission } from "@/permission" +import { PermissionNext } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncate" +import { SessionSteer } from "./steer" +import { SessionSkills, SkillContentCache } from "./skill.service" +import { Skill } from "../skill/skill" import { decodeDataUrl } from "@/util/data-url" -import { Process } from "@/util/process" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -154,6 +156,20 @@ export namespace SessionPrompt { .meta({ ref: "SubtaskPartInput", }), + // SkillPart — user-initiated $skill mentions. + // When the user types "$brainstorming", the frontend creates a SkillPart + // and includes it in the parts array. createUserMessage() extracts it + // and persists it to SessionSkills for the session. + MessageV2.SkillPart.omit({ + messageID: true, + sessionID: true, + }) + .partial({ + id: true, + }) + .meta({ + ref: "SkillPartInput", + }), ]), ), }) @@ -168,7 +184,7 @@ export namespace SessionPrompt { // this is backwards compatibility for allowing `tools` to be specified when // prompting - const permissions: Permission.Ruleset = [] + const permissions: PermissionNext.Ruleset = [] for (const [tool, enabled] of Object.entries(input.tools ?? {})) { permissions.push({ permission: tool, @@ -257,17 +273,18 @@ export namespace SessionPrompt { return s[sessionID].abort.signal } - export async function cancel(sessionID: SessionID) { + export function cancel(sessionID: SessionID) { log.info("cancel", { sessionID }) + SessionSteer.clear(sessionID) const s = state() const match = s[sessionID] if (!match) { - await SessionStatus.set(sessionID, { type: "idle" }) + SessionStatus.set(sessionID, { type: "idle" }) return } match.abort.abort() delete s[sessionID] - await SessionStatus.set(sessionID, { type: "idle" }) + SessionStatus.set(sessionID, { type: "idle" }) return } @@ -286,7 +303,7 @@ export namespace SessionPrompt { }) } - await using _ = defer(() => cancel(sessionID)) + using _ = defer(() => cancel(sessionID)) // Structured output state // Note: On session resumption, state is reset but outputFormat is preserved @@ -296,7 +313,7 @@ export namespace SessionPrompt { let step = 0 const session = await Session.get(sessionID) while (true) { - await SessionStatus.set(sessionID, { type: "busy" }) + SessionStatus.set(sessionID, { type: "busy" }) log.info("loop", { step, sessionID }) if (abort.aborted) break let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) @@ -321,15 +338,59 @@ export namespace SessionPrompt { if (!lastUser) throw new Error("No user message found in stream. This should never happen.") if ( lastAssistant?.finish && - ![ - "tool-calls", - // in v6 unknown became other but other existed in v5 too and was distinctly different - // I think there are certain providers that used to have bad stop reasons, not rlly sure which - // ones if any still have this? - // "unknown", - ].includes(lastAssistant.finish) && + !["tool-calls", "unknown"].includes(lastAssistant.finish) && lastUser.id < lastAssistant.id ) { + // Check for "steer" mode messages — inject mid-turn at loop boundaries + const steered = SessionSteer.takeByMode(sessionID, "steer") + if (steered.length > 0) { + log.info("steer: injecting pending input", { sessionID, count: steered.length }) + const text = steered.map((m) => m.text).join("\n\n") + const steerMsg: MessageV2.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: lastUser.agent, + model: lastUser.model, + variant: lastUser.variant, + } + await Session.updateMessage(steerMsg) + await Session.updatePart({ + id: PartID.ascending(), + messageID: steerMsg.id, + sessionID, + type: "text", + text, + } satisfies MessageV2.TextPart) + continue + } + + // Turn finished. Drain "queue" mode messages and auto-submit as new user messages. + const queued = SessionSteer.takeByMode(sessionID, "queue") + if (queued.length > 0) { + log.info("steer: auto-submitting queued input", { sessionID, count: queued.length }) + const text = queued.map((m) => m.text).join("\n\n") + const queueMsg: MessageV2.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: lastUser.agent, + model: lastUser.model, + variant: lastUser.variant, + } + await Session.updateMessage(queueMsg) + await Session.updatePart({ + id: PartID.ascending(), + messageID: queueMsg.id, + sessionID, + type: "text", + text, + } satisfies MessageV2.TextPart) + continue + } + log.info("exiting loop", { sessionID }) break } @@ -424,16 +485,6 @@ export namespace SessionPrompt { ) let executionError: Error | undefined const taskAgent = await Agent.get(task.agent) - if (!taskAgent) { - const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) - const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` }) - Bus.publish(Session.Event.Error, { - sessionID, - error: error.toObject(), - }) - throw error - } const taskCtx: Tool.Context = { agent: task.agent, messageID: assistantMessage.id, @@ -453,10 +504,10 @@ export namespace SessionPrompt { } satisfies MessageV2.ToolPart)) as MessageV2.ToolPart }, async ask(req) { - await Permission.ask({ + await PermissionNext.ask({ ...req, sessionID: sessionID, - ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), + ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []), }) }, } @@ -576,16 +627,6 @@ export namespace SessionPrompt { // normal processing const agent = await Agent.get(lastUser.agent) - if (!agent) { - const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) - const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` }) - Bus.publish(Session.Event.Error, { - sessionID, - error: error.toObject(), - }) - throw error - } const maxSteps = agent.steps ?? Infinity const isLastStep = step >= maxSteps msgs = await insertReminders({ @@ -679,11 +720,57 @@ export namespace SessionPrompt { await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) // Build system prompt, adding structured output instruction if needed + // + // ─── Skill injection flow (from $skill mentions) ───────────────── + // SessionSkills.list(sessionID) → active skill names + // ↓ + // LRU cache (5-min TTL) → Skill.load() on miss + // ↓ + // Dedup against auto-discovered skills (already in `skills` var) + // ↓ + // Wrap in XML → append to system prompt array + // + // Auto-discovered skills (from SystemPrompt.skills) are UNCHANGED. + // User $-mentioned skills are ADDITIVE — they never remove auto skills. + // If a skill was both auto-discovered and $-mentioned, it's deduped. const skills = await SystemPrompt.skills(agent) + + // Load user-initiated $skill mentions from server-side session_skills table. + // These are skills the user explicitly loaded via "$brainstorming" or the API. + // They persist for the session (survive page reloads). + const activeSkills = SessionSkills.list(sessionID) + const userSkills: string[] = [] + for (const active of activeSkills) { + // Dedup: skip if this skill is already in auto-discovered system prompt. + // The auto-discovered skills section contains skillname tags. + if (skills && skills.includes(`${active.name}`)) continue + try { + // Check LRU cache first (5-min TTL), then fall back to disk read. + const cached = SkillContentCache.get(active.name) + if (cached) { + userSkills.push(`\n${active.name}\n${cached.content}\n`) + continue + } + // Cache miss — load from disk via Skill.get() and cache the result. + const loaded = await Skill.get(active.name).catch(() => null) + if (!loaded) { + log.warn("skill.get.notfound", { sessionID, name: active.name }) + continue + } + SkillContentCache.set(active.name, loaded.content) + userSkills.push(`\n${active.name}\n${loaded.content}\n`) + } catch (err) { + // ParseError or FileNotFoundError — skip this skill gracefully. + // Don't crash the loop; just log and continue without this skill. + log.warn("skill.load.failed", { sessionID, name: active.name, error: String(err) }) + } + } + const system = [ ...(await SystemPrompt.environment(model)), ...(skills ? [skills] : []), ...(await InstructionPrompt.system()), + ...userSkills, // User $-mentioned skills (additive, deduped, per-session persistent) ] const format = lastUser.format ?? { type: "text" } if (format.type === "json_schema") { @@ -698,7 +785,7 @@ export namespace SessionPrompt { sessionID, system, messages: [ - ...(await MessageV2.toModelMessages(msgs, model)), + ...MessageV2.toModelMessages(msgs, model), ...(isLastStep ? [ { @@ -781,7 +868,7 @@ export namespace SessionPrompt { using _ = log.time("resolveTools") const tools: Record = {} - const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({ + const context = (args: any, options: ToolCallOptions): Tool.Context => ({ sessionID: input.session.id, abort: options.abortSignal!, messageID: input.processor.message.id, @@ -807,11 +894,11 @@ export namespace SessionPrompt { } }, async ask(req) { - await Permission.ask({ + await PermissionNext.ask({ ...req, sessionID: input.session.id, tool: { messageID: input.processor.message.id, callID: options.toolCallId }, - ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []), + ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []), }) }, }) @@ -867,8 +954,7 @@ export namespace SessionPrompt { const execute = item.execute if (!execute) continue - const schema = await asSchema(item.inputSchema).jsonSchema - const transformed = ProviderTransform.schema(input.model, schema) + const transformed = ProviderTransform.schema(input.model, asSchema(item.inputSchema).jsonSchema) item.inputSchema = jsonSchema(transformed) // Wrap execute to add plugin hooks and format output item.execute = async (args, opts) => { @@ -981,28 +1067,17 @@ export namespace SessionPrompt { metadata: { valid: true }, } }, - toModelOutput({ output }) { + toModelOutput(result) { return { type: "text", - value: output.output, + value: result.output, } }, }) } async function createUserMessage(input: PromptInput) { - const agentName = input.agent || (await Agent.defaultAgent()) - const agent = await Agent.get(agentName) - if (!agent) { - const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) - const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` }) - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: error.toObject(), - }) - throw error - } + const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) const full = @@ -1309,7 +1384,7 @@ export namespace SessionPrompt { if (part.type === "agent") { // Check if this agent would be denied by task permission - const perm = Permission.evaluate("task", part.name, agent.permission) + const perm = PermissionNext.evaluate("task", part.name, agent.permission) const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" return [ { @@ -1332,6 +1407,35 @@ export namespace SessionPrompt { ] } + // ─── Skill Part Handling ──────────────────────────────────────── + // When user types "$brainstorming", the frontend creates a SkillPart. + // Here we persist it to SessionSkills (server-side DB) so the skill + // stays active for the entire session, and create a synthetic log + // message visible in the chat history. + // + // The actual SKILL.md content injection happens later in loop(), + // NOT here. This function only persists the skill name and logs it. + // + // Flow: SkillPart in parts → SessionSkills.add() → synthetic log + // → loop() reads SessionSkills.list() → injects into system prompt + if (part.type === "skill") { + SessionSkills.add(input.sessionID, part.name) + return [ + { + ...part, + messageID: info.id, + sessionID: input.sessionID, + }, + { + messageID: info.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: `Loaded skill: ${part.name}`, + }, + ] + } + return [ { ...part, @@ -1569,16 +1673,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the await SessionRevert.cleanup(session) } const agent = await Agent.get(input.agent) - if (!agent) { - const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) - const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` }) - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: error.toObject(), - }) - throw error - } const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) const userMsg: MessageV2.User = { id: MessageID.ascending(), @@ -1830,16 +1924,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the export async function command(input: CommandInput) { log.info("command", input) const command = await Command.get(input.command) - if (!command) { - const available = await Command.list().then((cmds) => cmds.map((c) => c.name)) - const hint = available.length ? ` Available commands: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` }) - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: error.toObject(), - }) - throw error - } const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) const raw = input.arguments.match(argsRegex) ?? [] @@ -1871,13 +1955,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the template = template + "\n\n" + input.arguments } - const shellMatches = ConfigMarkdown.shell(template) - if (shellMatches.length > 0) { - const sh = Shell.preferred() + const shell = ConfigMarkdown.shell(template) + if (shell.length > 0) { const results = await Promise.all( - shellMatches.map(async ([, cmd]) => { - const out = await Process.text([cmd], { shell: sh, nothrow: true }) - return out.text + shell.map(async ([, cmd]) => { + try { + return await $`${{ raw: cmd }}`.quiet().nothrow().text() + } catch (error) { + return `Error executing command: ${error instanceof Error ? error.message : String(error)}` + } }), ) let index = 0 @@ -2017,28 +2103,28 @@ NOTE: At any point in time through this workflow you should feel free to ask the (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID)) ) }) - try { - const result = await LLM.stream({ - agent, - user: firstRealUser.info as MessageV2.User, - system: [], - small: true, - tools: {}, - model, - abort: new AbortController().signal, - sessionID: input.session.id, - retries: 2, - messages: [ - { - role: "user", - content: "Generate a title for this conversation:\n", - }, - ...(hasOnlySubtaskParts - ? [{ role: "user" as const, content: subtaskParts.map((p) => p.prompt).join("\n") }] - : await MessageV2.toModelMessages(contextMessages, model)), - ], - }) - const text = await result.text + const result = await LLM.stream({ + agent, + user: firstRealUser.info as MessageV2.User, + system: [], + small: true, + tools: {}, + model, + abort: new AbortController().signal, + sessionID: input.session.id, + retries: 2, + messages: [ + { + role: "user", + content: "Generate a title for this conversation:\n", + }, + ...(hasOnlySubtaskParts + ? [{ role: "user" as const, content: subtaskParts.map((p) => p.prompt).join("\n") }] + : MessageV2.toModelMessages(contextMessages, model)), + ], + }) + const text = await result.text.catch((err) => log.error("failed to generate title", { error: err })) + if (text) { const cleaned = text .replace(/[\s\S]*?<\/think>\s*/g, "") .split("\n") @@ -2047,12 +2133,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (!cleaned) return const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned - return Session.setTitle({ sessionID: input.session.id, title }).catch((err) => { - if (NotFoundError.isInstance(err)) return - throw err - }) - } catch (error) { - log.error("failed to generate title", { error }) + return Session.setTitle({ sessionID: input.session.id, title }) } } } diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 189a596873a3..b4ce23839681 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -2,7 +2,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlit import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" import type { Snapshot } from "../snapshot" -import type { Permission } from "../permission" +import type { PermissionNext } from "../permission" import type { ProjectID } from "../project/schema" import type { SessionID, MessageID, PartID } from "./schema" import type { WorkspaceID } from "../control-plane/schema" @@ -31,7 +31,7 @@ export const SessionTable = sqliteTable( summary_files: integer(), summary_diffs: text({ mode: "json" }).$type(), revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), - permission: text({ mode: "json" }).$type(), + permission: text({ mode: "json" }).$type(), ...Timestamps, time_compacting: integer(), time_archived: integer(), @@ -94,10 +94,36 @@ export const TodoTable = sqliteTable( ], ) +// session_skills — persists user-initiated $skill mentions per session. +// Each row represents one active skill for one session. +// Skills are added via "$brainstorming" in the prompt or POST /session/:id/skill, +// removed via "$-brainstorming" or DELETE /session/:id/skill/:name. +// The loop() function in prompt.ts reads this table each message to inject +// skill content into the system prompt. +// +// Schema: composite PK on (session_id, skill_name) ensures uniqueness. +// Index on session_id for fast list queries. +export const SessionSkillTable = sqliteTable( + "session_skill", + { + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + skill_name: text().notNull(), + added_at: integer().notNull(), + token_estimate: integer(), + }, + (table) => [ + primaryKey({ columns: [table.session_id, table.skill_name] }), + index("session_skill_session_idx").on(table.session_id), + ], +) + export const PermissionTable = sqliteTable("permission", { project_id: text() .primaryKey() .references(() => ProjectTable.id, { onDelete: "cascade" }), ...Timestamps, - data: text({ mode: "json" }).notNull().$type(), + data: text({ mode: "json" }).notNull().$type(), }) diff --git a/packages/opencode/src/session/skill.service.ts b/packages/opencode/src/session/skill.service.ts new file mode 100644 index 000000000000..845c2cf2b842 --- /dev/null +++ b/packages/opencode/src/session/skill.service.ts @@ -0,0 +1,230 @@ +// SessionSkills — Server-side persistence for user-initiated $skill mentions. +// +// This service manages the lifecycle of skills that users explicitly load +// via the "$" prefix (e.g., typing "$brainstorming" in the prompt). +// It is the server-side counterpart to the frontend badge strip + popover. +// +// Architecture diagram: +// +// Frontend Backend +// ┌──────────┐ ┌──────────────────────┐ +// │ $ popover│──POST /skill──────▶│ SessionSkills.add() │ +// │ × badge │──DELETE /skill────▶│ SessionSkills.remove()│ +// │ badge │◀──sync channel─────│ SessionSkills.list() │ +// └──────────┘ └──────────┬───────────┘ +// │ +// ┌──────────▼───────────┐ +// │ session_skills table │ +// │ (SQLite, persists │ +// │ across page reloads) │ +// └──────────────────────┘ +// +// The prompt loop (prompt.ts) calls list() each message, then uses the +// LRU cache (SkillContentCache below) to avoid re-reading SKILL.md files +// from disk on every message. +// +// Design decisions (from CEO/Eng/Design reviews, 2026-03-19): +// - Server-side persistence (Approach B) — survives page reloads +// - In-memory LRU cache with 5-min TTL — fast after first load +// - Idempotent add — adding a skill that's already active is a no-op +// - $-removal only removes user-added skills, not AI auto-loaded ones +// - Bus event on change → sync channel → frontend badge strip updates + +import { Bus } from "../bus" +import { BusEvent } from "../bus/bus-event" +import { Database, eq, and } from "@/storage/db" +import { SessionSkillTable } from "./session.sql" +import { Log } from "../util/log" +import z from "zod" +import type { SessionID } from "./schema" + +// ─── LRU Cache for Skill Content ─────────────────────────────────────────── +// +// Avoids re-reading SKILL.md from disk on every message in a session. +// Keyed by skill name (global, not per-session — content is the same). +// TTL of 5 minutes handles the case where a user edits a SKILL.md mid-session. +// +// cache.get("brainstorming") +// → hit: return cached content (if not expired) +// → miss/expired: return undefined → caller reads from disk → cache.set() + +const CACHE_TTL = 5 * 60 * 1000 // 5 minutes in milliseconds + +interface CacheEntry { + content: string + tokens: number // estimated token count (chars ÷ 4) + time: number // timestamp when cached +} + +// In-memory LRU cache for skill content. +// Using a simple Map — entries are evicted by TTL, not by count, +// because the number of unique skills is small (typically <50). +const cache = new Map() + +export namespace SkillContentCache { + /** Get cached skill content. Returns undefined if miss or expired. */ + export function get(name: string): CacheEntry | undefined { + const entry = cache.get(name) + if (!entry) return undefined + if (Date.now() - entry.time > CACHE_TTL) { + // TTL expired — evict and return miss + cache.delete(name) + return undefined + } + return entry + } + + /** Cache skill content with estimated token count. */ + export function set(name: string, content: string) { + const tokens = Math.ceil(content.length / 4) + cache.set(name, { content, tokens, time: Date.now() }) + } + + /** Evict a specific skill from cache (e.g., when removed from session). */ + export function evict(name: string) { + cache.delete(name) + } + + /** Clear entire cache (e.g., on session end). */ + export function clear() { + cache.clear() + } + + /** Get estimated token count for a cached skill. Returns 0 if not cached. */ + export function tokens(name: string): number { + return get(name)?.tokens ?? 0 + } +} + +// ─── SessionSkills Service ───────────────────────────────────────────────── + +export namespace SessionSkills { + const log = Log.create({ service: "session.skills" }) + + // Bus event published when the skill list changes for a session. + // The frontend reads this via sync channel to update the badge strip. + export const Event = { + Changed: BusEvent.define( + "session.skill.changed", + z.object({ + sessionID: z.string(), + skills: z.array(z.object({ + name: z.string(), + added_at: z.number(), + token_estimate: z.number().nullable(), + })), + }), + ), + } + + export interface ActiveSkill { + name: string + added_at: number + token_estimate: number | null + } + + /** Add a skill to a session. Idempotent — re-adding is a no-op. */ + export function add(sessionID: SessionID, name: string, tokens?: number) { + const now = Date.now() + try { + Database.use((db) => + db + .insert(SessionSkillTable) + .values({ + session_id: sessionID, + skill_name: name, + added_at: now, + token_estimate: tokens ?? null, + }) + .onConflictDoNothing() + .run(), + ) + log.info("skill.add", { sessionID, name }) + } catch (err) { + log.error("skill.add.failed", { sessionID, name, error: String(err) }) + throw err + } + // Publish change event so frontend badge strip updates + Bus.publish(Event.Changed, { sessionID, skills: list(sessionID) }) + } + + /** Remove a skill from a session. Returns true if it existed. */ + export function remove(sessionID: SessionID, name: string): boolean { + // Check if the skill exists before deleting, since Drizzle's .run() + // returns void and doesn't report affected row count. + const before = list(sessionID) + const existed = before.some((s) => s.name === name) + if (!existed) return false + // Delete using composite key: both session_id AND skill_name must match. + Database.use((db) => + db + .delete(SessionSkillTable) + .where( + and( + eq(SessionSkillTable.session_id, sessionID), + eq(SessionSkillTable.skill_name, name), + ), + ) + .run(), + ) + log.info("skill.remove", { sessionID, name }) + SkillContentCache.evict(name) + Bus.publish(Event.Changed, { sessionID, skills: list(sessionID) }) + return true + } + + /** List all active skills for a session. */ + export function list(sessionID: SessionID): ActiveSkill[] { + return Database.use((db) => + db + .select({ + name: SessionSkillTable.skill_name, + added_at: SessionSkillTable.added_at, + token_estimate: SessionSkillTable.token_estimate, + }) + .from(SessionSkillTable) + .where(eq(SessionSkillTable.session_id, sessionID)) + .all(), + ) + } + + /** Clear all active skills for a session. */ + export function clear(sessionID: SessionID) { + Database.use((db) => + db + .delete(SessionSkillTable) + .where(eq(SessionSkillTable.session_id, sessionID)) + .run(), + ) + log.info("skill.clear", { sessionID }) + Bus.publish(Event.Changed, { sessionID, skills: [] }) + } + + /** Get recent skill names across all sessions (for "Recent" popover section). */ + export function recent(limit = 5): string[] { + const rows = Database.use((db) => + db + .select({ name: SessionSkillTable.skill_name }) + .from(SessionSkillTable) + .orderBy(SessionSkillTable.added_at) + .all(), + ) + // Deduplicate and take last N unique names + const seen = new Set() + const result: string[] = [] + for (let i = rows.length - 1; i >= 0; i--) { + if (!seen.has(rows[i].name)) { + seen.add(rows[i].name) + result.push(rows[i].name) + if (result.length >= limit) break + } + } + return result + } + + /** Estimate total token budget for active skills in a session. */ + export function budget(sessionID: SessionID): number { + const skills = list(sessionID) + return skills.reduce((sum, s) => sum + (s.token_estimate ?? 0), 0) + } +} diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 527584e7e29e..2ae58eb43cb2 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -24,6 +24,8 @@ import type { EventTuiPromptAppend, EventTuiSessionSelect, EventTuiToastShow, + ExperimentalEnhanceErrors, + ExperimentalEnhanceResponses, ExperimentalResourceListResponses, ExperimentalSessionListResponses, ExperimentalWorkspaceCreateErrors, @@ -46,9 +48,6 @@ import type { GlobalDisposeResponses, GlobalEventResponses, GlobalHealthResponses, - GlobalSyncEventSubscribeResponses, - GlobalUpgradeErrors, - GlobalUpgradeResponses, InstanceDisposeResponses, LspStatusResponses, McpAddErrors, @@ -140,8 +139,20 @@ import type { SessionShareResponses, SessionShellErrors, SessionShellResponses, + SessionSkillAddErrors, + SessionSkillAddResponses, + SessionSkillListErrors, + SessionSkillListResponses, + SessionSkillRemoveErrors, + SessionSkillRemoveResponses, SessionStatusErrors, SessionStatusResponses, + SessionSteerErrors, + SessionSteerListErrors, + SessionSteerListResponses, + SessionSteerRemoveErrors, + SessionSteerRemoveResponses, + SessionSteerResponses, SessionSummarizeErrors, SessionSummarizeResponses, SessionTodoErrors, @@ -152,6 +163,7 @@ import type { SessionUnshareResponses, SessionUpdateErrors, SessionUpdateResponses, + SkillPartInput, SubtaskPartInput, TextPartInput, ToolIdsErrors, @@ -231,20 +243,6 @@ class HeyApiRegistry { } } -export class SyncEvent extends HeyApiClient { - /** - * Subscribe to global sync events - * - * Get global sync events - */ - public subscribe(options?: Options) { - return (options?.client ?? this.client).sse.get({ - url: "/global/sync-event", - ...options, - }) - } -} - export class Config extends HeyApiClient { /** * Get global configuration @@ -320,35 +318,6 @@ export class Global extends HeyApiClient { }) } - /** - * Upgrade opencode - * - * Upgrade opencode to the specified version or latest if not specified. - */ - public upgrade( - parameters?: { - target?: string - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }]) - return (options?.client ?? this.client).post({ - url: "/global/upgrade", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - private _syncEvent?: SyncEvent - get syncEvent(): SyncEvent { - return (this._syncEvent ??= new SyncEvent({ client: this.client })) - } - private _config?: Config get config(): Config { return (this._config ??= new Config({ client: this.client })) @@ -411,113 +380,6 @@ export class Auth extends HeyApiClient { } } -export class App extends HeyApiClient { - /** - * Write log - * - * Write a log entry to the server logs with specified level and metadata. - */ - public log( - parameters?: { - directory?: string - workspace?: string - service?: string - level?: "debug" | "info" | "error" | "warn" - message?: string - extra?: { - [key: string]: unknown - } - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "service" }, - { in: "body", key: "level" }, - { in: "body", key: "message" }, - { in: "body", key: "extra" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/log", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * List agents - * - * Get a list of all available AI agents in the OpenCode system. - */ - public agents( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/agent", - ...options, - ...params, - }) - } - - /** - * List skills - * - * Get a list of all available skills in the OpenCode system. - */ - public skills( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/skill", - ...options, - ...params, - }) - } -} - export class Project extends HeyApiClient { /** * List all projects @@ -1238,6 +1100,49 @@ export class Resource extends HeyApiClient { } export class Experimental extends HeyApiClient { + /** + * Enhance prompt + * + * Rewrite a user prompt to be clearer, more specific, and more effective for an AI coding assistant. + */ + public enhance( + parameters?: { + directory?: string + workspace?: string + text?: string + providerID?: string + modelID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "text" }, + { in: "body", key: "providerID" }, + { in: "body", key: "modelID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post( + { + url: "/experimental/enhance", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }, + ) + } + private _workspace?: Workspace get workspace(): Workspace { return (this._workspace ??= new Workspace({ client: this.client })) @@ -1342,13 +1247,193 @@ export class Worktree extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "worktreeCreateInput", map: "body" }, + { key: "worktreeCreateInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/worktree", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Reset worktree + * + * Reset a worktree branch to the primary default branch. + */ + public reset( + parameters?: { + directory?: string + workspace?: string + worktreeResetInput?: WorktreeResetInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "worktreeResetInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/worktree/reset", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Steer extends HeyApiClient { + /** + * Get steer queue + * + * List all pending steered messages for a session without draining the queue. + */ + public list( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/session/{sessionID}/steer", + ...options, + ...params, + }) + } + + /** + * Remove steered message + * + * Remove a specific queued steered message by its ID before it gets injected. + */ + public remove( + parameters: { + sessionID: string + steerID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "steerID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete( + { + url: "/session/{sessionID}/steer/{steerID}", + ...options, + ...params, + }, + ) + } +} + +export class Skill extends HeyApiClient { + /** + * List active skills + * + * List all user-initiated skills currently active for this session. + */ + public list( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/session/{sessionID}/skill", + ...options, + ...params, + }) + } + + /** + * Add skill to session + * + * Add a user-initiated skill to the session. The skill's SKILL.md content will be injected into the system prompt for all subsequent messages in this session. + */ + public add( + parameters: { + sessionID: string + directory?: string + workspace?: string + name?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "name" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/experimental/worktree", + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/skill", ...options, ...params, headers: { @@ -1360,15 +1445,16 @@ export class Worktree extends HeyApiClient { } /** - * Reset worktree + * Remove skill from session * - * Reset a worktree branch to the primary default branch. + * Remove a user-initiated skill from the session. Only removes skills added via $ prefix or this API — does not affect AI auto-loaded skills. */ - public reset( - parameters?: { + public remove( + parameters: { + sessionID: string + skillName: string directory?: string workspace?: string - worktreeResetInput?: WorktreeResetInput }, options?: Options, ) { @@ -1377,23 +1463,21 @@ export class Worktree extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "skillName" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "worktreeResetInput", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/experimental/worktree/reset", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, + return (options?.client ?? this.client).delete( + { + url: "/session/{sessionID}/skill/{skillName}", + ...options, + ...params, }, - }) + ) } } @@ -1994,7 +2078,7 @@ export class Session2 extends HeyApiClient { format?: OutputFormat system?: string variant?: string - parts?: Array + parts?: Array }, options?: Options, ) { @@ -2126,7 +2210,7 @@ export class Session2 extends HeyApiClient { format?: OutputFormat system?: string variant?: string - parts?: Array + parts?: Array }, options?: Options, ) { @@ -2339,6 +2423,57 @@ export class Session2 extends HeyApiClient { ...params, }) } + + /** + * Steer session + * + * Push a message into the session's pending input buffer. If the session is busy, the message will be injected at the next agentic loop boundary. If idle, it is queued for the next turn. + */ + public steer( + parameters: { + sessionID: string + directory?: string + workspace?: string + text?: string + mode?: "queue" | "steer" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "text" }, + { in: "body", key: "mode" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/steer", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _steer?: Steer + get steer2(): Steer { + return (this._steer ??= new Steer({ client: this.client })) + } + + private _skill?: Skill + get skill(): Skill { + return (this._skill ??= new Skill({ client: this.client })) + } } export class Part extends HeyApiClient { @@ -2998,38 +3133,6 @@ export class File extends HeyApiClient { } } -export class Event extends HeyApiClient { - /** - * Subscribe to events - * - * Get events - */ - public subscribe( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).sse.get({ - url: "/event", - ...options, - ...params, - }) - } -} - export class Auth2 extends HeyApiClient { /** * Remove MCP OAuth @@ -3880,6 +3983,113 @@ export class Command extends HeyApiClient { } } +export class App extends HeyApiClient { + /** + * Write log + * + * Write a log entry to the server logs with specified level and metadata. + */ + public log( + parameters?: { + directory?: string + workspace?: string + service?: string + level?: "debug" | "info" | "error" | "warn" + message?: string + extra?: { + [key: string]: unknown + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "service" }, + { in: "body", key: "level" }, + { in: "body", key: "message" }, + { in: "body", key: "extra" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/log", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * List agents + * + * Get a list of all available AI agents in the OpenCode system. + */ + public agents( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/agent", + ...options, + ...params, + }) + } + + /** + * List skills + * + * Get a list of all available skills in the OpenCode system. + */ + public skills( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/skill", + ...options, + ...params, + }) + } +} + export class Lsp extends HeyApiClient { /** * Get LSP status @@ -3944,6 +4154,38 @@ export class Formatter extends HeyApiClient { } } +export class Event extends HeyApiClient { + /** + * Subscribe to events + * + * Get events + */ + public subscribe( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).sse.get({ + url: "/event", + ...options, + ...params, + }) + } +} + export class OpencodeClient extends HeyApiClient { public static readonly __registry = new HeyApiRegistry() @@ -3962,11 +4204,6 @@ export class OpencodeClient extends HeyApiClient { return (this._auth ??= new Auth({ client: this.client })) } - private _app?: App - get app(): App { - return (this._app ??= new App({ client: this.client })) - } - private _project?: Project get project(): Project { return (this._project ??= new Project({ client: this.client })) @@ -4032,11 +4269,6 @@ export class OpencodeClient extends HeyApiClient { return (this._file ??= new File({ client: this.client })) } - private _event?: Event - get event(): Event { - return (this._event ??= new Event({ client: this.client })) - } - private _mcp?: Mcp get mcp(): Mcp { return (this._mcp ??= new Mcp({ client: this.client })) @@ -4067,6 +4299,11 @@ export class OpencodeClient extends HeyApiClient { return (this._command ??= new Command({ client: this.client })) } + private _app?: App + get app(): App { + return (this._app ??= new App({ client: this.client })) + } + private _lsp?: Lsp get lsp(): Lsp { return (this._lsp ??= new Lsp({ client: this.client })) @@ -4076,4 +4313,9 @@ export class OpencodeClient extends HeyApiClient { get formatter(): Formatter { return (this._formatter ??= new Formatter({ client: this.client })) } + + private _event?: Event + get event(): Event { + return (this._event ??= new Event({ client: this.client })) + } } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 318b8907a91d..7ad3371dcc6f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -47,50 +47,25 @@ export type EventProjectUpdated = { properties: Project } -export type EventServerInstanceDisposed = { - type: "server.instance.disposed" - properties: { - directory: string - } -} - -export type EventServerConnected = { - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - -export type EventLspClientDiagnostics = { - type: "lsp.client.diagnostics" +export type EventFileEdited = { + type: "file.edited" properties: { - serverID: string - path: string + file: string } } -export type EventLspUpdated = { - type: "lsp.updated" +export type EventServerInstanceDisposed = { + type: "server.instance.disposed" properties: { - [key: string]: unknown + directory: string } } -export type EventMessagePartDelta = { - type: "message.part.delta" +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" properties: { - sessionID: string - messageID: string - partID: string - field: string - delta: string + file: string + event: "add" | "change" | "unlink" } } @@ -123,32 +98,10 @@ export type EventPermissionReplied = { } } -export type SessionStatus = - | { - type: "idle" - } - | { - type: "retry" - attempt: number - message: string - next: number - } - | { - type: "busy" - } - -export type EventSessionStatus = { - type: "session.status" - properties: { - sessionID: string - status: SessionStatus - } -} - -export type EventSessionIdle = { - type: "session.idle" +export type EventVcsBranchUpdated = { + type: "vcs.branch.updated" properties: { - sessionID: string + branch?: string } } @@ -223,129 +176,50 @@ export type EventQuestionRejected = { } } -export type EventSessionCompacted = { - type: "session.compacted" - properties: { - sessionID: string - } -} - -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - -export type Todo = { - /** - * Brief description of the task - */ - content: string - /** - * Current status of the task: pending, in_progress, completed, cancelled - */ - status: string - /** - * Priority level of the task: high, medium, low - */ - priority: string -} - -export type EventTodoUpdated = { - type: "todo.updated" +export type EventServerConnected = { + type: "server.connected" properties: { - sessionID: string - todos: Array + [key: string]: unknown } } -export type EventTuiPromptAppend = { - type: "tui.prompt.append" +export type EventGlobalDisposed = { + type: "global.disposed" properties: { - text: string + [key: string]: unknown } } -export type EventTuiCommandExecute = { - type: "tui.command.execute" +export type EventLspClientDiagnostics = { + type: "lsp.client.diagnostics" properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string + serverID: string + path: string } } -export type EventTuiToastShow = { - type: "tui.toast.show" +export type EventLspUpdated = { + type: "lsp.updated" properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ - duration?: number + [key: string]: unknown } } -export type EventTuiSessionSelect = { - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } +export type OutputFormatText = { + type: "text" } -export type EventMcpToolsChanged = { - type: "mcp.tools.changed" - properties: { - server: string - } +export type JsonSchema = { + [key: string]: unknown } -export type EventMcpBrowserOpenFailed = { - type: "mcp.browser.open.failed" - properties: { - mcpName: string - url: string - } +export type OutputFormatJsonSchema = { + type: "json_schema" + schema: JsonSchema + retryCount?: number } -export type EventCommandExecuted = { - type: "command.executed" - properties: { - name: string - sessionID: string - arguments: string - messageID: string - } -} +export type OutputFormat = OutputFormatText | OutputFormatJsonSchema export type FileDiff = { file: string @@ -356,12 +230,29 @@ export type FileDiff = { status?: "added" | "deleted" | "modified" } -export type EventSessionDiff = { - type: "session.diff" - properties: { - sessionID: string - diff: Array +export type UserMessage = { + id: string + sessionID: string + role: "user" + time: { + created: number + } + format?: OutputFormat + summary?: { + title?: string + body?: string + diffs: Array } + agent: string + model: { + providerID: string + modelID: string + } + system?: string + tools?: { + [key: string]: boolean + } + variant?: string } export type ProviderAuthError = { @@ -413,147 +304,16 @@ export type ApiError = { name: "APIError" data: { message: string - statusCode?: number - isRetryable: boolean - responseHeaders?: { - [key: string]: string - } - responseBody?: string - metadata?: { - [key: string]: string - } - } -} - -export type EventSessionError = { - type: "session.error" - properties: { - sessionID?: string - error?: - | ProviderAuthError - | UnknownError - | MessageOutputLengthError - | MessageAbortedError - | StructuredOutputError - | ContextOverflowError - | ApiError - } -} - -export type EventVcsBranchUpdated = { - type: "vcs.branch.updated" - properties: { - branch?: string - } -} - -export type EventWorkspaceReady = { - type: "workspace.ready" - properties: { - name: string - } -} - -export type EventWorkspaceFailed = { - type: "workspace.failed" - properties: { - message: string - } -} - -export type Pty = { - id: string - title: string - command: string - args: Array - cwd: string - status: "running" | "exited" - pid: number -} - -export type EventPtyCreated = { - type: "pty.created" - properties: { - info: Pty - } -} - -export type EventPtyUpdated = { - type: "pty.updated" - properties: { - info: Pty - } -} - -export type EventPtyExited = { - type: "pty.exited" - properties: { - id: string - exitCode: number - } -} - -export type EventPtyDeleted = { - type: "pty.deleted" - properties: { - id: string - } -} - -export type EventWorktreeReady = { - type: "worktree.ready" - properties: { - name: string - branch: string - } -} - -export type EventWorktreeFailed = { - type: "worktree.failed" - properties: { - message: string - } -} - -export type OutputFormatText = { - type: "text" -} - -export type JsonSchema = { - [key: string]: unknown -} - -export type OutputFormatJsonSchema = { - type: "json_schema" - schema: JsonSchema - retryCount?: number -} - -export type OutputFormat = OutputFormatText | OutputFormatJsonSchema - -export type UserMessage = { - id: string - sessionID: string - role: "user" - time: { - created: number - } - format?: OutputFormat - summary?: { - title?: string - body?: string - diffs: Array - } - agent: string - model: { - providerID: string - modelID: string - } - system?: string - tools?: { - [key: string]: boolean + statusCode?: number + isRetryable: boolean + responseHeaders?: { + [key: string]: string + } + responseBody?: string + metadata?: { + [key: string]: string + } } - variant?: string } export type AssistantMessage = { @@ -603,7 +363,6 @@ export type Message = UserMessage | AssistantMessage export type EventMessageUpdated = { type: "message.updated" properties: { - sessionID: string info: Message } } @@ -843,6 +602,14 @@ export type AgentPart = { } } +export type SkillPart = { + id: string + sessionID: string + messageID: string + type: "skill" + name: string +} + export type RetryPart = { id: string sessionID: string @@ -875,15 +642,25 @@ export type Part = | SnapshotPart | PatchPart | AgentPart + | SkillPart | RetryPart | CompactionPart export type EventMessagePartUpdated = { type: "message.part.updated" properties: { - sessionID: string part: Part - time: number + } +} + +export type EventMessagePartDelta = { + type: "message.part.delta" + properties: { + sessionID: string + messageID: string + partID: string + field: string + delta: string } } @@ -896,6 +673,169 @@ export type EventMessagePartRemoved = { } } +export type SessionStatus = + | { + type: "idle" + } + | { + type: "retry" + attempt: number + message: string + next: number + } + | { + type: "busy" + } + +export type EventSessionStatus = { + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + +export type EventSessionIdle = { + type: "session.idle" + properties: { + sessionID: string + } +} + +export type EventSessionCompacted = { + type: "session.compacted" + properties: { + sessionID: string + } +} + +export type Todo = { + /** + * Brief description of the task + */ + content: string + /** + * Current status of the task: pending, in_progress, completed, cancelled + */ + status: string + /** + * Priority level of the task: high, medium, low + */ + priority: string +} + +export type EventTodoUpdated = { + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } +} + +export type EventTuiPromptAppend = { + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + /** + * Duration in milliseconds + */ + duration?: number + } +} + +export type EventTuiSessionSelect = { + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type EventMcpToolsChanged = { + type: "mcp.tools.changed" + properties: { + server: string + } +} + +export type EventMcpBrowserOpenFailed = { + type: "mcp.browser.open.failed" + properties: { + mcpName: string + url: string + } +} + +export type EventCommandExecuted = { + type: "command.executed" + properties: { + name: string + sessionID: string + arguments: string + messageID: string + } +} + +export type EventSessionQueueChanged = { + type: "session.queue.changed" + properties: { + sessionID: string + queue: Array<{ + id: string + text: string + time: number + mode: "queue" | "steer" + }> + } +} + +export type EventSessionSkillChanged = { + type: "session.skill.changed" + properties: { + sessionID: string + skills: Array<{ + name: string + added_at: number + token_estimate: number | null + }> + } +} + export type PermissionAction = "allow" | "deny" | "ask" export type PermissionRule = { @@ -923,43 +863,131 @@ export type Session = { url: string } title: string - version: string - time: { - created: number - updated: number - compacting?: number - archived?: number + version: string + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + +export type EventSessionCreated = { + type: "session.created" + properties: { + info: Session + } +} + +export type EventSessionUpdated = { + type: "session.updated" + properties: { + info: Session + } +} + +export type EventSessionDeleted = { + type: "session.deleted" + properties: { + info: Session + } +} + +export type EventSessionDiff = { + type: "session.diff" + properties: { + sessionID: string + diff: Array + } +} + +export type EventSessionError = { + type: "session.error" + properties: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ApiError + } +} + +export type EventWorkspaceReady = { + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + type: "workspace.failed" + properties: { + message: string + } +} + +export type Pty = { + id: string + title: string + command: string + args: Array + cwd: string + status: "running" | "exited" + pid: number +} + +export type EventPtyCreated = { + type: "pty.created" + properties: { + info: Pty } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string +} + +export type EventPtyUpdated = { + type: "pty.updated" + properties: { + info: Pty } } -export type EventSessionCreated = { - type: "session.created" +export type EventPtyExited = { + type: "pty.exited" properties: { - sessionID: string - info: Session + id: string + exitCode: number } } -export type EventSessionUpdated = { - type: "session.updated" +export type EventPtyDeleted = { + type: "pty.deleted" properties: { - sessionID: string - info: Session + id: string } } -export type EventSessionDeleted = { - type: "session.deleted" +export type EventWorktreeReady = { + type: "worktree.ready" properties: { - sessionID: string - info: Session + name: string + branch: string + } +} + +export type EventWorktreeFailed = { + type: "worktree.failed" + properties: { + message: string } } @@ -967,22 +995,27 @@ export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable | EventProjectUpdated + | EventFileEdited | EventServerInstanceDisposed + | EventFileWatcherUpdated + | EventPermissionAsked + | EventPermissionReplied + | EventVcsBranchUpdated + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected | EventServerConnected | EventGlobalDisposed | EventLspClientDiagnostics | EventLspUpdated + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated | EventMessagePartDelta - | EventPermissionAsked - | EventPermissionReplied + | EventMessagePartRemoved | EventSessionStatus | EventSessionIdle - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected | EventSessionCompacted - | EventFileEdited - | EventFileWatcherUpdated | EventTodoUpdated | EventTuiPromptAppend | EventTuiCommandExecute @@ -991,9 +1024,13 @@ export type Event = | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted + | EventSessionQueueChanged + | EventSessionSkillChanged + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted | EventSessionDiff | EventSessionError - | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed | EventPtyCreated @@ -1002,119 +1039,12 @@ export type Event = | EventPtyDeleted | EventWorktreeReady | EventWorktreeFailed - | EventMessageUpdated - | EventMessageRemoved - | EventMessagePartUpdated - | EventMessagePartRemoved - | EventSessionCreated - | EventSessionUpdated - | EventSessionDeleted export type GlobalEvent = { directory: string payload: Event } -export type SyncEventMessageUpdated = { - type: "message.updated.1" - aggregate: "sessionID" - data: { - sessionID: string - info: Message - } -} - -export type SyncEventMessageRemoved = { - type: "message.removed.1" - aggregate: "sessionID" - data: { - sessionID: string - messageID: string - } -} - -export type SyncEventMessagePartUpdated = { - type: "message.part.updated.1" - aggregate: "sessionID" - data: { - sessionID: string - part: Part - time: number - } -} - -export type SyncEventMessagePartRemoved = { - type: "message.part.removed.1" - aggregate: "sessionID" - data: { - sessionID: string - messageID: string - partID: string - } -} - -export type SyncEventSessionCreated = { - type: "session.created.1" - aggregate: "sessionID" - data: { - sessionID: string - info: Session - } -} - -export type SyncEventSessionUpdated = { - type: "session.updated.1" - aggregate: "sessionID" - data: { - sessionID: string - info: { - id: string | null - slug: string | null - projectID: string | null - workspaceID: string | null - directory: string | null - parentID: string | null - summary: { - additions: number - deletions: number - files: number - diffs?: Array - } | null - share?: { - url: string | null - } - title: string | null - version: string | null - time?: { - created: number | null - updated: number | null - compacting: number | null - archived: number | null - } - permission: PermissionRuleset | null - revert: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } | null - } - } -} - -export type SyncEventSessionDeleted = { - type: "session.deleted.1" - aggregate: "sessionID" - data: { - sessionID: string - info: Session - } -} - -export type SyncEvent = { - payload: SyncEvent -} - /** * Log level */ @@ -1166,6 +1096,7 @@ export type PermissionConfig = task?: PermissionRuleConfig external_directory?: PermissionRuleConfig todowrite?: PermissionActionConfig + todoread?: PermissionActionConfig question?: PermissionActionConfig webfetch?: PermissionActionConfig websearch?: PermissionActionConfig @@ -1447,19 +1378,11 @@ export type Config = { watcher?: { ignore?: Array } + plugin?: Array /** * Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true. */ snapshot?: boolean - plugin?: Array< - | string - | [ - string, - { - [key: string]: unknown - }, - ] - > /** * Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing */ @@ -1593,6 +1516,10 @@ export type Config = { * Token buffer for compaction. Leaves enough window to avoid overflow during compaction. */ reserved?: number + /** + * Strategy for handling thinking blocks that cause API errors. 'none' (default) sends thinking blocks as-is (original behavior). 'strip' removes thinking from last message before sending (prevents errors proactively). 'compact' preserves thinking but auto-compacts on error (retries with summarized context). + */ + thinking_strategy?: "none" | "strip" | "compact" } experimental?: { disable_paste_summary?: boolean @@ -1612,6 +1539,10 @@ export type Config = { * Continue the agent loop when a tool call is denied */ continue_loop_on_deny?: boolean + /** + * Enable plan mode — AI plans before executing, asks for confirmation + */ + plan_mode?: boolean /** * Timeout in milliseconds for model context protocol (MCP) requests */ @@ -1831,6 +1762,10 @@ export type McpResource = { client: string } +export type EnhanceResult = { + text: string +} + export type TextPartInput = { id?: string type: "text" @@ -1879,6 +1814,12 @@ export type SubtaskPartInput = { command?: string } +export type SkillPartInput = { + id?: string + type: "skill" + name: string +} + export type ProviderAuthMethod = { type: "oauth" | "api" label: string @@ -2002,7 +1943,7 @@ export type Path = { } export type VcsInfo = { - branch?: string + branch: string } export type Command = { @@ -2086,23 +2027,6 @@ export type GlobalEventResponses = { export type GlobalEventResponse = GlobalEventResponses[keyof GlobalEventResponses] -export type GlobalSyncEventSubscribeData = { - body?: never - path?: never - query?: never - url: "/global/sync-event" -} - -export type GlobalSyncEventSubscribeResponses = { - /** - * Event stream - */ - 200: SyncEvent -} - -export type GlobalSyncEventSubscribeResponse = - GlobalSyncEventSubscribeResponses[keyof GlobalSyncEventSubscribeResponses] - export type GlobalConfigGetData = { body?: never path?: never @@ -2160,41 +2084,6 @@ export type GlobalDisposeResponses = { export type GlobalDisposeResponse = GlobalDisposeResponses[keyof GlobalDisposeResponses] -export type GlobalUpgradeData = { - body?: { - target?: string - } - path?: never - query?: never - url: "/global/upgrade" -} - -export type GlobalUpgradeErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type GlobalUpgradeError = GlobalUpgradeErrors[keyof GlobalUpgradeErrors] - -export type GlobalUpgradeResponses = { - /** - * Upgrade result - */ - 200: - | { - success: true - version: string - } - | { - success: false - error: string - } -} - -export type GlobalUpgradeResponse = GlobalUpgradeResponses[keyof GlobalUpgradeResponses] - export type AuthRemoveData = { body?: never path: { @@ -2249,53 +2138,6 @@ export type AuthSetResponses = { export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] -export type AppLogData = { - body?: { - /** - * Service name for the log entry - */ - service: string - /** - * Log level - */ - level: "debug" | "info" | "error" | "warn" - /** - * Log message - */ - message: string - /** - * Additional metadata for the log entry - */ - extra?: { - [key: string]: unknown - } - } - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/log" -} - -export type AppLogErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type AppLogError = AppLogErrors[keyof AppLogErrors] - -export type AppLogResponses = { - /** - * Log entry written successfully - */ - 200: boolean -} - -export type AppLogResponse = AppLogResponses[keyof AppLogResponses] - export type ProjectListData = { body?: never path?: never @@ -2968,6 +2810,38 @@ export type ExperimentalResourceListResponses = { export type ExperimentalResourceListResponse = ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses] +export type ExperimentalEnhanceData = { + body?: { + text: string + providerID?: string + modelID?: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/enhance" +} + +export type ExperimentalEnhanceErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalEnhanceError = ExperimentalEnhanceErrors[keyof ExperimentalEnhanceErrors] + +export type ExperimentalEnhanceResponses = { + /** + * Enhanced prompt text + */ + 200: EnhanceResult +} + +export type ExperimentalEnhanceResponse = ExperimentalEnhanceResponses[keyof ExperimentalEnhanceResponses] + export type SessionListData = { body?: never path?: never @@ -3527,7 +3401,7 @@ export type SessionPromptData = { format?: OutputFormat system?: string variant?: string - parts: Array + parts: Array } path: { sessionID: string @@ -3612,7 +3486,264 @@ export type SessionMessageData = { url: "/session/{sessionID}/message/{messageID}" } -export type SessionMessageErrors = { +export type SessionMessageErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionMessageError = SessionMessageErrors[keyof SessionMessageErrors] + +export type SessionMessageResponses = { + /** + * Message + */ + 200: { + info: Message + parts: Array + } +} + +export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses] + +export type PartDeleteData = { + body?: never + path: { + sessionID: string + messageID: string + partID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/message/{messageID}/part/{partID}" +} + +export type PartDeleteErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors] + +export type PartDeleteResponses = { + /** + * Successfully deleted part + */ + 200: boolean +} + +export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses] + +export type PartUpdateData = { + body?: Part + path: { + sessionID: string + messageID: string + partID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/message/{messageID}/part/{partID}" +} + +export type PartUpdateErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors] + +export type PartUpdateResponses = { + /** + * Successfully updated part + */ + 200: Part +} + +export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses] + +export type SessionPromptAsyncData = { + body?: { + messageID?: string + model?: { + providerID: string + modelID: string + } + agent?: string + noReply?: boolean + /** + * @deprecated tools and permissions have been merged, you can set permissions on the session itself now + */ + tools?: { + [key: string]: boolean + } + format?: OutputFormat + system?: string + variant?: string + parts: Array + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/prompt_async" +} + +export type SessionPromptAsyncErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionPromptAsyncError = SessionPromptAsyncErrors[keyof SessionPromptAsyncErrors] + +export type SessionPromptAsyncResponses = { + /** + * Prompt accepted + */ + 204: void +} + +export type SessionPromptAsyncResponse = SessionPromptAsyncResponses[keyof SessionPromptAsyncResponses] + +export type SessionCommandData = { + body?: { + messageID?: string + agent?: string + model?: string + arguments: string + command: string + variant?: string + parts?: Array<{ + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource + }> + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/command" +} + +export type SessionCommandErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionCommandError = SessionCommandErrors[keyof SessionCommandErrors] + +export type SessionCommandResponses = { + /** + * Created message + */ + 200: { + info: AssistantMessage + parts: Array + } +} + +export type SessionCommandResponse = SessionCommandResponses[keyof SessionCommandResponses] + +export type SessionShellData = { + body?: { + agent: string + model?: { + providerID: string + modelID: string + } + command: string + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/shell" +} + +export type SessionShellErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionShellError = SessionShellErrors[keyof SessionShellErrors] + +export type SessionShellResponses = { + /** + * Created message + */ + 200: AssistantMessage +} + +export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses] + +export type SessionRevertData = { + body?: { + messageID: string + partID?: string + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/revert" +} + +export type SessionRevertErrors = { /** * Bad request */ @@ -3623,35 +3754,30 @@ export type SessionMessageErrors = { 404: NotFoundError } -export type SessionMessageError = SessionMessageErrors[keyof SessionMessageErrors] +export type SessionRevertError = SessionRevertErrors[keyof SessionRevertErrors] -export type SessionMessageResponses = { +export type SessionRevertResponses = { /** - * Message + * Updated session */ - 200: { - info: Message - parts: Array - } + 200: Session } -export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses] +export type SessionRevertResponse = SessionRevertResponses[keyof SessionRevertResponses] -export type PartDeleteData = { +export type SessionUnrevertData = { body?: never path: { sessionID: string - messageID: string - partID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/message/{messageID}/part/{partID}" + url: "/session/{sessionID}/unrevert" } -export type PartDeleteErrors = { +export type SessionUnrevertErrors = { /** * Bad request */ @@ -3662,32 +3788,33 @@ export type PartDeleteErrors = { 404: NotFoundError } -export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors] +export type SessionUnrevertError = SessionUnrevertErrors[keyof SessionUnrevertErrors] -export type PartDeleteResponses = { +export type SessionUnrevertResponses = { /** - * Successfully deleted part + * Updated session */ - 200: boolean + 200: Session } -export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses] +export type SessionUnrevertResponse = SessionUnrevertResponses[keyof SessionUnrevertResponses] -export type PartUpdateData = { - body?: Part +export type SessionSteerListData = { + body?: never path: { + /** + * Session ID + */ sessionID: string - messageID: string - partID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/message/{messageID}/part/{partID}" + url: "/session/{sessionID}/steer" } -export type PartUpdateErrors = { +export type SessionSteerListErrors = { /** * Bad request */ @@ -3698,48 +3825,47 @@ export type PartUpdateErrors = { 404: NotFoundError } -export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors] +export type SessionSteerListError = SessionSteerListErrors[keyof SessionSteerListErrors] -export type PartUpdateResponses = { +export type SessionSteerListResponses = { /** - * Successfully updated part + * Pending steered messages */ - 200: Part + 200: Array<{ + id: string + text: string + time: number + mode: "queue" | "steer" + }> } -export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses] +export type SessionSteerListResponse = SessionSteerListResponses[keyof SessionSteerListResponses] -export type SessionPromptAsyncData = { +export type SessionSteerData = { body?: { - messageID?: string - model?: { - providerID: string - modelID: string - } - agent?: string - noReply?: boolean /** - * @deprecated tools and permissions have been merged, you can set permissions on the session itself now + * The message text to inject */ - tools?: { - [key: string]: boolean - } - format?: OutputFormat - system?: string - variant?: string - parts: Array + text: string + /** + * queue waits for turn end, steer injects mid-turn + */ + mode?: "queue" | "steer" } path: { + /** + * Session ID + */ sessionID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/prompt_async" + url: "/session/{sessionID}/steer" } -export type SessionPromptAsyncErrors = { +export type SessionSteerErrors = { /** * Bad request */ @@ -3750,45 +3876,42 @@ export type SessionPromptAsyncErrors = { 404: NotFoundError } -export type SessionPromptAsyncError = SessionPromptAsyncErrors[keyof SessionPromptAsyncErrors] +export type SessionSteerError = SessionSteerErrors[keyof SessionSteerErrors] -export type SessionPromptAsyncResponses = { +export type SessionSteerResponses = { /** - * Prompt accepted + * Queued message */ - 204: void + 200: { + id: string + text: string + time: number + mode: "queue" | "steer" + } } -export type SessionPromptAsyncResponse = SessionPromptAsyncResponses[keyof SessionPromptAsyncResponses] +export type SessionSteerResponse = SessionSteerResponses[keyof SessionSteerResponses] -export type SessionCommandData = { - body?: { - messageID?: string - agent?: string - model?: string - arguments: string - command: string - variant?: string - parts?: Array<{ - id?: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource - }> - } +export type SessionSteerRemoveData = { + body?: never path: { + /** + * Session ID + */ sessionID: string + /** + * Steer message ID + */ + steerID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/command" + url: "/session/{sessionID}/steer/{steerID}" } -export type SessionCommandErrors = { +export type SessionSteerRemoveErrors = { /** * Bad request */ @@ -3799,40 +3922,33 @@ export type SessionCommandErrors = { 404: NotFoundError } -export type SessionCommandError = SessionCommandErrors[keyof SessionCommandErrors] +export type SessionSteerRemoveError = SessionSteerRemoveErrors[keyof SessionSteerRemoveErrors] -export type SessionCommandResponses = { +export type SessionSteerRemoveResponses = { /** - * Created message + * Whether the message was found and removed */ - 200: { - info: AssistantMessage - parts: Array - } + 200: boolean } -export type SessionCommandResponse = SessionCommandResponses[keyof SessionCommandResponses] +export type SessionSteerRemoveResponse = SessionSteerRemoveResponses[keyof SessionSteerRemoveResponses] -export type SessionShellData = { - body?: { - agent: string - model?: { - providerID: string - modelID: string - } - command: string - } +export type SessionSkillListData = { + body?: never path: { + /** + * Session ID + */ sessionID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/shell" + url: "/session/{sessionID}/skill" } -export type SessionShellErrors = { +export type SessionSkillListErrors = { /** * Bad request */ @@ -3843,33 +3959,42 @@ export type SessionShellErrors = { 404: NotFoundError } -export type SessionShellError = SessionShellErrors[keyof SessionShellErrors] +export type SessionSkillListError = SessionSkillListErrors[keyof SessionSkillListErrors] -export type SessionShellResponses = { +export type SessionSkillListResponses = { /** - * Created message + * Active skills */ - 200: AssistantMessage + 200: Array<{ + name: string + added_at: number + token_estimate: number | null + }> } -export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses] +export type SessionSkillListResponse = SessionSkillListResponses[keyof SessionSkillListResponses] -export type SessionRevertData = { +export type SessionSkillAddData = { body?: { - messageID: string - partID?: string + /** + * Skill name (e.g., 'brainstorming') + */ + name: string } path: { + /** + * Session ID + */ sessionID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/revert" + url: "/session/{sessionID}/skill" } -export type SessionRevertErrors = { +export type SessionSkillAddErrors = { /** * Bad request */ @@ -3880,30 +4005,41 @@ export type SessionRevertErrors = { 404: NotFoundError } -export type SessionRevertError = SessionRevertErrors[keyof SessionRevertErrors] +export type SessionSkillAddError = SessionSkillAddErrors[keyof SessionSkillAddErrors] -export type SessionRevertResponses = { +export type SessionSkillAddResponses = { /** - * Updated session + * Active skills after addition */ - 200: Session + 200: Array<{ + name: string + added_at: number + token_estimate: number | null + }> } -export type SessionRevertResponse = SessionRevertResponses[keyof SessionRevertResponses] +export type SessionSkillAddResponse = SessionSkillAddResponses[keyof SessionSkillAddResponses] -export type SessionUnrevertData = { +export type SessionSkillRemoveData = { body?: never path: { + /** + * Session ID + */ sessionID: string + /** + * Skill name to remove + */ + skillName: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/unrevert" + url: "/session/{sessionID}/skill/{skillName}" } -export type SessionUnrevertErrors = { +export type SessionSkillRemoveErrors = { /** * Bad request */ @@ -3914,16 +4050,16 @@ export type SessionUnrevertErrors = { 404: NotFoundError } -export type SessionUnrevertError = SessionUnrevertErrors[keyof SessionUnrevertErrors] +export type SessionSkillRemoveError = SessionSkillRemoveErrors[keyof SessionSkillRemoveErrors] -export type SessionUnrevertResponses = { +export type SessionSkillRemoveResponses = { /** - * Updated session + * Whether the skill was found and removed */ - 200: Session + 200: boolean } -export type SessionUnrevertResponse = SessionUnrevertResponses[keyof SessionUnrevertResponses] +export type SessionSkillRemoveResponse = SessionSkillRemoveResponses[keyof SessionSkillRemoveResponses] export type PermissionRespondData = { body?: { @@ -4441,25 +4577,6 @@ export type FileStatusResponses = { export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] -export type EventSubscribeData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/event" -} - -export type EventSubscribeResponses = { - /** - * Event stream - */ - 200: Event -} - -export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] - export type McpStatusData = { body?: never path?: never @@ -5083,6 +5200,53 @@ export type CommandListResponses = { export type CommandListResponse = CommandListResponses[keyof CommandListResponses] +export type AppLogData = { + body?: { + /** + * Service name for the log entry + */ + service: string + /** + * Log level + */ + level: "debug" | "info" | "error" | "warn" + /** + * Log message + */ + message: string + /** + * Additional metadata for the log entry + */ + extra?: { + [key: string]: unknown + } + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/log" +} + +export type AppLogErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AppLogError = AppLogErrors[keyof AppLogErrors] + +export type AppLogResponses = { + /** + * Log entry written successfully + */ + 200: boolean +} + +export type AppLogResponse = AppLogResponses[keyof AppLogResponses] + export type AppAgentsData = { body?: never path?: never @@ -5163,3 +5327,22 @@ export type FormatterStatusResponses = { } export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] + +export type EventSubscribeData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/event" +} + +export type EventSubscribeResponses = { + /** + * Event stream + */ + 200: Event +} + +export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses]