Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83676b36cf | ||
|
|
398075f5df | ||
|
|
d4347e829d | ||
|
|
980b685393 | ||
|
|
b5c1cfb57f | ||
|
|
cd97572d0a | ||
|
|
b9ec4c7c4a | ||
|
|
2064568124 | ||
|
|
ad44af9d15 | ||
|
|
d331b484f9 | ||
|
|
4a38e70fa8 | ||
|
|
204ea319cb | ||
|
|
a2bfb5e556 | ||
|
|
f25f7ed0f5 | ||
|
|
29dbc0f57b | ||
|
|
544212fa9c | ||
|
|
f3eed731d6 | ||
|
|
6f1cabd3f4 | ||
|
|
15571d3d95 |
@@ -10,6 +10,7 @@
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | [Discordコミュニティ](https://discord.gg/PUwSMR9XNk)に参加して、コントリビューターや`oh-my-opencode`仲間とつながりましょう。 |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode`に関するニュースは私のXアカウントで投稿していましたが、無実の罪で凍結されたため、<br />[@justsisyphus](https://x.com/justsisyphus)が代わりに更新を投稿しています。 |
|
||||
> | [<img alt="GitHub Follow" src="https://img.shields.io/github/followers/code-yeongyu?style=flat-square&logo=github&labelColor=black&color=24292f" width="156px" />](https://github.com/code-yeongyu) | GitHubで[@code-yeongyu](https://github.com/code-yeongyu)をフォローして、他のプロジェクトもチェックしてください。 |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
|
||||
1040
README.ko.md
1040
README.ko.md
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | Join our [Discord community](https://discord.gg/PUwSMR9XNk) to connect with contributors and fellow `oh-my-opencode` users. |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | News and updates for `oh-my-opencode` used to be posted on my X account. <br /> Since it was suspended mistakenly, [@justsisyphus](https://x.com/justsisyphus) now posts updates on my behalf. |
|
||||
> | [<img alt="GitHub Follow" src="https://img.shields.io/github/followers/code-yeongyu?style=flat-square&logo=github&labelColor=black&color=24292f" width="156px" />](https://github.com/code-yeongyu) | Follow [@code-yeongyu](https://github.com/code-yeongyu) on GitHub for more projects. |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -41,7 +42,7 @@ No stupid token consumption massive subagents here. No bloat tools here.
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/issues)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
|
||||
|
||||
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
|
||||
[English](README.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -582,6 +583,7 @@ These tools enable agents to reference previous conversations and maintain conti
|
||||
- Use camelCase for function names
|
||||
```
|
||||
- **Online**: Project rules aren't everything. Built-in MCPs for extended capabilities:
|
||||
- **websearch**: Real-time web search powered by [Exa AI](https://exa.ai)
|
||||
- **context7**: Official documentation lookup
|
||||
- **grep_app**: Ultra-fast code search across public GitHub repos (great for finding implementation examples)
|
||||
|
||||
@@ -983,8 +985,9 @@ Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `sessio
|
||||
|
||||
### MCPs
|
||||
|
||||
Context7 and grep.app MCP enabled by default.
|
||||
Exa, Context7 and grep.app MCP enabled by default.
|
||||
|
||||
- **websearch**: Real-time web search powered by [Exa AI](https://exa.ai) - searches the web and returns relevant content
|
||||
- **context7**: Fetches up-to-date official documentation for libraries
|
||||
- **grep_app**: Ultra-fast code search across millions of public GitHub repositories via [grep.app](https://grep.app)
|
||||
|
||||
@@ -992,7 +995,7 @@ Don't want them? Disable via `disabled_mcps` in `~/.config/opencode/oh-my-openco
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["context7", "grep_app"]
|
||||
"disabled_mcps": ["websearch", "context7", "grep_app"]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | 加入我们的 [Discord 社区](https://discord.gg/PUwSMR9XNk),和贡献者们、`oh-my-opencode` 用户们一起交流。 |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 的消息之前在我的 X 账号发,但账号被无辜封了,<br />现在 [@justsisyphus](https://x.com/justsisyphus) 替我发更新。 |
|
||||
> | [<img alt="GitHub Follow" src="https://img.shields.io/github/followers/code-yeongyu?style=flat-square&logo=github&labelColor=black&color=24292f" width="156px" />](https://github.com/code-yeongyu) | 在 GitHub 上关注 [@code-yeongyu](https://github.com/code-yeongyu),了解更多项目。 |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
|
||||
@@ -1658,6 +1658,35 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"background_task": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"defaultConcurrency": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
},
|
||||
"providerConcurrency": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"modelConcurrency": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "2.13.2",
|
||||
"version": "2.14.0",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -239,6 +239,30 @@
|
||||
"created_at": "2026-01-06T04:36:53Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 532
|
||||
},
|
||||
{
|
||||
"name": "ananas-viber",
|
||||
"id": 241022041,
|
||||
"comment_id": 3714661395,
|
||||
"created_at": "2026-01-06T13:16:18Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 544
|
||||
},
|
||||
{
|
||||
"name": "JohnC0de",
|
||||
"id": 88864312,
|
||||
"comment_id": 3714978210,
|
||||
"created_at": "2026-01-06T14:45:26Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 543
|
||||
},
|
||||
{
|
||||
"name": "atripathy86",
|
||||
"id": 3656621,
|
||||
"comment_id": 3715631259,
|
||||
"created_at": "2026-01-06T17:32:32Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 550
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export const DOCUMENT_WRITER_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
export function createDocumentWriterAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions(["background_task"])
|
||||
const restrictions = createAgentToolRestrictions([])
|
||||
|
||||
return {
|
||||
description:
|
||||
|
||||
@@ -28,7 +28,6 @@ export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
return {
|
||||
|
||||
@@ -22,7 +22,7 @@ export const FRONTEND_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
export function createFrontendUiUxEngineerAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions(["background_task"])
|
||||
const restrictions = createAgentToolRestrictions([])
|
||||
|
||||
return {
|
||||
description:
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
|
||||
const DEFAULT_MODEL = "opencode/glm-4.7-free"
|
||||
|
||||
export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
category: "exploration",
|
||||
@@ -25,7 +25,6 @@ export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
return {
|
||||
@@ -39,7 +38,7 @@ export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig
|
||||
|
||||
You are **THE LIBRARIAN**, a specialized open-source codebase understanding agent.
|
||||
|
||||
Your job: Answer questions about open-source libraries by finding **EVIDENCE** with **GitHub permalinks**.
|
||||
Your job: Answer questions about open-source libraries. Provide **EVIDENCE** with **GitHub permalinks** when the question requires verification, implementation details, or current/version-specific information. For well-known APIs and stable concepts, answer directly from knowledge.
|
||||
|
||||
## CRITICAL: DATE AWARENESS
|
||||
|
||||
@@ -51,9 +50,13 @@ Your job: Answer questions about open-source libraries by finding **EVIDENCE** w
|
||||
|
||||
---
|
||||
|
||||
## PHASE 0: REQUEST CLASSIFICATION (MANDATORY FIRST STEP)
|
||||
## PHASE 0: ASSESS BEFORE SEARCHING
|
||||
|
||||
Classify EVERY request into one of these categories before taking action:
|
||||
**First**: Can you answer confidently from training knowledge? If yes, answer directly.
|
||||
|
||||
**Search when**: version-specific info, implementation internals, recent changes, unfamiliar libraries, user explicitly requests source/examples.
|
||||
|
||||
**If search needed**, classify into:
|
||||
|
||||
| Type | Trigger Examples | Tools |
|
||||
|------|------------------|-------|
|
||||
@@ -69,7 +72,7 @@ Classify EVERY request into one of these categories before taking action:
|
||||
### TYPE A: CONCEPTUAL QUESTION
|
||||
**Trigger**: "How do I...", "What is...", "Best practice for...", rough/general questions
|
||||
|
||||
**Execute in parallel (2+ calls)**:
|
||||
**If searching**, use tools as needed:
|
||||
\`\`\`
|
||||
Tool 1: context7_resolve-library-id("library-name")
|
||||
→ then context7_get-library-docs(id, topic: "specific-topic")
|
||||
@@ -101,7 +104,7 @@ Step 4: Construct permalink
|
||||
https://github.com/owner/repo/blob/<sha>/path/to/file#L10-L20
|
||||
\`\`\`
|
||||
|
||||
**Parallel acceleration (4+ calls)**:
|
||||
**For faster results, parallelize**:
|
||||
\`\`\`
|
||||
Tool 1: gh repo clone owner/repo \${TMPDIR:-/tmp}/repo -- --depth 1
|
||||
Tool 2: grep_app_searchGitHub(query: "function_name", repo: "owner/repo")
|
||||
@@ -114,7 +117,7 @@ Tool 4: context7_get-library-docs(id, topic: "relevant-api")
|
||||
### TYPE C: CONTEXT & HISTORY
|
||||
**Trigger**: "Why was this changed?", "What's the history?", "Related issues/PRs?"
|
||||
|
||||
**Execute in parallel (4+ calls)**:
|
||||
**Tools to use**:
|
||||
\`\`\`
|
||||
Tool 1: gh search issues "keyword" --repo owner/repo --state all --limit 10
|
||||
Tool 2: gh search prs "keyword" --repo owner/repo --state merged --limit 10
|
||||
@@ -136,7 +139,7 @@ gh api repos/owner/repo/pulls/<number>/files
|
||||
### TYPE D: COMPREHENSIVE RESEARCH
|
||||
**Trigger**: Complex questions, ambiguous requests, "deep dive into..."
|
||||
|
||||
**Execute ALL available tools in parallel (5+ calls)**:
|
||||
**Use multiple tools as needed**:
|
||||
\`\`\`
|
||||
// Documentation
|
||||
Tool 1: context7_resolve-library-id → context7_get-library-docs
|
||||
@@ -222,14 +225,16 @@ Use OS-appropriate temp directory:
|
||||
|
||||
---
|
||||
|
||||
## PARALLEL EXECUTION REQUIREMENTS
|
||||
## PARALLEL EXECUTION GUIDANCE
|
||||
|
||||
| Request Type | Minimum Parallel Calls |
|
||||
|--------------|----------------------|
|
||||
| TYPE A (Conceptual) | 3+ |
|
||||
| TYPE B (Implementation) | 4+ |
|
||||
| TYPE C (Context) | 4+ |
|
||||
| TYPE D (Comprehensive) | 6+ |
|
||||
When searching is needed, scale effort to question complexity:
|
||||
|
||||
| Request Type | Suggested Calls |
|
||||
|--------------|----------------|
|
||||
| TYPE A (Conceptual) | 1-2 |
|
||||
| TYPE B (Implementation) | 2-3 |
|
||||
| TYPE C (Context) | 2-3 |
|
||||
| TYPE D (Comprehensive) | 3-5 |
|
||||
|
||||
**Always vary queries** when using grep_app:
|
||||
\`\`\`
|
||||
|
||||
@@ -18,7 +18,6 @@ export function createMultimodalLookerAgent(
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
return {
|
||||
|
||||
@@ -102,7 +102,6 @@ export function createOracleAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
"write",
|
||||
"edit",
|
||||
"task",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
const base = {
|
||||
|
||||
@@ -232,6 +232,12 @@ export const RalphLoopConfigSchema = z.object({
|
||||
state_dir: z.string().optional(),
|
||||
})
|
||||
|
||||
export const BackgroundTaskConfigSchema = z.object({
|
||||
defaultConcurrency: z.number().min(1).optional(),
|
||||
providerConcurrency: z.record(z.string(), z.number().min(1)).optional(),
|
||||
modelConcurrency: z.record(z.string(), z.number().min(1)).optional(),
|
||||
})
|
||||
|
||||
export const OhMyOpenCodeConfigSchema = z.object({
|
||||
$schema: z.string().optional(),
|
||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||
@@ -248,11 +254,13 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
auto_update: z.boolean().optional(),
|
||||
skills: SkillsConfigSchema.optional(),
|
||||
ralph_loop: RalphLoopConfigSchema.optional(),
|
||||
background_task: BackgroundTaskConfigSchema.optional(),
|
||||
})
|
||||
|
||||
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
|
||||
export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>
|
||||
export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
|
||||
export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>
|
||||
export type AgentName = z.infer<typeof AgentNameSchema>
|
||||
export type HookName = z.infer<typeof HookNameSchema>
|
||||
export type BuiltinCommandName = z.infer<typeof BuiltinCommandNameSchema>
|
||||
|
||||
351
src/features/background-agent/concurrency.test.ts
Normal file
351
src/features/background-agent/concurrency.test.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
|
||||
describe("ConcurrencyManager.getConcurrencyLimit", () => {
|
||||
test("should return model-specific limit when modelConcurrency is set", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 5 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(5)
|
||||
})
|
||||
|
||||
test("should return provider limit when providerConcurrency is set for model provider", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
providerConcurrency: { anthropic: 3 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(3)
|
||||
})
|
||||
|
||||
test("should return provider limit even when modelConcurrency exists but doesn't match", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "google/gemini-3-pro": 5 },
|
||||
providerConcurrency: { anthropic: 3 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(3)
|
||||
})
|
||||
|
||||
test("should return default limit when defaultConcurrency is set", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
defaultConcurrency: 2
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(2)
|
||||
})
|
||||
|
||||
test("should return default 5 when no config provided", () => {
|
||||
// #given
|
||||
const manager = new ConcurrencyManager()
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(5)
|
||||
})
|
||||
|
||||
test("should return default 5 when config exists but no concurrency settings", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(5)
|
||||
})
|
||||
|
||||
test("should prioritize model-specific over provider-specific over default", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 10 },
|
||||
providerConcurrency: { anthropic: 5 },
|
||||
defaultConcurrency: 2
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-5")
|
||||
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3-pro")
|
||||
|
||||
// #then
|
||||
expect(modelLimit).toBe(10)
|
||||
expect(providerLimit).toBe(5)
|
||||
expect(defaultLimit).toBe(2)
|
||||
})
|
||||
|
||||
test("should handle models without provider part", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
providerConcurrency: { "custom-model": 4 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("custom-model")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(4)
|
||||
})
|
||||
|
||||
test("should return Infinity when defaultConcurrency is 0", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 0 }
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("any-model")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(Infinity)
|
||||
})
|
||||
|
||||
test("should return Infinity when providerConcurrency is 0", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
providerConcurrency: { anthropic: 0 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(Infinity)
|
||||
})
|
||||
|
||||
test("should return Infinity when modelConcurrency is 0", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 0 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(Infinity)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ConcurrencyManager.acquire/release", () => {
|
||||
let manager: ConcurrencyManager
|
||||
|
||||
beforeEach(() => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {}
|
||||
manager = new ConcurrencyManager(config)
|
||||
})
|
||||
|
||||
test("should allow acquiring up to limit", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 2 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then - both resolved without waiting
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should allow acquires up to default limit of 5", async () => {
|
||||
// #given - no config = default limit of 5
|
||||
|
||||
// #when
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then - all 5 resolved
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should queue when limit reached", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #when
|
||||
let resolved = false
|
||||
const waitPromise = manager.acquire("model-a").then(() => { resolved = true })
|
||||
|
||||
// Give microtask queue a chance to run
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - should still be waiting
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
// #when - release
|
||||
manager.release("model-a")
|
||||
await waitPromise
|
||||
|
||||
// #then - now resolved
|
||||
expect(resolved).toBe(true)
|
||||
})
|
||||
|
||||
test("should queue multiple tasks and process in order", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #when
|
||||
const order: string[] = []
|
||||
const task1 = manager.acquire("model-a").then(() => { order.push("1") })
|
||||
const task2 = manager.acquire("model-a").then(() => { order.push("2") })
|
||||
const task3 = manager.acquire("model-a").then(() => { order.push("3") })
|
||||
|
||||
// Give microtask queue a chance to run
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - none resolved yet
|
||||
expect(order).toEqual([])
|
||||
|
||||
// #when - release one at a time
|
||||
manager.release("model-a")
|
||||
await task1
|
||||
expect(order).toEqual(["1"])
|
||||
|
||||
manager.release("model-a")
|
||||
await task2
|
||||
expect(order).toEqual(["1", "2"])
|
||||
|
||||
manager.release("model-a")
|
||||
await task3
|
||||
expect(order).toEqual(["1", "2", "3"])
|
||||
})
|
||||
|
||||
test("should handle independent models separately", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #when - acquire different model
|
||||
const resolved = await Promise.race([
|
||||
manager.acquire("model-b").then(() => "resolved"),
|
||||
Promise.resolve("timeout").then(() => "timeout")
|
||||
])
|
||||
|
||||
// #then - different model should resolve immediately
|
||||
expect(resolved).toBe("resolved")
|
||||
})
|
||||
|
||||
test("should allow re-acquiring after release", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
await manager.acquire("model-a")
|
||||
manager.release("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle release when no acquire", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 2 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when - release without acquire
|
||||
manager.release("model-a")
|
||||
|
||||
// #then - should not throw
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle release when no prior acquire", () => {
|
||||
// #given - default config
|
||||
|
||||
// #when - release without acquire
|
||||
manager.release("model-a")
|
||||
|
||||
// #then - should not throw
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle multiple acquires and releases correctly", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 3 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// Release all
|
||||
manager.release("model-a")
|
||||
manager.release("model-a")
|
||||
manager.release("model-a")
|
||||
|
||||
// Should be able to acquire again
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should use model-specific limit for acquire", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 2 },
|
||||
defaultConcurrency: 5
|
||||
}
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("anthropic/claude-sonnet-4-5")
|
||||
await manager.acquire("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #when
|
||||
let resolved = false
|
||||
const waitPromise = manager.acquire("anthropic/claude-sonnet-4-5").then(() => { resolved = true })
|
||||
|
||||
// Give microtask queue a chance to run
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - should be waiting (model-specific limit is 2)
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
// Cleanup
|
||||
manager.release("anthropic/claude-sonnet-4-5")
|
||||
await waitPromise
|
||||
})
|
||||
})
|
||||
66
src/features/background-agent/concurrency.ts
Normal file
66
src/features/background-agent/concurrency.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
|
||||
export class ConcurrencyManager {
|
||||
private config?: BackgroundTaskConfig
|
||||
private counts: Map<string, number> = new Map()
|
||||
private queues: Map<string, Array<() => void>> = new Map()
|
||||
|
||||
constructor(config?: BackgroundTaskConfig) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
getConcurrencyLimit(model: string): number {
|
||||
const modelLimit = this.config?.modelConcurrency?.[model]
|
||||
if (modelLimit !== undefined) {
|
||||
return modelLimit === 0 ? Infinity : modelLimit
|
||||
}
|
||||
const provider = model.split('/')[0]
|
||||
const providerLimit = this.config?.providerConcurrency?.[provider]
|
||||
if (providerLimit !== undefined) {
|
||||
return providerLimit === 0 ? Infinity : providerLimit
|
||||
}
|
||||
const defaultLimit = this.config?.defaultConcurrency
|
||||
if (defaultLimit !== undefined) {
|
||||
return defaultLimit === 0 ? Infinity : defaultLimit
|
||||
}
|
||||
return 5
|
||||
}
|
||||
|
||||
async acquire(model: string): Promise<void> {
|
||||
const limit = this.getConcurrencyLimit(model)
|
||||
if (limit === Infinity) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = this.counts.get(model) ?? 0
|
||||
if (current < limit) {
|
||||
this.counts.set(model, current + 1)
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const queue = this.queues.get(model) ?? []
|
||||
queue.push(resolve)
|
||||
this.queues.set(model, queue)
|
||||
})
|
||||
}
|
||||
|
||||
release(model: string): void {
|
||||
const limit = this.getConcurrencyLimit(model)
|
||||
if (limit === Infinity) {
|
||||
return
|
||||
}
|
||||
|
||||
const queue = this.queues.get(model)
|
||||
if (queue && queue.length > 0) {
|
||||
const next = queue.shift()!
|
||||
this.counts.set(model, this.counts.get(model) ?? 0)
|
||||
next()
|
||||
} else {
|
||||
const current = this.counts.get(model) ?? 0
|
||||
if (current > 0) {
|
||||
this.counts.set(model, current - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./types"
|
||||
export { BackgroundManager } from "./manager"
|
||||
export { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
@@ -302,6 +302,74 @@ describe("BackgroundManager.getAllDescendantTasks", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.notifyParentSession - release ordering", () => {
|
||||
test("should unblock queued task even when prompt hangs", async () => {
|
||||
// #given - concurrency limit 1, task1 running, task2 waiting
|
||||
const { ConcurrencyManager } = await import("./concurrency")
|
||||
const concurrencyManager = new ConcurrencyManager({ defaultConcurrency: 1 })
|
||||
|
||||
await concurrencyManager.acquire("explore")
|
||||
|
||||
let task2Resolved = false
|
||||
const task2Promise = concurrencyManager.acquire("explore").then(() => {
|
||||
task2Resolved = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(task2Resolved).toBe(false)
|
||||
|
||||
// #when - simulate notifyParentSession: release BEFORE prompt (fixed behavior)
|
||||
let promptStarted = false
|
||||
const simulateNotifyParentSession = async () => {
|
||||
concurrencyManager.release("explore")
|
||||
|
||||
promptStarted = true
|
||||
await new Promise(() => {})
|
||||
}
|
||||
|
||||
simulateNotifyParentSession()
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - task2 should be unblocked even though prompt never completes
|
||||
expect(promptStarted).toBe(true)
|
||||
await task2Promise
|
||||
expect(task2Resolved).toBe(true)
|
||||
})
|
||||
|
||||
test("should keep queue blocked if release is after prompt (demonstrates the bug)", async () => {
|
||||
// #given - same setup
|
||||
const { ConcurrencyManager } = await import("./concurrency")
|
||||
const concurrencyManager = new ConcurrencyManager({ defaultConcurrency: 1 })
|
||||
|
||||
await concurrencyManager.acquire("explore")
|
||||
|
||||
let task2Resolved = false
|
||||
concurrencyManager.acquire("explore").then(() => {
|
||||
task2Resolved = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(task2Resolved).toBe(false)
|
||||
|
||||
// #when - simulate BUGGY behavior: release AFTER prompt (in finally)
|
||||
const simulateBuggyNotifyParentSession = async () => {
|
||||
try {
|
||||
await new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 50))
|
||||
} finally {
|
||||
concurrencyManager.release("explore")
|
||||
}
|
||||
}
|
||||
|
||||
await simulateBuggyNotifyParentSession().catch(() => {})
|
||||
|
||||
// #then - task2 resolves only after prompt completes (blocked during hang)
|
||||
await Promise.resolve()
|
||||
expect(task2Resolved).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.pruneStaleTasksAndNotifications", () => {
|
||||
let manager: MockBackgroundManager
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
LaunchInput,
|
||||
} from "./types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
import {
|
||||
findNearestMessageWithFields,
|
||||
MESSAGE_STORAGE,
|
||||
@@ -60,12 +62,14 @@ export class BackgroundManager {
|
||||
private client: OpencodeClient
|
||||
private directory: string
|
||||
private pollingInterval?: ReturnType<typeof setInterval>
|
||||
private concurrencyManager: ConcurrencyManager
|
||||
|
||||
constructor(ctx: PluginInput) {
|
||||
constructor(ctx: PluginInput, config?: BackgroundTaskConfig) {
|
||||
this.tasks = new Map()
|
||||
this.notifications = new Map()
|
||||
this.client = ctx.client
|
||||
this.directory = ctx.directory
|
||||
this.concurrencyManager = new ConcurrencyManager(config)
|
||||
}
|
||||
|
||||
async launch(input: LaunchInput): Promise<BackgroundTask> {
|
||||
@@ -73,14 +77,22 @@ export class BackgroundManager {
|
||||
throw new Error("Agent parameter is required")
|
||||
}
|
||||
|
||||
const model = input.agent
|
||||
|
||||
await this.concurrencyManager.acquire(model)
|
||||
|
||||
const createResult = await this.client.session.create({
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `Background: ${input.description}`,
|
||||
},
|
||||
}).catch((error) => {
|
||||
this.concurrencyManager.release(model)
|
||||
throw error
|
||||
})
|
||||
|
||||
if (createResult.error) {
|
||||
this.concurrencyManager.release(model)
|
||||
throw new Error(`Failed to create background session: ${createResult.error}`)
|
||||
}
|
||||
|
||||
@@ -102,6 +114,7 @@ export class BackgroundManager {
|
||||
lastUpdate: new Date(),
|
||||
},
|
||||
parentModel: input.parentModel,
|
||||
model,
|
||||
}
|
||||
|
||||
this.tasks.set(task.id, task)
|
||||
@@ -132,6 +145,9 @@ export class BackgroundManager {
|
||||
existingTask.error = errorMessage
|
||||
}
|
||||
existingTask.completedAt = new Date()
|
||||
if (existingTask.model) {
|
||||
this.concurrencyManager.release(existingTask.model)
|
||||
}
|
||||
this.markForNotification(existingTask)
|
||||
this.notifyParentSession(existingTask)
|
||||
}
|
||||
@@ -253,6 +269,9 @@ export class BackgroundManager {
|
||||
task.error = "Session deleted"
|
||||
}
|
||||
|
||||
if (task.model) {
|
||||
this.concurrencyManager.release(task.model)
|
||||
}
|
||||
this.tasks.delete(task.id)
|
||||
this.clearNotificationsForTask(task.id)
|
||||
subagentSessions.delete(sessionID)
|
||||
@@ -330,6 +349,10 @@ export class BackgroundManager {
|
||||
|
||||
const taskId = task.id
|
||||
setTimeout(async () => {
|
||||
if (task.model) {
|
||||
this.concurrencyManager.release(task.model)
|
||||
}
|
||||
|
||||
try {
|
||||
const messageDir = getMessageDir(task.parentSessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
@@ -352,7 +375,6 @@ export class BackgroundManager {
|
||||
} catch (error) {
|
||||
log("[background-agent] prompt failed:", String(error))
|
||||
} finally {
|
||||
// Always clean up both maps to prevent memory leaks
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
@@ -391,6 +413,9 @@ export class BackgroundManager {
|
||||
task.status = "error"
|
||||
task.error = "Task timed out after 30 minutes"
|
||||
task.completedAt = new Date()
|
||||
if (task.model) {
|
||||
this.concurrencyManager.release(task.model)
|
||||
}
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
subagentSessions.delete(task.sessionID)
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface BackgroundTask {
|
||||
error?: string
|
||||
progress?: TaskProgress
|
||||
parentModel?: { providerID: string; modelID: string }
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface LaunchInput {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BuiltinSkill } from "./types"
|
||||
|
||||
const playwrightSkill: BuiltinSkill = {
|
||||
name: "playwright",
|
||||
description: "Browser automation with Playwright MCP. Use for web scraping, testing, screenshots, and browser interactions.",
|
||||
description: "MUST USE for any browser-related tasks. Browser automation via Playwright MCP - verification, browsing, information gathering, web scraping, testing, screenshots, and all browser interactions.",
|
||||
template: `# Playwright Browser Automation
|
||||
|
||||
This skill provides browser automation capabilities via the Playwright MCP server.`,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "../../shared"
|
||||
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader"
|
||||
import type { ParsedSlashCommand } from "./types"
|
||||
|
||||
interface CommandScope {
|
||||
@@ -32,6 +32,7 @@ interface CommandInfo {
|
||||
metadata: CommandMetadata
|
||||
content?: string
|
||||
scope: CommandScope["type"]
|
||||
lazyContentLoader?: LazyContentLoader
|
||||
}
|
||||
|
||||
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"]): CommandInfo[] {
|
||||
@@ -91,10 +92,15 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
|
||||
},
|
||||
content: skill.definition.template,
|
||||
scope: "skill",
|
||||
lazyContentLoader: skill.lazyContent,
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverAllCommands(): Promise<CommandInfo[]> {
|
||||
export interface ExecutorOptions {
|
||||
skills?: LoadedSkill[]
|
||||
}
|
||||
|
||||
async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandInfo[]> {
|
||||
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
||||
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
||||
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command")
|
||||
@@ -105,7 +111,7 @@ async function discoverAllCommands(): Promise<CommandInfo[]> {
|
||||
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
|
||||
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
|
||||
|
||||
const skills = await discoverAllSkills()
|
||||
const skills = options?.skills ?? await discoverAllSkills()
|
||||
const skillCommands = skills.map(skillToCommandInfo)
|
||||
|
||||
return [
|
||||
@@ -117,8 +123,8 @@ async function discoverAllCommands(): Promise<CommandInfo[]> {
|
||||
]
|
||||
}
|
||||
|
||||
async function findCommand(commandName: string): Promise<CommandInfo | null> {
|
||||
const allCommands = await discoverAllCommands()
|
||||
async function findCommand(commandName: string, options?: ExecutorOptions): Promise<CommandInfo | null> {
|
||||
const allCommands = await discoverAllCommands(options)
|
||||
return allCommands.find(
|
||||
(cmd) => cmd.name.toLowerCase() === commandName.toLowerCase()
|
||||
) ?? null
|
||||
@@ -149,8 +155,13 @@ async function formatCommandTemplate(cmd: CommandInfo, args: string): Promise<st
|
||||
sections.push("---\n")
|
||||
sections.push("## Command Instructions\n")
|
||||
|
||||
let content = cmd.content || ""
|
||||
if (!content && cmd.lazyContentLoader) {
|
||||
content = await cmd.lazyContentLoader.load()
|
||||
}
|
||||
|
||||
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
|
||||
const withFileRefs = await resolveFileReferencesInText(cmd.content || "", commandDir)
|
||||
const withFileRefs = await resolveFileReferencesInText(content, commandDir)
|
||||
const resolvedContent = await resolveCommandsInText(withFileRefs)
|
||||
sections.push(resolvedContent.trim())
|
||||
|
||||
@@ -169,8 +180,8 @@ export interface ExecuteResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function executeSlashCommand(parsed: ParsedSlashCommand): Promise<ExecuteResult> {
|
||||
const command = await findCommand(parsed.command)
|
||||
export async function executeSlashCommand(parsed: ParsedSlashCommand, options?: ExecutorOptions): Promise<ExecuteResult> {
|
||||
const command = await findCommand(parsed.command, options)
|
||||
|
||||
if (!command) {
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
detectSlashCommand,
|
||||
extractPromptText,
|
||||
} from "./detector"
|
||||
import { executeSlashCommand } from "./executor"
|
||||
import { executeSlashCommand, type ExecutorOptions } from "./executor"
|
||||
import { log } from "../../shared"
|
||||
import {
|
||||
AUTO_SLASH_COMMAND_TAG_OPEN,
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
AutoSlashCommandHookInput,
|
||||
AutoSlashCommandHookOutput,
|
||||
} from "./types"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./executor"
|
||||
@@ -20,7 +21,15 @@ export * from "./types"
|
||||
|
||||
const sessionProcessedCommands = new Set<string>()
|
||||
|
||||
export function createAutoSlashCommandHook() {
|
||||
export interface AutoSlashCommandHookOptions {
|
||||
skills?: LoadedSkill[]
|
||||
}
|
||||
|
||||
export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions) {
|
||||
const executorOptions: ExecutorOptions = {
|
||||
skills: options?.skills,
|
||||
}
|
||||
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: AutoSlashCommandHookInput,
|
||||
@@ -52,7 +61,7 @@ export function createAutoSlashCommandHook() {
|
||||
args: parsed.args,
|
||||
})
|
||||
|
||||
const result = await executeSlashCommand(parsed)
|
||||
const result = await executeSlashCommand(parsed, executorOptions)
|
||||
|
||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||
if (idx < 0) {
|
||||
|
||||
125
src/hooks/keyword-detector/index.test.ts
Normal file
125
src/hooks/keyword-detector/index.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { createKeywordDetectorHook } from "./index"
|
||||
import { setMainSession } from "../../features/claude-code-session-state"
|
||||
import * as sharedModule from "../../shared"
|
||||
|
||||
describe("keyword-detector session filtering", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
|
||||
beforeEach(() => {
|
||||
setMainSession(undefined)
|
||||
logCalls = []
|
||||
spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logCalls.push({ msg, data })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setMainSession(undefined)
|
||||
})
|
||||
|
||||
function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
|
||||
const toastCalls = options.toastCalls ?? []
|
||||
return {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async (opts: any) => {
|
||||
toastCalls.push(opts.body.title)
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
test("should skip non-ultrawork keywords in non-main session (using mainSessionID check)", async () => {
|
||||
// #given - main session is set, different session submits search keyword
|
||||
const mainSessionID = "main-123"
|
||||
const subagentSessionID = "subagent-456"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search mode 찾아줘" }],
|
||||
}
|
||||
|
||||
// #when - non-main session triggers keyword detection
|
||||
await hook["chat.message"](
|
||||
{ sessionID: subagentSessionID },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - search keyword should be filtered out based on mainSessionID comparison
|
||||
const skipLog = logCalls.find(c => c.msg.includes("Skipping non-ultrawork keywords in non-main session"))
|
||||
expect(skipLog).toBeDefined()
|
||||
})
|
||||
|
||||
test("should allow ultrawork keywords in non-main session", async () => {
|
||||
// #given - main session is set, different session submits ultrawork keyword
|
||||
const mainSessionID = "main-123"
|
||||
const subagentSessionID = "subagent-456"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork mode" }],
|
||||
}
|
||||
|
||||
// #when - non-main session triggers ultrawork keyword
|
||||
await hook["chat.message"](
|
||||
{ sessionID: subagentSessionID },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - ultrawork should still work (variant set to max)
|
||||
expect(output.message.variant).toBe("max")
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
test("should allow all keywords in main session", async () => {
|
||||
// #given - main session submits search keyword
|
||||
const mainSessionID = "main-123"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search mode 찾아줘" }],
|
||||
}
|
||||
|
||||
// #when - main session triggers keyword detection
|
||||
await hook["chat.message"](
|
||||
{ sessionID: mainSessionID },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - search keyword should be detected (output unchanged but detection happens)
|
||||
// Note: search keywords don't set variant, they inject messages via context-injector
|
||||
// This test verifies the detection logic runs without filtering
|
||||
expect(output.message.variant).toBeUndefined() // search doesn't set variant
|
||||
})
|
||||
|
||||
test("should allow all keywords when mainSessionID is not set", async () => {
|
||||
// #given - no main session set (early startup or standalone mode)
|
||||
setMainSession(undefined)
|
||||
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork search" }],
|
||||
}
|
||||
|
||||
// #when - any session triggers keyword detection
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "any-session" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - all keywords should work
|
||||
expect(output.message.variant).toBe("max")
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector"
|
||||
import { log } from "../../shared"
|
||||
import { getMainSessionID } from "../../features/claude-code-session-state"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./constants"
|
||||
@@ -21,12 +22,28 @@ export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
}
|
||||
): Promise<void> => {
|
||||
const promptText = extractPromptText(output.parts)
|
||||
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText), input.agent)
|
||||
let detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText), input.agent)
|
||||
|
||||
if (detectedKeywords.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only ultrawork keywords work in non-main sessions
|
||||
// Other keywords (search, analyze, etc.) only work in main sessions
|
||||
const mainSessionID = getMainSessionID()
|
||||
const isNonMainSession = mainSessionID && input.sessionID !== mainSessionID
|
||||
|
||||
if (isNonMainSession) {
|
||||
detectedKeywords = detectedKeywords.filter((k) => k.type === "ultrawork")
|
||||
if (detectedKeywords.length === 0) {
|
||||
log(`[keyword-detector] Skipping non-ultrawork keywords in non-main session`, {
|
||||
sessionID: input.sessionID,
|
||||
mainSessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork")
|
||||
if (hasUltrawork) {
|
||||
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn, mock } from "bun:test"
|
||||
import { EventEmitter } from "node:events"
|
||||
import * as childProcess from "node:child_process"
|
||||
|
||||
import { createSessionNotification } from "./session-notification"
|
||||
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
||||
@@ -6,20 +8,11 @@ import * as utils from "./session-notification-utils"
|
||||
|
||||
describe("session-notification", () => {
|
||||
let notificationCalls: string[]
|
||||
let spawnMock: ReturnType<typeof spyOn>
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
$: async (cmd: TemplateStringsArray | string, ...values: any[]) => {
|
||||
// #given - track notification commands (osascript, notify-send, powershell)
|
||||
const cmdStr = typeof cmd === "string"
|
||||
? cmd
|
||||
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
||||
|
||||
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
|
||||
notificationCalls.push(cmdStr)
|
||||
}
|
||||
return { stdout: "", stderr: "", exitCode: 0 }
|
||||
},
|
||||
$: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
|
||||
client: {
|
||||
session: {
|
||||
todo: async () => ({ data: [] }),
|
||||
@@ -32,6 +25,18 @@ describe("session-notification", () => {
|
||||
beforeEach(() => {
|
||||
notificationCalls = []
|
||||
|
||||
// Mock spawn to track notification commands
|
||||
// Uses node:child_process.spawn instead of Bun shell to avoid GC crash
|
||||
spawnMock = spyOn(childProcess, "spawn").mockImplementation(((cmd: string, args?: string[]) => {
|
||||
// Track notification commands (osascript, notify-send, powershell)
|
||||
if (cmd.includes("osascript") || cmd.includes("notify-send") || cmd.includes("powershell")) {
|
||||
notificationCalls.push(`${cmd} ${(args ?? []).join(" ")}`)
|
||||
}
|
||||
const emitter = new EventEmitter()
|
||||
setTimeout(() => emitter.emit("close", 0), 0)
|
||||
return emitter as any
|
||||
}) as typeof childProcess.spawn)
|
||||
|
||||
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
|
||||
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
|
||||
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { platform } from "os"
|
||||
import { spawn } from "node:child_process"
|
||||
import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state"
|
||||
import {
|
||||
getOsascriptPath,
|
||||
@@ -11,6 +12,21 @@ import {
|
||||
startBackgroundCheck,
|
||||
} from "./session-notification-utils"
|
||||
|
||||
/**
|
||||
* Execute a command using node:child_process instead of Bun shell.
|
||||
* This avoids Bun's ShellInterpreter GC bug on Windows (oven-sh/bun#23177, #24368).
|
||||
*/
|
||||
function execCommand(command: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(command, args, {
|
||||
stdio: "ignore",
|
||||
detached: false,
|
||||
})
|
||||
proc.on("close", () => resolve())
|
||||
proc.on("error", () => resolve())
|
||||
})
|
||||
}
|
||||
|
||||
interface Todo {
|
||||
content: string
|
||||
status: string
|
||||
@@ -65,14 +81,17 @@ async function sendNotification(
|
||||
|
||||
const esTitle = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
const esMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
await ctx.$`${osascriptPath} -e ${"display notification \"" + esMessage + "\" with title \"" + esTitle + "\""}`.catch(() => {})
|
||||
const script = `display notification "${esMessage}" with title "${esTitle}"`
|
||||
// Use node:child_process instead of Bun shell to avoid potential GC issues
|
||||
await execCommand(osascriptPath, ["-e", script]).catch(() => {})
|
||||
break
|
||||
}
|
||||
case "linux": {
|
||||
const notifySendPath = await getNotifySendPath()
|
||||
if (!notifySendPath) return
|
||||
|
||||
await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid potential GC issues
|
||||
await execCommand(notifySendPath, [title, message]).catch(() => {})
|
||||
break
|
||||
}
|
||||
case "win32": {
|
||||
@@ -93,7 +112,8 @@ $Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml)
|
||||
$Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('OpenCode')
|
||||
$Notifier.Show($Toast)
|
||||
`.trim().replace(/\n/g, "; ")
|
||||
await ctx.$`${powershellPath} -Command ${toastScript}`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid GC crash (oven-sh/bun#23177)
|
||||
await execCommand(powershellPath, ["-Command", toastScript]).catch(() => {})
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -104,17 +124,19 @@ async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Prom
|
||||
case "darwin": {
|
||||
const afplayPath = await getAfplayPath()
|
||||
if (!afplayPath) return
|
||||
ctx.$`${afplayPath} ${soundPath}`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid potential GC issues
|
||||
execCommand(afplayPath, [soundPath]).catch(() => {})
|
||||
break
|
||||
}
|
||||
case "linux": {
|
||||
const paplayPath = await getPaplayPath()
|
||||
if (paplayPath) {
|
||||
ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid potential GC issues
|
||||
execCommand(paplayPath, [soundPath]).catch(() => {})
|
||||
} else {
|
||||
const aplayPath = await getAplayPath()
|
||||
if (aplayPath) {
|
||||
ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
|
||||
execCommand(aplayPath, [soundPath]).catch(() => {})
|
||||
}
|
||||
}
|
||||
break
|
||||
@@ -122,7 +144,9 @@ async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Prom
|
||||
case "win32": {
|
||||
const powershellPath = await getPowershellPath()
|
||||
if (!powershellPath) return
|
||||
ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + soundPath + "').PlaySync()"}`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid GC crash (oven-sh/bun#23177)
|
||||
const soundScript = `(New-Object Media.SoundPlayer '${soundPath.replace(/'/g, "''")}').PlaySync()`
|
||||
execCommand(powershellPath, ["-Command", soundScript]).catch(() => {})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,10 +166,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
})
|
||||
: null;
|
||||
|
||||
const autoSlashCommand = isHookEnabled("auto-slash-command")
|
||||
? createAutoSlashCommandHook()
|
||||
: null;
|
||||
|
||||
const editErrorRecovery = isHookEnabled("edit-error-recovery")
|
||||
? createEditErrorRecoveryHook(ctx)
|
||||
: null;
|
||||
@@ -239,6 +235,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
skills: mergedSkills,
|
||||
});
|
||||
|
||||
const autoSlashCommand = isHookEnabled("auto-slash-command")
|
||||
? createAutoSlashCommandHook({ skills: mergedSkills })
|
||||
: null;
|
||||
|
||||
const googleAuthHooks = pluginConfig.google_auth !== false
|
||||
? await createGoogleAntigravityAuthPlugin(ctx)
|
||||
: null;
|
||||
|
||||
@@ -10,9 +10,10 @@ describe("createBuiltinMcps", () => {
|
||||
const result = createBuiltinMcps(disabledMcps)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("websearch")
|
||||
expect(result).toHaveProperty("context7")
|
||||
expect(result).toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(2)
|
||||
expect(Object.keys(result)).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("should filter out disabled built-in MCPs", () => {
|
||||
@@ -23,19 +24,21 @@ describe("createBuiltinMcps", () => {
|
||||
const result = createBuiltinMcps(disabledMcps)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("websearch")
|
||||
expect(result).not.toHaveProperty("context7")
|
||||
expect(result).toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(1)
|
||||
expect(Object.keys(result)).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("should filter out both built-in MCPs when both disabled", () => {
|
||||
test("should filter out all built-in MCPs when all disabled", () => {
|
||||
//#given
|
||||
const disabledMcps = ["context7", "grep_app"]
|
||||
const disabledMcps = ["websearch", "context7", "grep_app"]
|
||||
|
||||
//#when
|
||||
const result = createBuiltinMcps(disabledMcps)
|
||||
|
||||
//#then
|
||||
expect(result).not.toHaveProperty("websearch")
|
||||
expect(result).not.toHaveProperty("context7")
|
||||
expect(result).not.toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(0)
|
||||
@@ -49,9 +52,10 @@ describe("createBuiltinMcps", () => {
|
||||
const result = createBuiltinMcps(disabledMcps)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("websearch")
|
||||
expect(result).not.toHaveProperty("context7")
|
||||
expect(result).toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(1)
|
||||
expect(Object.keys(result)).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("should handle empty disabled_mcps by default", () => {
|
||||
@@ -60,9 +64,10 @@ describe("createBuiltinMcps", () => {
|
||||
const result = createBuiltinMcps()
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("websearch")
|
||||
expect(result).toHaveProperty("context7")
|
||||
expect(result).toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(2)
|
||||
expect(Object.keys(result)).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("should only filter built-in MCPs, ignoring unknown names", () => {
|
||||
@@ -73,8 +78,9 @@ describe("createBuiltinMcps", () => {
|
||||
const result = createBuiltinMcps(disabledMcps)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("websearch")
|
||||
expect(result).toHaveProperty("context7")
|
||||
expect(result).toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(2)
|
||||
expect(Object.keys(result)).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { websearch } from "./websearch"
|
||||
import { context7 } from "./context7"
|
||||
import { grep_app } from "./grep-app"
|
||||
import type { McpName } from "./types"
|
||||
@@ -5,6 +6,7 @@ import type { McpName } from "./types"
|
||||
export { McpNameSchema, type McpName } from "./types"
|
||||
|
||||
const allBuiltinMcps: Record<McpName, { type: "remote"; url: string; enabled: boolean }> = {
|
||||
websearch,
|
||||
context7,
|
||||
grep_app,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const McpNameSchema = z.enum(["context7", "grep_app"])
|
||||
export const McpNameSchema = z.enum(["websearch", "context7", "grep_app"])
|
||||
|
||||
export type McpName = z.infer<typeof McpNameSchema>
|
||||
|
||||
|
||||
5
src/mcp/websearch.ts
Normal file
5
src/mcp/websearch.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const websearch = {
|
||||
type: "remote" as const,
|
||||
url: "https://mcp.exa.ai/mcp?tools=web_search_exa",
|
||||
enabled: true,
|
||||
}
|
||||
@@ -5,16 +5,17 @@ import { existsSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
|
||||
const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"]
|
||||
const DEFAULT_BASH_PATHS = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]
|
||||
|
||||
function getHomeDir(): string {
|
||||
return process.env.HOME || process.env.USERPROFILE || homedir()
|
||||
}
|
||||
|
||||
function findZshPath(customZshPath?: string): string | null {
|
||||
if (customZshPath && existsSync(customZshPath)) {
|
||||
return customZshPath
|
||||
function findShellPath(defaultPaths: string[], customPath?: string): string | null {
|
||||
if (customPath && existsSync(customPath)) {
|
||||
return customPath
|
||||
}
|
||||
for (const path of DEFAULT_ZSH_PATHS) {
|
||||
for (const path of defaultPaths) {
|
||||
if (existsSync(path)) {
|
||||
return path
|
||||
}
|
||||
@@ -22,6 +23,14 @@ function findZshPath(customZshPath?: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function findZshPath(customZshPath?: string): string | null {
|
||||
return findShellPath(DEFAULT_ZSH_PATHS, customZshPath)
|
||||
}
|
||||
|
||||
function findBashPath(): string | null {
|
||||
return findShellPath(DEFAULT_BASH_PATHS)
|
||||
}
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
export interface CommandResult {
|
||||
@@ -55,10 +64,18 @@ export async function executeHookCommand(
|
||||
let finalCommand = expandedCommand
|
||||
|
||||
if (options?.forceZsh) {
|
||||
const zshPath = options.zshPath || findZshPath()
|
||||
// Always verify shell exists before using it
|
||||
const zshPath = findZshPath(options.zshPath)
|
||||
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
|
||||
if (zshPath) {
|
||||
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
|
||||
finalCommand = `${zshPath} -lc '${escapedCommand}'`
|
||||
} else {
|
||||
// Fall back to bash login shell to preserve PATH from user profile
|
||||
const bashPath = findBashPath()
|
||||
if (bashPath) {
|
||||
finalCommand = `${bashPath} -lc '${escapedCommand}'`
|
||||
}
|
||||
// If neither zsh nor bash found, fall through to spawn with shell: true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
|
||||
},
|
||||
content: skill.definition.template,
|
||||
scope: skill.scope,
|
||||
lazyContentLoader: skill.lazyContent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +113,13 @@ async function formatLoadedCommand(cmd: CommandInfo): Promise<string> {
|
||||
sections.push("---\n")
|
||||
sections.push("## Command Instructions\n")
|
||||
|
||||
let content = cmd.content || ""
|
||||
if (!content && cmd.lazyContentLoader) {
|
||||
content = await cmd.lazyContentLoader.load()
|
||||
}
|
||||
|
||||
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
|
||||
const withFileRefs = await resolveFileReferencesInText(cmd.content || "", commandDir)
|
||||
const withFileRefs = await resolveFileReferencesInText(content, commandDir)
|
||||
const resolvedContent = await resolveCommandsInText(withFileRefs)
|
||||
sections.push(resolvedContent.trim())
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
import type { LoadedSkill, LazyContentLoader } from "../../features/opencode-skill-loader"
|
||||
|
||||
export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface CommandInfo {
|
||||
metadata: CommandMetadata
|
||||
content?: string
|
||||
scope: CommandScope
|
||||
lazyContentLoader?: LazyContentLoader
|
||||
}
|
||||
|
||||
export interface SlashcommandToolOptions {
|
||||
|
||||
Reference in New Issue
Block a user