Compare commits
18 Commits
v3.7.4
...
fix/merge-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17d3184e7a | ||
|
|
e70539fe5f | ||
|
|
c13886e13a | ||
|
|
05d7c3f462 | ||
|
|
e00275c07d | ||
|
|
70c033060b | ||
|
|
8fee45401d | ||
|
|
31dc6e206d | ||
|
|
f9c78de171 | ||
|
|
bd2e23584b | ||
|
|
2034cf137a | ||
|
|
a28e989f83 | ||
|
|
73514ed329 | ||
|
|
d5bd9cae98 | ||
|
|
d485ba2d4c | ||
|
|
69d6a2d181 | ||
|
|
575fc383e0 | ||
|
|
6df7f73f81 |
@@ -1,10 +1,10 @@
|
||||
# oh-my-opencode — OpenCode Plugin
|
||||
|
||||
**Generated:** 2026-02-18 | **Commit:** 04e95d7e | **Branch:** dev
|
||||
**Generated:** 2026-02-19 | **Commit:** 5dc437f4 | **Branch:** dev
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 44 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1149 TypeScript files, 132k LOC.
|
||||
OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 44 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1161 TypeScript files, 133k LOC.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
@@ -84,7 +84,7 @@ Fields: agents (14 overridable), categories (8 built-in + custom), disabled_* ar
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- **Test pattern**: Vitest, co-located `*.test.ts`, given/when/then style
|
||||
- **Test pattern**: Bun test (`bun:test`), co-located `*.test.ts`, given/when/then style
|
||||
- **Factory pattern**: `createXXX()` for all tools, hooks, agents
|
||||
- **Hook tiers**: Session (22) → Tool-Guard (9) → Transform (4) → Continuation (7) → Skill (2)
|
||||
- **Agent modes**: `primary` (respects UI model) vs `subagent` (own fallback chain) vs `all`
|
||||
@@ -103,7 +103,7 @@ Fields: agents (14 overridable), categories (8 built-in + custom), disabled_* ar
|
||||
## COMMANDS
|
||||
|
||||
```bash
|
||||
bun test # Vitest test suite
|
||||
bun test # Bun test suite
|
||||
bun run build # Build plugin
|
||||
bunx oh-my-opencode install # Interactive setup
|
||||
bunx oh-my-opencode doctor # Health diagnostics
|
||||
|
||||
@@ -80,7 +80,6 @@
|
||||
"non-interactive-env",
|
||||
"interactive-bash-session",
|
||||
"thinking-block-validator",
|
||||
"ultrawork-model-override",
|
||||
"ralph-loop",
|
||||
"category-skill-reminder",
|
||||
"compaction-context-injector",
|
||||
@@ -279,21 +278,6 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ultrawork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -467,21 +451,6 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ultrawork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -655,21 +624,6 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ultrawork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -843,21 +797,6 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ultrawork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1031,21 +970,6 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ultrawork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1219,21 +1143,6 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ultrawork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1407,21 +1316,6 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ultrawork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1595,21 +1489,6 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ultrawork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1783,21 +1662,6 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ultrawork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1971,21 +1835,6 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ultrawork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -2159,21 +2008,6 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ultrawork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -2347,21 +2181,6 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ultrawork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -2535,21 +2354,6 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ultrawork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -2723,21 +2527,6 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ultrawork": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -3373,4 +3162,4 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,15 +68,6 @@ Ask the user these questions to determine CLI options:
|
||||
|
||||
**Provider Priority**: Native (anthropic/, openai/, google/) > GitHub Copilot > OpenCode Zen > Z.ai Coding Plan
|
||||
|
||||
#### Claude Subscription Model Assignments
|
||||
|
||||
| Subscription | Sisyphus (Daily) | Ultrawork Mode |
|
||||
| ------------ | ---------------- | -------------- |
|
||||
| **max20** | `anthropic/claude-opus-4-6` (max) | Already on Opus — no override |
|
||||
| **standard** | `anthropic/claude-sonnet-4-6` (max) | `anthropic/claude-opus-4-6` (max) |
|
||||
|
||||
Standard Claude subscribers use Sonnet 4.6 for daily driving and automatically switch to Opus 4.6 when ultrawork mode is activated (by typing `ultrawork` or `ulw`).
|
||||
|
||||
MUST STRONGLY WARNING, WHEN USER SAID THEY DON'T HAVE CLAUDE SUBSCRIPTION, SISYPHUS AGENT MIGHT NOT WORK IDEALLY.
|
||||
|
||||
### Step 1: Install OpenCode (if not installed)
|
||||
|
||||
@@ -1583,6 +1583,22 @@
|
||||
"created_at": "2026-02-18T16:43:47Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1936
|
||||
},
|
||||
{
|
||||
"name": "gustavosmendes",
|
||||
"id": 87918773,
|
||||
"comment_id": 3922620232,
|
||||
"created_at": "2026-02-18T19:04:24Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1952
|
||||
},
|
||||
{
|
||||
"name": "maximharizanov",
|
||||
"id": 103421586,
|
||||
"comment_id": 3923157250,
|
||||
"created_at": "2026-02-18T20:52:27Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1953
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/ — Plugin Source
|
||||
|
||||
**Generated:** 2026-02-18
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/agents/ — 11 Agent Definitions
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
import { formatCustomSkillsBlock, type AvailableAgent, type AvailableSkill } from "../dynamic-agent-prompt-builder"
|
||||
import type { AvailableAgent, AvailableSkill } from "../dynamic-agent-prompt-builder"
|
||||
import { CATEGORY_DESCRIPTIONS } from "../../tools/delegate-task/constants"
|
||||
import { mergeCategories } from "../../shared/merge-categories"
|
||||
import { truncateDescription } from "../../shared/truncate-description"
|
||||
@@ -58,43 +58,16 @@ export function buildSkillsSection(skills: AvailableSkill[]): string {
|
||||
const builtinSkills = skills.filter((s) => s.location === "plugin")
|
||||
const customSkills = skills.filter((s) => s.location !== "plugin")
|
||||
|
||||
const builtinRows = builtinSkills.map((s) => {
|
||||
const shortDesc = truncateDescription(s.description)
|
||||
return `- **\`${s.name}\`** — ${shortDesc}`
|
||||
})
|
||||
|
||||
const customRows = customSkills.map((s) => {
|
||||
const shortDesc = truncateDescription(s.description)
|
||||
const source = s.location === "project" ? "project" : "user"
|
||||
return `- **\`${s.name}\`** (${source}): ${shortDesc}`
|
||||
})
|
||||
|
||||
const customSkillBlock = formatCustomSkillsBlock(customRows, customSkills, "**")
|
||||
|
||||
let skillsTable: string
|
||||
|
||||
if (customSkills.length > 0 && builtinSkills.length > 0) {
|
||||
skillsTable = `**Built-in Skills:**
|
||||
|
||||
${builtinRows.join("\n")}
|
||||
|
||||
${customSkillBlock}`
|
||||
} else if (customSkills.length > 0) {
|
||||
skillsTable = customSkillBlock
|
||||
} else {
|
||||
skillsTable = `${builtinRows.join("\n")}`
|
||||
}
|
||||
|
||||
return `
|
||||
#### 3.2.2: Skill Selection (PREPEND TO PROMPT)
|
||||
|
||||
**Skills are specialized instructions that guide subagent behavior. Consider them alongside category selection.**
|
||||
|
||||
${skillsTable}
|
||||
**Use the \`Category + Skills Delegation System\` section below as the single source of truth for skill details.**
|
||||
- Built-in skills available: ${builtinSkills.length}
|
||||
- User-installed skills available: ${customSkills.length}
|
||||
|
||||
**MANDATORY: Evaluate ALL skills (built-in AND user-installed) for relevance to your task.**
|
||||
|
||||
Read each skill's description and ask: "Does this skill's domain overlap with my task?"
|
||||
Read each skill's description in the section below and ask: "Does this skill's domain overlap with my task?"
|
||||
- If YES: INCLUDE in load_skills=[...]
|
||||
- If NO: You MUST justify why in your pre-delegation declaration
|
||||
|
||||
|
||||
@@ -43,16 +43,16 @@ describe("buildCategorySkillsDelegationGuide", () => {
|
||||
expect(result).toContain("HIGH PRIORITY")
|
||||
})
|
||||
|
||||
it("should include custom skill names in CRITICAL warning", () => {
|
||||
it("should list custom skills and keep CRITICAL warning", () => {
|
||||
//#given: custom skills installed
|
||||
const allSkills = [...builtinSkills, ...customUserSkills]
|
||||
|
||||
//#when: building the delegation guide
|
||||
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
|
||||
|
||||
//#then: should mention custom skills by name in the warning
|
||||
expect(result).toContain('"react-19"')
|
||||
expect(result).toContain('"tailwind-4"')
|
||||
//#then: should mention custom skills by name and include warning
|
||||
expect(result).toContain("`react-19`")
|
||||
expect(result).toContain("`tailwind-4`")
|
||||
expect(result).toContain("CRITICAL")
|
||||
})
|
||||
|
||||
@@ -180,8 +180,8 @@ describe("formatCustomSkillsBlock", () => {
|
||||
//#then: contains all expected elements
|
||||
expect(result).toContain("User-Installed Skills (HIGH PRIORITY)")
|
||||
expect(result).toContain("CRITICAL")
|
||||
expect(result).toContain('"react-19"')
|
||||
expect(result).toContain('"tailwind-4"')
|
||||
expect(result).toContain("`react-19`")
|
||||
expect(result).toContain("`tailwind-4`")
|
||||
expect(result).toContain("| user |")
|
||||
expect(result).toContain("| project |")
|
||||
})
|
||||
|
||||
@@ -35,7 +35,7 @@ export function categorizeTools(toolNames: string[]): AvailableTool[] {
|
||||
category = "search"
|
||||
} else if (name.startsWith("session_")) {
|
||||
category = "session"
|
||||
} else if (name === "slashcommand") {
|
||||
} else if (name === "skill") {
|
||||
category = "command"
|
||||
}
|
||||
return { name, category }
|
||||
@@ -167,7 +167,6 @@ export function formatCustomSkillsBlock(
|
||||
customSkills: AvailableSkill[],
|
||||
headerLevel: "####" | "**" = "####"
|
||||
): string {
|
||||
const customSkillNames = customSkills.map((s) => `"${s.name}"`).join(", ")
|
||||
const header = headerLevel === "####"
|
||||
? `#### User-Installed Skills (HIGH PRIORITY)`
|
||||
: `**User-Installed Skills (HIGH PRIORITY):**`
|
||||
@@ -180,7 +179,7 @@ Subagents are STATELESS — they lose all custom knowledge unless you pass these
|
||||
${customRows.join("\n")}
|
||||
|
||||
> **CRITICAL**: Ignoring user-installed skills when they match the task domain is a failure.
|
||||
> The user installed ${customSkillNames} for a reason — USE THEM when the task overlaps with their domain.`
|
||||
> The user installed custom skills for a reason — USE THEM when the task overlaps with their domain.`
|
||||
}
|
||||
|
||||
export function buildCategorySkillsDelegationGuide(categories: AvailableCategory[], skills: AvailableSkill[]): string {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/cli/ — CLI: install, run, doctor, mcp-oauth
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -94,11 +94,7 @@ exports[`generateModelConfig single native provider uses Claude models when only
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"ultrawork": {
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -483,11 +479,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"ultrawork": {
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -1044,11 +1036,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"ultrawork": {
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -1192,11 +1180,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"ultrawork": {
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -1257,11 +1241,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"ultrawork": {
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -1405,11 +1385,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"ultrawork": {
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -74,6 +74,7 @@ program
|
||||
.option("--attach <url>", "Attach to existing opencode server URL")
|
||||
.option("--on-complete <command>", "Shell command to run after completion")
|
||||
.option("--json", "Output structured JSON result to stdout")
|
||||
.option("--no-timestamp", "Disable timestamp prefix in run output")
|
||||
.option("--verbose", "Show full event stream (default: messages/tools only)")
|
||||
.option("--session-id <id>", "Resume existing session instead of creating new one")
|
||||
.addHelpText("after", `
|
||||
@@ -112,6 +113,7 @@ Unlike 'opencode run', this command waits until:
|
||||
attach: options.attach,
|
||||
onComplete: options.onComplete,
|
||||
json: options.json ?? false,
|
||||
timestamp: options.timestamp ?? true,
|
||||
verbose: options.verbose ?? false,
|
||||
sessionId: options.sessionId,
|
||||
}
|
||||
|
||||
@@ -240,52 +240,6 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
|
||||
})
|
||||
|
||||
describe("generateOmoConfig - model fallback system", () => {
|
||||
test("generates sonnet model with ultrawork opus for Claude standard subscription", () => {
|
||||
// #given user has Claude standard subscription (not max20)
|
||||
const config: InstallConfig = {
|
||||
hasClaude: true,
|
||||
isMax20: false,
|
||||
hasOpenAI: false,
|
||||
hasGemini: false,
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
}
|
||||
|
||||
// #when generating config
|
||||
const result = generateOmoConfig(config)
|
||||
|
||||
// #then Sisyphus uses sonnet for daily driving with ultrawork opus override
|
||||
const sisyphus = (result.agents as Record<string, { model: string; variant?: string; ultrawork?: { model: string; variant?: string } }>).sisyphus
|
||||
expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json")
|
||||
expect(sisyphus.model).toBe("anthropic/claude-sonnet-4-6")
|
||||
expect(sisyphus.variant).toBe("max")
|
||||
expect(sisyphus.ultrawork).toEqual({ model: "anthropic/claude-opus-4-6", variant: "max" })
|
||||
})
|
||||
|
||||
test("generates native opus models without ultrawork when Claude max20 subscription", () => {
|
||||
// #given user has Claude max20 subscription
|
||||
const config: InstallConfig = {
|
||||
hasClaude: true,
|
||||
isMax20: true,
|
||||
hasOpenAI: false,
|
||||
hasGemini: false,
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
}
|
||||
|
||||
// #when generating config
|
||||
const result = generateOmoConfig(config)
|
||||
|
||||
// #then Sisyphus uses opus directly, no ultrawork override needed
|
||||
const sisyphus = (result.agents as Record<string, { model: string; ultrawork?: unknown }>).sisyphus
|
||||
expect(sisyphus.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(sisyphus.ultrawork).toBeUndefined()
|
||||
})
|
||||
|
||||
test("uses github-copilot sonnet fallback when only copilot available", () => {
|
||||
// #given user has only copilot (no max plan)
|
||||
const config: InstallConfig = {
|
||||
|
||||
52
src/cli/config-manager/AGENTS.md
Normal file
52
src/cli/config-manager/AGENTS.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# src/cli/config-manager/ — CLI Installation Utilities
|
||||
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
20 files. Stateless utility functions for the `install` command. Handles OpenCode config manipulation, provider configuration, JSONC operations, binary detection, and npm registry queries. No class — flat utility collection.
|
||||
|
||||
## FILE CATALOG
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `add-plugin-to-opencode-config.ts` | Register `oh-my-opencode` in `.opencode/opencode.json` plugin array |
|
||||
| `add-provider-config.ts` | Add provider API key to OpenCode config (user-level) |
|
||||
| `antigravity-provider-configuration.ts` | Handle Antigravity provider setup (special case) |
|
||||
| `auth-plugins.ts` | Detect auth plugin requirements per provider (oauth vs key) |
|
||||
| `bun-install.ts` | Run `bun install` / `npm install` for plugin setup |
|
||||
| `config-context.ts` | `ConfigContext` — shared config state across install steps |
|
||||
| `deep-merge-record.ts` | Deep merge utility for JSONC config objects |
|
||||
| `detect-current-config.ts` | Read existing OpenCode config, detect installed plugins |
|
||||
| `ensure-config-directory-exists.ts` | Create `.opencode/` dir if missing |
|
||||
| `format-error-with-suggestion.ts` | Format errors with actionable suggestions |
|
||||
| `generate-omo-config.ts` | Generate `oh-my-opencode.jsonc` from install selections |
|
||||
| `jsonc-provider-editor.ts` | Read/write JSONC files with comment preservation |
|
||||
| `npm-dist-tags.ts` | Fetch latest version from npm registry (dist-tags) |
|
||||
| `opencode-binary.ts` | Detect OpenCode binary location, verify it's installed |
|
||||
| `opencode-config-format.ts` | OpenCode config format constants and type guards |
|
||||
| `parse-opencode-config-file.ts` | Parse opencode.json/opencode.jsonc with fallback |
|
||||
| `plugin-name-with-version.ts` | Resolve `oh-my-opencode@X.Y.Z` for installation |
|
||||
| `write-omo-config.ts` | Write generated config to `.opencode/oh-my-opencode.jsonc` |
|
||||
|
||||
## USAGE PATTERN
|
||||
|
||||
Functions are called sequentially by `src/cli/install.ts` / `src/cli/tui-installer.ts`:
|
||||
|
||||
```
|
||||
1. ensure-config-directory-exists
|
||||
2. detect-current-config (check what's already set up)
|
||||
3. opencode-binary (verify opencode installed)
|
||||
4. npm-dist-tags (get latest version)
|
||||
5. generate-omo-config (build config from user selections)
|
||||
6. write-omo-config
|
||||
7. add-plugin-to-opencode-config
|
||||
8. add-provider-config (for each provider selected)
|
||||
9. bun-install
|
||||
```
|
||||
|
||||
## NOTES
|
||||
|
||||
- All functions are pure / stateless (except disk I/O) — no shared module state
|
||||
- `jsonc-provider-editor.ts` uses comment-preserving JSONC library — NEVER use `JSON.parse` on JSONC files
|
||||
- `opencode-binary.ts` searches PATH + common install locations (`.local/bin`, `~/.bun/bin`, etc.)
|
||||
@@ -11,15 +11,9 @@ export interface ProviderAvailability {
|
||||
isMaxPlan: boolean
|
||||
}
|
||||
|
||||
export interface UltraworkConfig {
|
||||
model: string
|
||||
variant?: string
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
model: string
|
||||
variant?: string
|
||||
ultrawork?: UltraworkConfig
|
||||
}
|
||||
|
||||
export interface CategoryConfig {
|
||||
|
||||
@@ -76,15 +76,6 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
continue
|
||||
}
|
||||
|
||||
if (avail.native.claude && !avail.isMaxPlan) {
|
||||
agents[role] = {
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
variant: "max",
|
||||
ultrawork: { model: "anthropic/claude-opus-4-6", variant: "max" },
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const resolved = resolveModelFromChain(fallbackChain, avail)
|
||||
if (resolved) {
|
||||
const variant = resolved.variant ?? req.variant
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/cli/run/ — Non-Interactive Session Launcher
|
||||
|
||||
**Generated:** 2026-02-18
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("pollForCompletion", () => {
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
minStabilizationMs: 0,
|
||||
minStabilizationMs: 10,
|
||||
})
|
||||
|
||||
//#then - exits with 0 but only after 3 consecutive checks
|
||||
@@ -136,7 +136,7 @@ describe("pollForCompletion", () => {
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
minStabilizationMs: 0,
|
||||
minStabilizationMs: 10,
|
||||
})
|
||||
const elapsedMs = Date.now() - startMs
|
||||
|
||||
@@ -227,7 +227,7 @@ describe("pollForCompletion", () => {
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 2,
|
||||
minStabilizationMs: 0,
|
||||
minStabilizationMs: 10,
|
||||
})
|
||||
|
||||
//#then - completion succeeds without idle event
|
||||
@@ -255,6 +255,48 @@ describe("pollForCompletion", () => {
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it("uses default stabilization to avoid indefinite wait when no meaningful work arrives", async () => {
|
||||
//#given - idle with no meaningful work and no explicit minStabilization override
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = true
|
||||
eventState.hasReceivedMeaningfulWork = false
|
||||
const abortController = new AbortController()
|
||||
|
||||
//#when
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 1,
|
||||
})
|
||||
|
||||
//#then - command exits without manual Ctrl+C
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it("coerces non-positive stabilization values to default stabilization", async () => {
|
||||
//#given - explicit zero stabilization should still wait for default window
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = true
|
||||
eventState.hasReceivedMeaningfulWork = false
|
||||
const abortController = new AbortController()
|
||||
|
||||
//#when - abort before default 1s window elapses
|
||||
setTimeout(() => abortController.abort(), 100)
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 1,
|
||||
minStabilizationMs: 0,
|
||||
})
|
||||
|
||||
//#then - should not complete early
|
||||
expect(result).toBe(130)
|
||||
})
|
||||
|
||||
it("simulates race condition: brief idle with 0 todos does not cause immediate exit", async () => {
|
||||
//#given - simulate Sisyphus outputting text, session goes idle briefly, then tool fires
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
@@ -7,7 +7,7 @@ import { normalizeSDKResponse } from "../../shared"
|
||||
const DEFAULT_POLL_INTERVAL_MS = 500
|
||||
const DEFAULT_REQUIRED_CONSECUTIVE = 1
|
||||
const ERROR_GRACE_CYCLES = 3
|
||||
const MIN_STABILIZATION_MS = 0
|
||||
const MIN_STABILIZATION_MS = 1_000
|
||||
|
||||
export interface PollOptions {
|
||||
pollIntervalMs?: number
|
||||
@@ -24,8 +24,10 @@ export async function pollForCompletion(
|
||||
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
|
||||
const requiredConsecutive =
|
||||
options.requiredConsecutive ?? DEFAULT_REQUIRED_CONSECUTIVE
|
||||
const minStabilizationMs =
|
||||
const rawMinStabilizationMs =
|
||||
options.minStabilizationMs ?? MIN_STABILIZATION_MS
|
||||
const minStabilizationMs =
|
||||
rawMinStabilizationMs > 0 ? rawMinStabilizationMs : MIN_STABILIZATION_MS
|
||||
let consecutiveCompleteChecks = 0
|
||||
let errorCycleCount = 0
|
||||
let firstWorkTimestamp: number | null = null
|
||||
@@ -75,27 +77,21 @@ export async function pollForCompletion(
|
||||
}
|
||||
|
||||
if (!eventState.hasReceivedMeaningfulWork) {
|
||||
if (minStabilizationMs <= 0) {
|
||||
consecutiveCompleteChecks = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (Date.now() - pollStartTimestamp < minStabilizationMs) {
|
||||
consecutiveCompleteChecks = 0
|
||||
continue
|
||||
}
|
||||
consecutiveCompleteChecks = 0
|
||||
}
|
||||
} else {
|
||||
// Track when first meaningful work was received
|
||||
if (firstWorkTimestamp === null) {
|
||||
firstWorkTimestamp = Date.now()
|
||||
}
|
||||
|
||||
// Track when first meaningful work was received
|
||||
if (firstWorkTimestamp === null) {
|
||||
firstWorkTimestamp = Date.now()
|
||||
}
|
||||
|
||||
// Don't check completion during stabilization period
|
||||
if (Date.now() - firstWorkTimestamp < minStabilizationMs) {
|
||||
consecutiveCompleteChecks = 0
|
||||
continue
|
||||
// Don't check completion during stabilization period
|
||||
if (Date.now() - firstWorkTimestamp < minStabilizationMs) {
|
||||
consecutiveCompleteChecks = 0
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const shouldExit = await checkCompletionConditions(ctx)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { resolveRunAgent } from "./agent-resolver"
|
||||
import { pollForCompletion } from "./poll-for-completion"
|
||||
import { loadAgentProfileColors } from "./agent-profile-colors"
|
||||
import { suppressRunInput } from "./stdin-suppression"
|
||||
import { createTimestampedStdoutController } from "./timestamp-output"
|
||||
|
||||
export { resolveRunAgent }
|
||||
|
||||
@@ -38,6 +39,10 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
|
||||
const jsonManager = options.json ? createJsonOutputManager() : null
|
||||
if (jsonManager) jsonManager.redirectToStderr()
|
||||
const timestampOutput = options.json || options.timestamp === false
|
||||
? null
|
||||
: createTimestampedStdoutController()
|
||||
timestampOutput?.enable()
|
||||
|
||||
const pluginConfig = loadPluginConfig(directory, { command: "run" })
|
||||
const resolvedAgent = resolveRunAgent(options, pluginConfig)
|
||||
@@ -138,10 +143,13 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
}
|
||||
} catch (err) {
|
||||
if (jsonManager) jsonManager.restore()
|
||||
timestampOutput?.restore()
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return 130
|
||||
}
|
||||
console.error(pc.red(`Error: ${serializeError(err)}`))
|
||||
return 1
|
||||
} finally {
|
||||
timestampOutput?.restore()
|
||||
}
|
||||
}
|
||||
|
||||
127
src/cli/run/timestamp-output.test.ts
Normal file
127
src/cli/run/timestamp-output.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createTimestampTransformer, createTimestampedStdoutController } from "./timestamp-output"
|
||||
|
||||
interface MockWriteStream {
|
||||
write: (
|
||||
chunk: Uint8Array | string,
|
||||
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
|
||||
callback?: (error?: Error | null) => void,
|
||||
) => boolean
|
||||
writes: string[]
|
||||
}
|
||||
|
||||
function createMockWriteStream(): MockWriteStream {
|
||||
const writes: string[] = []
|
||||
|
||||
const write: MockWriteStream["write"] = (
|
||||
chunk,
|
||||
encodingOrCallback,
|
||||
callback,
|
||||
) => {
|
||||
const text = typeof chunk === "string"
|
||||
? chunk
|
||||
: Buffer.from(chunk).toString(typeof encodingOrCallback === "string" ? encodingOrCallback : undefined)
|
||||
|
||||
writes.push(text)
|
||||
|
||||
if (typeof encodingOrCallback === "function") {
|
||||
encodingOrCallback(null)
|
||||
} else if (callback) {
|
||||
callback(null)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return { write, writes }
|
||||
}
|
||||
|
||||
describe("createTimestampTransformer", () => {
|
||||
it("prefixes each output line with timestamp", () => {
|
||||
// given
|
||||
const now = () => new Date("2026-02-19T12:34:56.000Z")
|
||||
const transform = createTimestampTransformer(now)
|
||||
|
||||
// when
|
||||
const output = transform("hello\nworld")
|
||||
|
||||
// then
|
||||
expect(output).toBe("[12:34:56] hello\n[12:34:56] world")
|
||||
})
|
||||
|
||||
it("keeps line-start state across chunk boundaries", () => {
|
||||
// given
|
||||
const now = () => new Date("2026-02-19T01:02:03.000Z")
|
||||
const transform = createTimestampTransformer(now)
|
||||
|
||||
// when
|
||||
const first = transform("hello")
|
||||
const second = transform(" world")
|
||||
const third = transform("\nnext")
|
||||
|
||||
// then
|
||||
expect(first).toBe("[01:02:03] hello")
|
||||
expect(second).toBe(" world")
|
||||
expect(third).toBe("\n[01:02:03] next")
|
||||
})
|
||||
|
||||
it("returns empty string for empty chunk", () => {
|
||||
// given
|
||||
const transform = createTimestampTransformer(() => new Date("2026-02-19T01:02:03.000Z"))
|
||||
|
||||
// when
|
||||
const output = transform("")
|
||||
|
||||
// then
|
||||
expect(output).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createTimestampedStdoutController", () => {
|
||||
it("prefixes stdout writes when enabled", () => {
|
||||
// given
|
||||
const stdout = createMockWriteStream()
|
||||
const controller = createTimestampedStdoutController(stdout as unknown as NodeJS.WriteStream)
|
||||
|
||||
// when
|
||||
controller.enable()
|
||||
stdout.write("hello\nworld")
|
||||
|
||||
// then
|
||||
expect(stdout.writes).toHaveLength(1)
|
||||
expect(stdout.writes[0]!).toMatch(/^\[\d{2}:\d{2}:\d{2}\] hello\n\[\d{2}:\d{2}:\d{2}\] world$/)
|
||||
})
|
||||
|
||||
it("restores original write function", () => {
|
||||
// given
|
||||
const stdout = createMockWriteStream()
|
||||
const controller = createTimestampedStdoutController(stdout as unknown as NodeJS.WriteStream)
|
||||
controller.enable()
|
||||
|
||||
// when
|
||||
stdout.write("before restore")
|
||||
controller.restore()
|
||||
stdout.write("after restore")
|
||||
|
||||
// then
|
||||
expect(stdout.writes).toHaveLength(2)
|
||||
expect(stdout.writes[0]!).toMatch(/^\[\d{2}:\d{2}:\d{2}\] before restore$/)
|
||||
expect(stdout.writes[1]).toBe("after restore")
|
||||
})
|
||||
|
||||
it("supports Uint8Array chunks and encoding", () => {
|
||||
// given
|
||||
const stdout = createMockWriteStream()
|
||||
const controller = createTimestampedStdoutController(stdout as unknown as NodeJS.WriteStream)
|
||||
|
||||
// when
|
||||
controller.enable()
|
||||
stdout.write(Buffer.from("byte line"), "utf8")
|
||||
|
||||
// then
|
||||
expect(stdout.writes).toHaveLength(1)
|
||||
expect(stdout.writes[0]!).toMatch(/^\[\d{2}:\d{2}:\d{2}\] byte line$/)
|
||||
})
|
||||
})
|
||||
70
src/cli/run/timestamp-output.ts
Normal file
70
src/cli/run/timestamp-output.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
function formatTimestamp(date: Date): string {
|
||||
const hh = String(date.getHours()).padStart(2, "0")
|
||||
const mm = String(date.getMinutes()).padStart(2, "0")
|
||||
const ss = String(date.getSeconds()).padStart(2, "0")
|
||||
return `${hh}:${mm}:${ss}`
|
||||
}
|
||||
|
||||
export function createTimestampTransformer(now: () => Date = () => new Date()): (chunk: string) => string {
|
||||
let atLineStart = true
|
||||
|
||||
return (chunk: string): string => {
|
||||
if (!chunk) return ""
|
||||
|
||||
let output = ""
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
const ch = chunk[i]
|
||||
if (atLineStart) {
|
||||
output += `[${formatTimestamp(now())}] `
|
||||
atLineStart = false
|
||||
}
|
||||
|
||||
output += ch
|
||||
|
||||
if (ch === "\n") {
|
||||
atLineStart = true
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
type WriteFn = NodeJS.WriteStream["write"]
|
||||
|
||||
export function createTimestampedStdoutController(stdout: NodeJS.WriteStream = process.stdout): {
|
||||
enable: () => void
|
||||
restore: () => void
|
||||
} {
|
||||
const originalWrite = stdout.write.bind(stdout)
|
||||
const transform = createTimestampTransformer()
|
||||
|
||||
function enable(): void {
|
||||
const write: WriteFn = (
|
||||
chunk: Uint8Array | string,
|
||||
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
|
||||
callback?: (error?: Error | null) => void,
|
||||
): boolean => {
|
||||
const text = typeof chunk === "string"
|
||||
? chunk
|
||||
: Buffer.from(chunk).toString(typeof encodingOrCallback === "string" ? encodingOrCallback : undefined)
|
||||
const stamped = transform(text)
|
||||
|
||||
if (typeof encodingOrCallback === "function") {
|
||||
return originalWrite(stamped, encodingOrCallback)
|
||||
}
|
||||
if (encodingOrCallback !== undefined) {
|
||||
return originalWrite(stamped, encodingOrCallback, callback)
|
||||
}
|
||||
return originalWrite(stamped)
|
||||
}
|
||||
|
||||
stdout.write = write
|
||||
}
|
||||
|
||||
function restore(): void {
|
||||
stdout.write = originalWrite
|
||||
}
|
||||
|
||||
return { enable, restore }
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export type { OpencodeClient }
|
||||
export interface RunOptions {
|
||||
message: string
|
||||
agent?: string
|
||||
timestamp?: boolean
|
||||
verbose?: boolean
|
||||
directory?: string
|
||||
port?: number
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/config/ — Zod v4 Schema System
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -32,11 +32,6 @@ export const AgentOverrideConfigSchema = z.object({
|
||||
budgetTokens: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
/** Ultrawork model override configuration. */
|
||||
ultrawork: z.object({
|
||||
model: z.string(),
|
||||
variant: z.string().optional(),
|
||||
}).optional(),
|
||||
/** Reasoning effort level (OpenAI). Overrides category and default settings. */
|
||||
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
|
||||
/** Text verbosity level. */
|
||||
|
||||
@@ -25,7 +25,6 @@ export const HookNameSchema = z.enum([
|
||||
"interactive-bash-session",
|
||||
|
||||
"thinking-block-validator",
|
||||
"ultrawork-model-override",
|
||||
"ralph-loop",
|
||||
"category-skill-reminder",
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/features/ — 19 Feature Modules
|
||||
|
||||
**Generated:** 2026-02-18
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/features/background-agent/ — Core Orchestration Engine
|
||||
|
||||
**Generated:** 2026-02-18
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
54
src/features/mcp-oauth/AGENTS.md
Normal file
54
src/features/mcp-oauth/AGENTS.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# src/features/mcp-oauth/ — OAuth 2.0 + PKCE + DCR for MCP Servers
|
||||
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
18 files. Full OAuth 2.0 authorization flow for MCP servers requiring authentication. Implements PKCE (RFC 7636), Dynamic Client Registration (DCR, RFC 7591), and resource indicators (RFC 8707). Used by `bunx oh-my-opencode mcp-oauth login`.
|
||||
|
||||
## AUTHORIZATION FLOW
|
||||
|
||||
```
|
||||
1. discovery.ts → fetch /.well-known/oauth-authorization-server
|
||||
2. dcr.ts → Dynamic Client Registration (if server supports it)
|
||||
3. oauth-authorization-flow.ts → generate PKCE verifier/challenge
|
||||
4. callback-server.ts → local HTTP server on random port for redirect
|
||||
5. Open browser → authorization URL
|
||||
6. callback-server.ts → receive code + state
|
||||
7. provider.ts → exchange code for token (with PKCE verifier)
|
||||
8. storage.ts → persist token to ~/.config/opencode/mcp-oauth/
|
||||
9. step-up.ts → handle step-up auth if initial token insufficient
|
||||
```
|
||||
|
||||
## KEY FILES
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `oauth-authorization-flow.ts` | PKCE helpers: `generateCodeVerifier()`, `generateCodeChallenge()`, `buildAuthorizationUrl()` |
|
||||
| `callback-server.ts` | Local HTTP redirect server — listens for OAuth callback |
|
||||
| `provider.ts` | `OAuthProvider` — token exchange, refresh, revoke |
|
||||
| `discovery.ts` | Fetch + parse OAuth server metadata from well-known endpoint |
|
||||
| `dcr.ts` | Dynamic Client Registration — register this app with OAuth server |
|
||||
| `resource-indicator.ts` | RFC 8707 resource indicator handling |
|
||||
| `step-up.ts` | Handle step-up authentication challenges |
|
||||
| `storage.ts` | Persist tokens to `~/.config/opencode/mcp-oauth/{server-hash}.json` |
|
||||
| `schema.ts` | Zod schemas for OAuth server metadata, token response, DCR |
|
||||
|
||||
## PKCE IMPLEMENTATION
|
||||
|
||||
- Code verifier: 32 random bytes → base64url (no padding)
|
||||
- Code challenge: SHA-256(verifier) → base64url
|
||||
- Method: `S256`
|
||||
|
||||
## TOKEN STORAGE
|
||||
|
||||
Location: `~/.config/opencode/mcp-oauth/` — one JSON file per MCP server (keyed by server URL hash).
|
||||
Fields: `access_token`, `refresh_token`, `expires_at`, `client_id`.
|
||||
|
||||
## CLI COMMANDS
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode mcp-oauth login <server-url> # Full PKCE flow
|
||||
bunx oh-my-opencode mcp-oauth logout <server-url> # Revoke + delete token
|
||||
bunx oh-my-opencode mcp-oauth status # List stored tokens
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/features/opencode-skill-loader/ — 4-Scope Skill Discovery
|
||||
|
||||
**Generated:** 2026-02-18
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/features/tmux-subagent/ — Tmux Pane Management
|
||||
|
||||
**Generated:** 2026-02-18
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/hooks/ — 44 Lifecycle Hooks
|
||||
|
||||
**Generated:** 2026-02-18
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/hooks/anthropic-context-window-limit-recovery/ — Multi-Strategy Context Recovery
|
||||
|
||||
**Generated:** 2026-02-18
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
64
src/hooks/atlas/AGENTS.md
Normal file
64
src/hooks/atlas/AGENTS.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# src/hooks/atlas/ — Master Boulder Orchestrator
|
||||
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
17 files (~1976 LOC). The `atlasHook` — Continuation Tier hook that monitors session.idle events and forces continuation when boulder sessions (ralph-loop, task-spawned agents) have incomplete work. Also enforces write/edit policies for subagent sessions.
|
||||
|
||||
## WHAT ATLAS DOES
|
||||
|
||||
Atlas is the "keeper of sessions" — it tracks every session and decides:
|
||||
1. Should this session be forced to continue? (if boulder session with incomplete todos)
|
||||
2. Should write/edit be blocked? (policy enforcement for certain session types)
|
||||
3. Should a verification reminder be injected? (after tool execution)
|
||||
|
||||
## DECISION GATE (session.idle)
|
||||
|
||||
```
|
||||
session.idle event
|
||||
→ Is this a boulder/ralph/atlas session? (session-last-agent.ts)
|
||||
→ Is there an abort signal? (is-abort-error.ts)
|
||||
→ Failure count < max? (state.promptFailureCount)
|
||||
→ No running background tasks?
|
||||
→ Agent matches expected? (recent-model-resolver.ts)
|
||||
→ Plan complete? (todo status)
|
||||
→ Cooldown passed? (5s between injections)
|
||||
→ Inject continuation prompt (boulder-continuation-injector.ts)
|
||||
```
|
||||
|
||||
## KEY FILES
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `atlas-hook.ts` | `createAtlasHook()` — composes event + tool handlers, maintains session state |
|
||||
| `event-handler.ts` | `createAtlasEventHandler()` — decision gate for session.idle events |
|
||||
| `boulder-continuation-injector.ts` | Build + inject continuation prompt into session |
|
||||
| `system-reminder-templates.ts` | Templates for continuation reminder messages |
|
||||
| `tool-execute-before.ts` | Block write/edit based on session policy |
|
||||
| `tool-execute-after.ts` | Inject verification reminders post-tool |
|
||||
| `write-edit-tool-policy.ts` | Policy: which sessions can write/edit? |
|
||||
| `verification-reminders.ts` | Reminder content for verifying work |
|
||||
| `session-last-agent.ts` | Determine which agent owns the session |
|
||||
| `recent-model-resolver.ts` | Resolve model used in recent messages |
|
||||
| `subagent-session-id.ts` | Detect if session is a subagent session |
|
||||
| `sisyphus-path.ts` | Resolve `.sisyphus/` directory path |
|
||||
| `is-abort-error.ts` | Detect abort signals in session output |
|
||||
| `types.ts` | `SessionState`, `AtlasHookOptions`, `AtlasContext` |
|
||||
|
||||
## STATE PER SESSION
|
||||
|
||||
```typescript
|
||||
interface SessionState {
|
||||
promptFailureCount: number // Increments on failed continuations
|
||||
// Resets on successful continuation
|
||||
}
|
||||
```
|
||||
|
||||
Max consecutive failures before 5min pause: 5 (exponential backoff in todo-continuation-enforcer).
|
||||
|
||||
## RELATIONSHIP TO OTHER HOOKS
|
||||
|
||||
- **atlasHook** (Continuation Tier): Master orchestrator, handles boulder sessions
|
||||
- **todoContinuationEnforcer** (Continuation Tier): "Boulder" mechanism for main Sisyphus sessions
|
||||
- Both inject into session.idle but serve different session types
|
||||
@@ -202,7 +202,9 @@ export async function executeSlashCommand(parsed: ParsedSlashCommand, options?:
|
||||
if (!command) {
|
||||
return {
|
||||
success: false,
|
||||
error: parsed.command.includes(":") ? `Marketplace plugin commands like "/${parsed.command}" are not supported. Use .claude/commands/ for custom commands.` : `Command "/${parsed.command}" not found. Use the slashcommand tool to list available commands.`,
|
||||
error: parsed.command.includes(":")
|
||||
? `Marketplace plugin commands like "/${parsed.command}" are not supported. Use .claude/commands/ for custom commands.`
|
||||
: `Command "/${parsed.command}" not found. Use the skill tool to list available skills and commands.`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/hooks/claude-code-hooks/ — Claude Code Compatibility
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -47,4 +47,3 @@ export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler";
|
||||
export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
|
||||
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
|
||||
|
||||
export { createUltraworkModelOverrideHook } from "./ultrawork-model-override";
|
||||
|
||||
57
src/hooks/keyword-detector/AGENTS.md
Normal file
57
src/hooks/keyword-detector/AGENTS.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# src/hooks/keyword-detector/ — Mode Keyword Injection
|
||||
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
8 files + 3 mode subdirs (~1665 LOC). Transform Tier hook on `messages.transform`. Scans first user message for mode keywords (ultrawork, search, analyze) and injects mode-specific system prompts.
|
||||
|
||||
## KEYWORDS
|
||||
|
||||
| Keyword | Pattern | Effect |
|
||||
|---------|---------|--------|
|
||||
| `ultrawork` / `ulw` | `/\b(ultrawork|ulw)\b/i` | Full orchestration mode — parallel agents, deep exploration, relentless execution |
|
||||
| Search mode | `SEARCH_PATTERN` (from `search/`) | Web/doc search focus prompt injection |
|
||||
| Analyze mode | `ANALYZE_PATTERN` (from `analyze/`) | Deep analysis mode prompt injection |
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
keyword-detector/
|
||||
├── index.ts # Barrel export
|
||||
├── hook.ts # createKeywordDetectorHook() — chat.message handler
|
||||
├── detector.ts # detectKeywordsWithType() + extractPromptText()
|
||||
├── constants.ts # KEYWORD_DETECTORS array, re-exports from submodules
|
||||
├── types.ts # KeywordDetector, DetectedKeyword types
|
||||
├── ultrawork/
|
||||
│ ├── index.ts
|
||||
│ ├── message.ts # getUltraworkMessage() — dynamic prompt by agent/model
|
||||
│ └── isPlannerAgent.ts
|
||||
├── search/
|
||||
│ ├── index.ts
|
||||
│ ├── pattern.ts # SEARCH_PATTERN regex
|
||||
│ └── message.ts # SEARCH_MESSAGE
|
||||
└── analyze/
|
||||
├── index.ts
|
||||
├── pattern.ts # ANALYZE_PATTERN regex
|
||||
└── message.ts # ANALYZE_MESSAGE
|
||||
```
|
||||
|
||||
## DETECTION LOGIC
|
||||
|
||||
```
|
||||
chat.message (user input)
|
||||
→ extractPromptText(parts)
|
||||
→ isSystemDirective? → skip
|
||||
→ removeSystemReminders(text) # strip <SYSTEM_REMINDER> blocks
|
||||
→ detectKeywordsWithType(cleanText, agentName, modelID)
|
||||
→ isPlannerAgent(agentName)? → filter out ultrawork
|
||||
→ for each detected keyword: inject mode message into output
|
||||
```
|
||||
|
||||
## GUARDS
|
||||
|
||||
- **System directive skip**: Messages tagged as system directives are not scanned (prevents infinite loops)
|
||||
- **Planner agent filter**: Prometheus/plan agents do not receive `ultrawork` injection
|
||||
- **Session agent tracking**: Uses `getSessionAgent()` to get actual agent (not just input hint)
|
||||
- **Model-aware messages**: `getUltraworkMessage(agentName, modelID)` adapts message to active model
|
||||
62
src/hooks/ralph-loop/AGENTS.md
Normal file
62
src/hooks/ralph-loop/AGENTS.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# src/hooks/ralph-loop/ — Self-Referential Dev Loop
|
||||
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
14 files (~1687 LOC). The `ralphLoop` Session Tier hook — powers the `/ralph-loop` command. Iterates a development loop until the agent emits `<promise>DONE</promise>` or max iterations reached.
|
||||
|
||||
## LOOP LIFECYCLE
|
||||
|
||||
```
|
||||
/ralph-loop → startLoop(sessionID, prompt, options)
|
||||
→ loopState.startLoop() → persists state to .sisyphus/ralph-loop.local.md
|
||||
→ session.idle events → createRalphLoopEventHandler()
|
||||
→ completionPromiseDetector: scan output for <promise>DONE</promise>
|
||||
→ if not done: inject continuation prompt → loop
|
||||
→ if done or maxIterations: cancelLoop()
|
||||
```
|
||||
|
||||
## KEY FILES
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `ralph-loop-hook.ts` | `createRalphLoopHook()` — composes controller + recovery + event handler |
|
||||
| `ralph-loop-event-handler.ts` | `createRalphLoopEventHandler()` — handles session.idle, drives loop |
|
||||
| `loop-state-controller.ts` | State CRUD: startLoop, cancelLoop, getState, persist to disk |
|
||||
| `loop-session-recovery.ts` | Recover from crashed/interrupted loop sessions |
|
||||
| `completion-promise-detector.ts` | Scan session transcript for `<promise>DONE</promise>` |
|
||||
| `continuation-prompt-builder.ts` | Build continuation message for next iteration |
|
||||
| `continuation-prompt-injector.ts` | Inject built prompt into active session |
|
||||
| `storage.ts` | Read/write `.sisyphus/ralph-loop.local.md` state file |
|
||||
| `message-storage-directory.ts` | Temp dir for prompt injection |
|
||||
| `with-timeout.ts` | API call wrapper with timeout (default 5000ms) |
|
||||
| `types.ts` | `RalphLoopState`, `RalphLoopOptions`, loop iteration types |
|
||||
|
||||
## STATE FILE
|
||||
|
||||
```
|
||||
.sisyphus/ralph-loop.local.md (gitignored)
|
||||
→ sessionID, prompt, iteration count, maxIterations, completionPromise, ultrawork flag
|
||||
```
|
||||
|
||||
## OPTIONS
|
||||
|
||||
```typescript
|
||||
startLoop(sessionID, prompt, {
|
||||
maxIterations?: number // Default from config (default: 100)
|
||||
completionPromise?: string // Custom "done" signal (default: "<promise>DONE</promise>")
|
||||
ultrawork?: boolean // Enable ultrawork mode for iterations
|
||||
})
|
||||
```
|
||||
|
||||
## EXPORTED INTERFACE
|
||||
|
||||
```typescript
|
||||
interface RalphLoopHook {
|
||||
event: (input) => Promise<void> // session.idle handler
|
||||
startLoop: (sessionID, prompt, options?) => boolean
|
||||
cancelLoop: (sessionID) => boolean
|
||||
getState: () => RalphLoopState | null
|
||||
}
|
||||
```
|
||||
53
src/hooks/rules-injector/AGENTS.md
Normal file
53
src/hooks/rules-injector/AGENTS.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# src/hooks/rules-injector/ — Conditional Rules Injection
|
||||
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
19 files (~1604 LOC). The `rulesInjectorHook` — Tool Guard Tier hook that auto-injects AGENTS.md (and similar rule files) into context when a file in a directory is read, written, or edited. Proximity-based: closest rule file to the target path wins.
|
||||
|
||||
## HOW IT WORKS
|
||||
|
||||
```
|
||||
tool.execute.after (read/write/edit/multiedit)
|
||||
→ Extract file path from tool output
|
||||
→ Find rule files near that path (finder.ts)
|
||||
→ Already injected this session? (cache.ts)
|
||||
→ Inject rule content into tool output (injector.ts)
|
||||
```
|
||||
|
||||
## TRACKED TOOLS
|
||||
|
||||
`["read", "write", "edit", "multiedit"]` — triggers only on file manipulation tools.
|
||||
|
||||
## KEY FILES
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `hook.ts` | `createRulesInjectorHook()` — wires cache + injector, handles tool events |
|
||||
| `injector.ts` | `createRuleInjectionProcessor()` — orchestrates find → cache → inject |
|
||||
| `finder.ts` | `findRuleFiles()` + `calculateDistance()` — locate AGENTS.md near target path |
|
||||
| `rule-file-finder.ts` | Walk directory tree to find AGENTS.md / .rules files |
|
||||
| `rule-file-scanner.ts` | Scan for rule files in a directory |
|
||||
| `matcher.ts` | Match file paths against rule file scope |
|
||||
| `rule-distance.ts` | Calculate path distance between file and rule file |
|
||||
| `project-root-finder.ts` | Find project root (stops at .git, package.json) |
|
||||
| `output-path.ts` | Extract file paths from tool output text |
|
||||
| `cache.ts` | `createSessionCacheStore()` — per-session injection dedup |
|
||||
| `storage.ts` | Persist injected paths across tool calls |
|
||||
| `parser.ts` | Parse rule file content |
|
||||
| `constants.ts` | Rule file names: `AGENTS.md`, `.rules`, `CLAUDE.md` |
|
||||
| `types.ts` | `RuleFile`, `InjectionResult`, `RuleFileScope` |
|
||||
|
||||
## RULE FILE DISCOVERY
|
||||
|
||||
Priority (closest → farthest from target file):
|
||||
1. Same directory as target file
|
||||
2. Parent directories up to project root
|
||||
3. Project root itself
|
||||
|
||||
Same-distance tie: all injected. Per-session dedup prevents re-injection.
|
||||
|
||||
## TRUNCATION
|
||||
|
||||
Uses `DynamicTruncator` — adapts injection size based on model context window (1M context models get full content, smaller models get truncated summaries).
|
||||
59
src/hooks/session-recovery/AGENTS.md
Normal file
59
src/hooks/session-recovery/AGENTS.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# src/hooks/session-recovery/ — Auto Session Error Recovery
|
||||
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
16 files + storage/ subdir. Session Tier hook handling `session.error` events. Detects recoverable error types, applies targeted recovery strategies, and resumes the session transparently.
|
||||
|
||||
## RECOVERY STRATEGIES
|
||||
|
||||
| Error Type | File | Recovery Action |
|
||||
|------------|------|-----------------|
|
||||
| `tool_result_missing` | `recover-tool-result-missing.ts` | Reconstruct missing tool results from storage |
|
||||
| `thinking_block_order` | `recover-thinking-block-order.ts` | Reorder malformed thinking blocks |
|
||||
| `thinking_disabled_violation` | `recover-thinking-disabled-violation.ts` | Strip thinking blocks when disabled |
|
||||
| `empty_content_message` | `recover-empty-content-message*.ts` | Handle empty/null content blocks |
|
||||
|
||||
## KEY FILES
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `hook.ts` | `createSessionRecoveryHook()` — error detection, strategy dispatch, resume |
|
||||
| `detect-error-type.ts` | `detectErrorType(error)` → `RecoveryErrorType \| null` |
|
||||
| `resume.ts` | `resumeSession()` — rebuild session context, trigger retry |
|
||||
| `storage.ts` | Per-session message storage for recovery reconstruction |
|
||||
| `recover-tool-result-missing.ts` | Reconstruct tool results from stored metadata |
|
||||
| `recover-thinking-block-order.ts` | Fix malformed thinking block sequences |
|
||||
| `recover-thinking-disabled-violation.ts` | Remove thinking blocks from model context |
|
||||
| `recover-empty-content-message.ts` | Handle empty assistant messages |
|
||||
| `recover-empty-content-message-sdk.ts` | SDK variant for empty content recovery |
|
||||
| `types.ts` | `StoredMessageMeta`, `StoredPart`, `ResumeConfig`, `MessageData` |
|
||||
|
||||
## STORAGE SUBDIRECTORY
|
||||
|
||||
```
|
||||
storage/
|
||||
├── message-store.ts # In-memory + file message cache
|
||||
├── part-store.ts # Individual message parts storage
|
||||
└── index.ts # Barrel export
|
||||
```
|
||||
|
||||
Stores message metadata and parts per session for recovery reconstruction.
|
||||
|
||||
## HOOK INTERFACE
|
||||
|
||||
```typescript
|
||||
interface SessionRecoveryHook {
|
||||
handleSessionRecovery: (info: MessageInfo) => Promise<boolean>
|
||||
isRecoverableError: (error: unknown) => boolean
|
||||
setOnAbortCallback: (cb: (sessionID: string) => void) => void
|
||||
setOnRecoveryCompleteCallback: (cb: (sessionID: string) => void) => void
|
||||
}
|
||||
```
|
||||
|
||||
## NOTES
|
||||
|
||||
- Guards with `processingErrors` Set to prevent duplicate recovery attempts on same error
|
||||
- Supports `experimental` config for behavior flags
|
||||
- Distinct from `anthropic-context-window-limit-recovery` (handles token limit; this handles structural errors)
|
||||
65
src/hooks/todo-continuation-enforcer/AGENTS.md
Normal file
65
src/hooks/todo-continuation-enforcer/AGENTS.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# src/hooks/todo-continuation-enforcer/ — Boulder Continuation Mechanism
|
||||
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
14 files (~2061 LOC). The "boulder" — Continuation Tier hook that forces Sisyphus to keep rolling when incomplete todos remain. Fires on `session.idle`, injects continuation prompt after 2s countdown toast.
|
||||
|
||||
## HOW IT WORKS
|
||||
|
||||
```
|
||||
session.idle
|
||||
→ Is main session (not prometheus/compaction)? (DEFAULT_SKIP_AGENTS)
|
||||
→ No abort detected recently? (ABORT_WINDOW_MS = 3s)
|
||||
→ Todos still incomplete? (todo.ts)
|
||||
→ No background tasks running?
|
||||
→ Cooldown passed? (CONTINUATION_COOLDOWN_MS = 30s)
|
||||
→ Failure count < max? (MAX_CONSECUTIVE_FAILURES = 5)
|
||||
→ Start 2s countdown toast → inject CONTINUATION_PROMPT
|
||||
```
|
||||
|
||||
## KEY FILES
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `handler.ts` | `createTodoContinuationHandler()` — event router, delegates to idle/non-idle handlers |
|
||||
| `idle-event.ts` | `handleSessionIdle()` — main decision gate for session.idle |
|
||||
| `non-idle-events.ts` | `handleNonIdleEvent()` — handles session.error (abort detection) |
|
||||
| `session-state.ts` | `SessionStateStore` — per-session failure/abort/cooldown state |
|
||||
| `todo.ts` | Check todo completion status via session store |
|
||||
| `countdown.ts` | 2s countdown toast before injection |
|
||||
| `abort-detection.ts` | Detect MessageAbortedError / AbortError |
|
||||
| `continuation-injection.ts` | Build + inject CONTINUATION_PROMPT into session |
|
||||
| `message-directory.ts` | Temp dir for message injection exchange |
|
||||
| `constants.ts` | Timing constants, CONTINUATION_PROMPT, skip agents |
|
||||
| `types.ts` | `SessionState`, handler argument types |
|
||||
|
||||
## CONSTANTS
|
||||
|
||||
```typescript
|
||||
DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"]
|
||||
CONTINUATION_COOLDOWN_MS = 30_000 // 30s between injections
|
||||
MAX_CONSECUTIVE_FAILURES = 5 // Then 5min pause (exponential backoff)
|
||||
FAILURE_RESET_WINDOW_MS = 5 * 60_000 // 5min window for failure reset
|
||||
COUNTDOWN_SECONDS = 2
|
||||
ABORT_WINDOW_MS = 3000 // Grace after abort signal
|
||||
```
|
||||
|
||||
## STATE PER SESSION
|
||||
|
||||
```typescript
|
||||
interface SessionState {
|
||||
failureCount: number // Consecutive failures
|
||||
lastFailureAt?: number // Timestamp
|
||||
abortDetectedAt?: number // Reset after ABORT_WINDOW_MS
|
||||
cooldownUntil?: number // Next injection allowed after
|
||||
countdownTimer?: Timer // Active countdown reference
|
||||
}
|
||||
```
|
||||
|
||||
## RELATIONSHIP TO ATLAS
|
||||
|
||||
`todoContinuationEnforcer` handles **main Sisyphus sessions** only.
|
||||
`atlasHook` handles **boulder/ralph/subagent sessions** with a different decision gate.
|
||||
Both fire on `session.idle` but check session type first.
|
||||
@@ -1,217 +0,0 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { createUltraworkModelOverrideHook } from "./hook"
|
||||
|
||||
interface ChatParamsInput {
|
||||
agent: string
|
||||
message: {
|
||||
variant: string
|
||||
model?: { providerID?: string; modelID?: string }
|
||||
}
|
||||
sessionID?: string
|
||||
}
|
||||
|
||||
interface ChatParamsOutput {
|
||||
// Not used by this hook
|
||||
}
|
||||
|
||||
function createMockParams(overrides: {
|
||||
agent?: string
|
||||
variant?: string
|
||||
model?: { providerID?: string; modelID?: string }
|
||||
sessionID?: string
|
||||
}): { input: ChatParamsInput; output: ChatParamsOutput } {
|
||||
const agent = overrides.agent ?? "sisyphus"
|
||||
const variant = overrides.variant ?? "max"
|
||||
const model = overrides.model
|
||||
const sessionID = overrides.sessionID
|
||||
|
||||
return {
|
||||
input: {
|
||||
agent,
|
||||
message: { variant, model },
|
||||
sessionID,
|
||||
},
|
||||
output: {},
|
||||
}
|
||||
}
|
||||
|
||||
describe("createUltraworkModelOverrideHook", () => {
|
||||
describe("model swap works", () => {
|
||||
it("variant max, ultrawork config exists → model swapped", async () => {
|
||||
// given
|
||||
const agents = {
|
||||
sisyphus: {
|
||||
ultrawork: {
|
||||
model: "openai/gpt-5.2",
|
||||
},
|
||||
},
|
||||
}
|
||||
const hook = createUltraworkModelOverrideHook({ agents })
|
||||
const { input, output } = createMockParams({})
|
||||
|
||||
// when
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
// then
|
||||
expect(input.message.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.2",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("no-op on non-max variant", () => {
|
||||
it("variant high → model unchanged", async () => {
|
||||
// given
|
||||
const agents = {
|
||||
sisyphus: {
|
||||
ultrawork: {
|
||||
model: "openai/gpt-5.2",
|
||||
},
|
||||
},
|
||||
}
|
||||
const hook = createUltraworkModelOverrideHook({ agents })
|
||||
const { input, output } = createMockParams({ variant: "high" })
|
||||
|
||||
// when
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
// then
|
||||
expect(input.message.model).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("no-op without config", () => {
|
||||
it("agent has no ultrawork config → model unchanged", async () => {
|
||||
// given
|
||||
const agents = {
|
||||
hephaestus: {
|
||||
ultrawork: {
|
||||
model: "openai/gpt-5.2",
|
||||
},
|
||||
},
|
||||
}
|
||||
const hook = createUltraworkModelOverrideHook({ agents })
|
||||
const { input, output } = createMockParams({})
|
||||
|
||||
// when
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
// then
|
||||
expect(input.message.model).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("empty ultrawork config", () => {
|
||||
it("ultrawork: {} → no-op (model required)", async () => {
|
||||
// given
|
||||
const agents = {
|
||||
sisyphus: {
|
||||
ultrawork: undefined,
|
||||
},
|
||||
}
|
||||
const hook = createUltraworkModelOverrideHook({ agents })
|
||||
const { input, output } = createMockParams({})
|
||||
|
||||
// when
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
// then
|
||||
expect(input.message.model).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("model string parsing", () => {
|
||||
it("openai/gpt-5.2 → { providerID: openai, modelID: gpt-5.2 }", async () => {
|
||||
// given
|
||||
const agents = {
|
||||
sisyphus: {
|
||||
ultrawork: {
|
||||
model: "openai/gpt-5.2",
|
||||
},
|
||||
},
|
||||
}
|
||||
const hook = createUltraworkModelOverrideHook({ agents })
|
||||
const { input, output } = createMockParams({})
|
||||
|
||||
// when
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
// then
|
||||
expect(input.message.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.2",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("nested slashes", () => {
|
||||
it("google-vertex-anthropic/claude-opus-4-6 → { providerID: google-vertex-anthropic, modelID: claude-opus-4-6 } (first / only)", async () => {
|
||||
// given
|
||||
const agents = {
|
||||
sisyphus: {
|
||||
ultrawork: {
|
||||
model: "google-vertex-anthropic/claude-opus-4-6",
|
||||
},
|
||||
},
|
||||
}
|
||||
const hook = createUltraworkModelOverrideHook({ agents })
|
||||
const { input, output } = createMockParams({})
|
||||
|
||||
// when
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
// then
|
||||
expect(input.message.model).toEqual({
|
||||
providerID: "google-vertex-anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("variant override", () => {
|
||||
it("ultrawork.variant exists → message.variant updated", async () => {
|
||||
// given
|
||||
const agents = {
|
||||
sisyphus: {
|
||||
ultrawork: {
|
||||
model: "openai/gpt-5.2",
|
||||
variant: "high",
|
||||
},
|
||||
},
|
||||
}
|
||||
const hook = createUltraworkModelOverrideHook({ agents })
|
||||
const { input, output } = createMockParams({})
|
||||
|
||||
// when
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
// then
|
||||
expect(input.message.variant).toBe("high")
|
||||
})
|
||||
})
|
||||
|
||||
describe("agent name normalization", () => {
|
||||
it("Sisyphus (Ultraworker) → sisyphus config key lookup", async () => {
|
||||
// given
|
||||
const agents = {
|
||||
sisyphus: {
|
||||
ultrawork: {
|
||||
model: "openai/gpt-5.2",
|
||||
},
|
||||
},
|
||||
}
|
||||
const hook = createUltraworkModelOverrideHook({ agents })
|
||||
const { input, output } = createMockParams({ agent: "Sisyphus (Ultraworker)" })
|
||||
|
||||
// when
|
||||
await hook["chat.params"](input, output)
|
||||
|
||||
// then
|
||||
expect(input.message.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.2",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { AgentOverrides } from "../../config"
|
||||
import { log } from "../../shared"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function getUltraworkConfig(agents: AgentOverrides | undefined, configKey: string) {
|
||||
if (!agents) return undefined
|
||||
|
||||
for (const [agentKey, override] of Object.entries(agents)) {
|
||||
if (getAgentConfigKey(agentKey) === configKey) {
|
||||
return override?.ultrawork
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function createUltraworkModelOverrideHook(args: { agents?: AgentOverrides }) {
|
||||
let didLogSpikeInput = false
|
||||
|
||||
return {
|
||||
"chat.params": async (input: unknown, output: unknown): Promise<void> => {
|
||||
if (!didLogSpikeInput) {
|
||||
didLogSpikeInput = true
|
||||
|
||||
const inputRecord = isRecord(input) ? input : null
|
||||
const messageRecord = isRecord(inputRecord?.message) ? inputRecord.message : null
|
||||
|
||||
log("ultrawork-model-override spike: raw chat.params input", {
|
||||
inputType: typeof input,
|
||||
outputType: typeof output,
|
||||
hasMessage: messageRecord !== null,
|
||||
messageKeys: messageRecord ? Object.keys(messageRecord) : [],
|
||||
hasMessageModel: messageRecord ? "model" in messageRecord : false,
|
||||
messageModelType: messageRecord ? typeof messageRecord.model : "undefined",
|
||||
})
|
||||
}
|
||||
|
||||
if (!isRecord(input)) return
|
||||
|
||||
const message = input.message
|
||||
if (!isRecord(message)) return
|
||||
if (message.variant !== "max") return
|
||||
|
||||
const agentName = input.agent
|
||||
if (typeof agentName !== "string") return
|
||||
|
||||
const configKey = getAgentConfigKey(agentName)
|
||||
const ultrawork = getUltraworkConfig(args.agents, configKey)
|
||||
if (!ultrawork?.model) return
|
||||
|
||||
const separatorIndex = ultrawork.model.indexOf("/")
|
||||
const providerID = separatorIndex === -1 ? ultrawork.model : ultrawork.model.slice(0, separatorIndex)
|
||||
const modelID = separatorIndex === -1 ? "" : ultrawork.model.slice(separatorIndex + 1)
|
||||
|
||||
const previousModel = isRecord(message.model)
|
||||
? {
|
||||
providerID:
|
||||
typeof message.model.providerID === "string" ? message.model.providerID : undefined,
|
||||
modelID: typeof message.model.modelID === "string" ? message.model.modelID : undefined,
|
||||
}
|
||||
: undefined
|
||||
|
||||
message.model = { providerID, modelID }
|
||||
|
||||
if (ultrawork.variant !== undefined) {
|
||||
message.variant = ultrawork.variant
|
||||
}
|
||||
|
||||
log("ultrawork-model-override: swapped model", {
|
||||
sessionID: typeof input.sessionID === "string" ? input.sessionID : undefined,
|
||||
agent: agentName,
|
||||
configKey,
|
||||
from: previousModel,
|
||||
to: message.model,
|
||||
variant: message.variant,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { createUltraworkModelOverrideHook } from "./hook"
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/mcp/ — 3 Built-in Remote MCPs
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/plugin-handlers/ — 6-Phase Config Loading Pipeline
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ export function createPluginInterface(args: {
|
||||
tool: tools,
|
||||
|
||||
"chat.params": async (input, output) => {
|
||||
await hooks.ultraworkModelOverride?.["chat.params"]?.(input, output)
|
||||
const handler = createChatParamsHandler({ anthropicEffort: hooks.anthropicEffort })
|
||||
await handler(input, output)
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/plugin/ — 8 OpenCode Hook Handlers + Hook Composition
|
||||
|
||||
**Generated:** 2026-02-18
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
createPreemptiveCompactionHook,
|
||||
} from "../../hooks"
|
||||
import { createAnthropicEffortHook } from "../../hooks/anthropic-effort"
|
||||
import { createUltraworkModelOverrideHook } from "../../hooks/ultrawork-model-override"
|
||||
import {
|
||||
detectExternalNotificationPlugin,
|
||||
getNotificationConflictWarning,
|
||||
@@ -56,7 +55,6 @@ export type SessionHooks = {
|
||||
questionLabelTruncator: ReturnType<typeof createQuestionLabelTruncatorHook>
|
||||
taskResumeInfo: ReturnType<typeof createTaskResumeInfoHook>
|
||||
anthropicEffort: ReturnType<typeof createAnthropicEffortHook> | null
|
||||
ultraworkModelOverride: ReturnType<typeof createUltraworkModelOverrideHook> | null
|
||||
}
|
||||
|
||||
export function createSessionHooks(args: {
|
||||
@@ -171,10 +169,6 @@ export function createSessionHooks(args: {
|
||||
? safeHook("anthropic-effort", () => createAnthropicEffortHook())
|
||||
: null
|
||||
|
||||
const ultraworkModelOverride = isHookEnabled("ultrawork-model-override")
|
||||
? safeHook("ultrawork-model-override", () => createUltraworkModelOverrideHook({ agents: pluginConfig.agents }))
|
||||
: null
|
||||
|
||||
return {
|
||||
contextWindowMonitor,
|
||||
preemptiveCompaction,
|
||||
@@ -197,6 +191,5 @@ export function createSessionHooks(args: {
|
||||
questionLabelTruncator,
|
||||
taskResumeInfo,
|
||||
anthropicEffort,
|
||||
ultraworkModelOverride,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function createToolGuardHooks(args: {
|
||||
: null
|
||||
|
||||
const hashlineReadEnhancer = isHookEnabled("hashline-read-enhancer")
|
||||
? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.experimental?.hashline_edit ?? false } }))
|
||||
? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.experimental?.hashline_edit ?? true } }))
|
||||
: null
|
||||
|
||||
return {
|
||||
|
||||
@@ -43,13 +43,13 @@ export function createToolExecuteBeforeHandler(args: {
|
||||
}
|
||||
}
|
||||
|
||||
if (hooks.ralphLoop && input.tool === "slashcommand") {
|
||||
const rawCommand = typeof output.args.command === "string" ? output.args.command : undefined
|
||||
const command = rawCommand?.replace(/^\//, "").toLowerCase()
|
||||
if (hooks.ralphLoop && input.tool === "skill") {
|
||||
const rawName = typeof output.args.name === "string" ? output.args.name : undefined
|
||||
const command = rawName?.replace(/^\//, "").toLowerCase()
|
||||
const sessionID = input.sessionID || getMainSessionID()
|
||||
|
||||
if (command === "ralph-loop" && sessionID) {
|
||||
const rawArgs = rawCommand?.replace(/^\/?(ralph-loop)\s*/i, "") || ""
|
||||
const rawArgs = rawName?.replace(/^\/?(ralph-loop)\s*/i, "") || ""
|
||||
const taskMatch = rawArgs.match(/^["'](.+?)["']/)
|
||||
const prompt =
|
||||
taskMatch?.[1] ||
|
||||
@@ -66,7 +66,7 @@ export function createToolExecuteBeforeHandler(args: {
|
||||
} else if (command === "cancel-ralph" && sessionID) {
|
||||
hooks.ralphLoop.cancelLoop(sessionID)
|
||||
} else if (command === "ulw-loop" && sessionID) {
|
||||
const rawArgs = rawCommand?.replace(/^\/?(ulw-loop)\s*/i, "") || ""
|
||||
const rawArgs = rawName?.replace(/^\/?(ulw-loop)\s*/i, "") || ""
|
||||
const taskMatch = rawArgs.match(/^["'](.+?)["']/)
|
||||
const prompt =
|
||||
taskMatch?.[1] ||
|
||||
@@ -84,9 +84,9 @@ export function createToolExecuteBeforeHandler(args: {
|
||||
}
|
||||
}
|
||||
|
||||
if (input.tool === "slashcommand") {
|
||||
const rawCommand = typeof output.args.command === "string" ? output.args.command : undefined
|
||||
const command = rawCommand?.replace(/^\//, "").toLowerCase()
|
||||
if (input.tool === "skill") {
|
||||
const rawName = typeof output.args.name === "string" ? output.args.name : undefined
|
||||
const command = rawName?.replace(/^\//, "").toLowerCase()
|
||||
const sessionID = input.sessionID || getMainSessionID()
|
||||
|
||||
if (command === "stop-continuation" && sessionID) {
|
||||
|
||||
@@ -11,9 +11,8 @@ import {
|
||||
createBackgroundTools,
|
||||
createCallOmoAgent,
|
||||
createLookAt,
|
||||
createSkillTool,
|
||||
createSkillMcpTool,
|
||||
createSlashcommandTool,
|
||||
createSkillTool,
|
||||
createGrepTools,
|
||||
createGlobTools,
|
||||
createAstGrepTools,
|
||||
@@ -89,14 +88,6 @@ export function createToolRegistry(args: {
|
||||
|
||||
const getSessionIDForMcp = (): string => getMainSessionID() || ""
|
||||
|
||||
const skillTool = createSkillTool({
|
||||
skills: skillContext.mergedSkills,
|
||||
mcpManager: managers.skillMcpManager,
|
||||
getSessionID: getSessionIDForMcp,
|
||||
gitMasterConfig: pluginConfig.git_master,
|
||||
disabledSkills: skillContext.disabledSkills,
|
||||
})
|
||||
|
||||
const skillMcpTool = createSkillMcpTool({
|
||||
manager: managers.skillMcpManager,
|
||||
getLoadedSkills: () => skillContext.mergedSkills,
|
||||
@@ -104,9 +95,12 @@ export function createToolRegistry(args: {
|
||||
})
|
||||
|
||||
const commands = discoverCommandsSync(ctx.directory)
|
||||
const slashcommandTool = createSlashcommandTool({
|
||||
const skillTool = createSkillTool({
|
||||
commands,
|
||||
skills: skillContext.mergedSkills,
|
||||
mcpManager: managers.skillMcpManager,
|
||||
getSessionID: getSessionIDForMcp,
|
||||
gitMasterConfig: pluginConfig.git_master,
|
||||
})
|
||||
|
||||
const taskSystemEnabled = pluginConfig.experimental?.task_system ?? false
|
||||
@@ -119,7 +113,7 @@ export function createToolRegistry(args: {
|
||||
}
|
||||
: {}
|
||||
|
||||
const hashlineEnabled = pluginConfig.experimental?.hashline_edit ?? false
|
||||
const hashlineEnabled = pluginConfig.experimental?.hashline_edit ?? true
|
||||
const hashlineToolsRecord: Record<string, ToolDefinition> = hashlineEnabled
|
||||
? { edit: createHashlineEditTool() }
|
||||
: {}
|
||||
@@ -134,9 +128,8 @@ export function createToolRegistry(args: {
|
||||
call_omo_agent: callOmoAgent,
|
||||
...(lookAt ? { look_at: lookAt } : {}),
|
||||
task: delegateTask,
|
||||
skill: skillTool,
|
||||
skill_mcp: skillMcpTool,
|
||||
slashcommand: slashcommandTool,
|
||||
skill: skillTool,
|
||||
interactive_bash,
|
||||
...taskToolsRecord,
|
||||
...hashlineToolsRecord,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/shared/ — 101 Utility Files in 13 Categories
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/tools/ — 26 Tools Across 14 Directories
|
||||
|
||||
**Generated:** 2026-02-17
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
53
src/tools/background-task/AGENTS.md
Normal file
53
src/tools/background-task/AGENTS.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# src/tools/background-task/ — Background Task Tool Wrappers
|
||||
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
18 files. Tool-layer wrappers for `background_output` and `background_cancel`. Does NOT implement the background execution engine — that lives in `src/features/background-agent/`. This directory provides the LLM-facing tool interface.
|
||||
|
||||
## THREE TOOLS
|
||||
|
||||
| Tool | Factory | Purpose |
|
||||
|------|---------|---------|
|
||||
| `background_output` | `createBackgroundOutput` | Get results from a running/completed background task |
|
||||
| `background_cancel` | `createBackgroundCancel` | Cancel running task(s) |
|
||||
| `createBackgroundTask` | internal | Shared factory used by both |
|
||||
|
||||
## KEY FILES
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `create-background-output.ts` | `background_output` tool: fetch task results by task_id |
|
||||
| `create-background-cancel.ts` | `background_cancel` tool: cancel by taskId or all=true |
|
||||
| `create-background-task.ts` | Shared tool factory with common params |
|
||||
| `clients.ts` | Client interfaces for background output and cancel |
|
||||
| `session-messages.ts` | Fetch session messages from OpenCode |
|
||||
| `full-session-format.ts` | Format full session output (messages, thinking blocks) |
|
||||
| `task-result-format.ts` | Format task result for LLM consumption |
|
||||
| `task-status-format.ts` | Format task status (running/completed/error) |
|
||||
| `message-dir.ts` | Temp directory for message exchange |
|
||||
| `truncate-text.ts` | Truncate large output to fit context |
|
||||
| `time-format.ts` | Human-readable duration formatting |
|
||||
| `delay.ts` | Polling delay utility |
|
||||
| `types.ts` | `BackgroundTaskOptions`, result/status types |
|
||||
| `constants.ts` | Timeout defaults, polling intervals |
|
||||
|
||||
## BACKGROUND OUTPUT MODES
|
||||
|
||||
```
|
||||
background_output(task_id, block=false) → check current status/result
|
||||
background_output(task_id, block=true) → wait until complete (timeout default: 120s)
|
||||
background_output(task_id, full_session=true) → return full session transcript
|
||||
background_output(task_id, message_limit=N) → last N messages only
|
||||
background_output(task_id, include_thinking=true) → include thinking blocks
|
||||
```
|
||||
|
||||
## RELATIONSHIP TO BACKGROUND ENGINE
|
||||
|
||||
```
|
||||
tools/background-task/ ← LLM tool interface
|
||||
features/background-agent/ ← execution engine (BackgroundManager)
|
||||
```
|
||||
|
||||
`createBackgroundOutput` queries `BackgroundManager.getTask(task_id)` — it does not manage task state.
|
||||
51
src/tools/call-omo-agent/AGENTS.md
Normal file
51
src/tools/call-omo-agent/AGENTS.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# src/tools/call-omo-agent/ — Direct Agent Invocation Tool
|
||||
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
23 files. The `call_omo_agent` tool — direct invocation of named agents (explore, librarian only). Distinct from `delegate-task`: no category system, no skill loading, no model selection. Fixed agent set, same execution modes (background/sync).
|
||||
|
||||
## DISTINCTION FROM delegate-task
|
||||
|
||||
| Aspect | `call_omo_agent` | `delegate-task` (`task`) |
|
||||
|--------|-----------------|--------------------------|
|
||||
| Agent selection | Named agent (explore/librarian) | Category or subagent_type |
|
||||
| Skill loading | None | `load_skills[]` supported |
|
||||
| Model selection | From agent's fallback chain | From category config |
|
||||
| Use case | Quick contextual grep | Full delegation with skills |
|
||||
|
||||
## ALLOWED AGENTS
|
||||
|
||||
Only `explore` and `librarian` — enforced via `ALLOWED_AGENTS` constant in `constants.ts`. Case-insensitive validation.
|
||||
|
||||
## EXECUTION MODES
|
||||
|
||||
Same two modes as delegate-task:
|
||||
|
||||
| Mode | File | Description |
|
||||
|------|------|-------------|
|
||||
| **Background** | `background-agent-executor.ts` | Async via `BackgroundManager` |
|
||||
| **Sync** | `sync-executor.ts` | Create session → wait for idle → return result |
|
||||
|
||||
## KEY FILES
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `tools.ts` | `createCallOmoAgent()` factory — validates agent, routes to executor |
|
||||
| `background-executor.ts` | Routes to background or sync based on `run_in_background` |
|
||||
| `background-agent-executor.ts` | Launch via `BackgroundManager.launch()` |
|
||||
| `sync-executor.ts` | Synchronous session: create → send prompt → poll → fetch result |
|
||||
| `session-creator.ts` | Create OpenCode session for sync execution |
|
||||
| `subagent-session-creator.ts` | Create session with agent-specific config |
|
||||
| `subagent-session-prompter.ts` | Inject prompt into session |
|
||||
| `completion-poller.ts` | Poll until session idle |
|
||||
| `session-completion-poller.ts` | Session-specific completion check |
|
||||
| `session-message-output-extractor.ts` | Extract last assistant message as result |
|
||||
| `message-processor.ts` | Process raw message content |
|
||||
| `message-dir.ts` + `message-storage-directory.ts` | Temp storage for message exchange |
|
||||
| `types.ts` | `CallOmoAgentArgs`, `AllowedAgentType`, `ToolContextWithMetadata` |
|
||||
|
||||
## SESSION CONTINUATION
|
||||
|
||||
Pass `session_id` to resume an existing session rather than create a new one — handled in both executors.
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/tools/delegate-task/ — Task Delegation Engine
|
||||
|
||||
**Generated:** 2026-02-18
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@ export { lspManager }
|
||||
export { createAstGrepTools } from "./ast-grep"
|
||||
export { createGrepTools } from "./grep"
|
||||
export { createGlobTools } from "./glob"
|
||||
export { createSlashcommandTool, discoverCommandsSync } from "./slashcommand"
|
||||
export { createSkillTool } from "./skill"
|
||||
export { discoverCommandsSync } from "./slashcommand"
|
||||
export { createSessionManagerTools } from "./session-manager"
|
||||
|
||||
export { sessionExists } from "./session-manager/storage"
|
||||
|
||||
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
|
||||
export { createSkillTool } from "./skill"
|
||||
export { createSkillMcpTool } from "./skill-mcp"
|
||||
|
||||
import {
|
||||
|
||||
70
src/tools/lsp/AGENTS.md
Normal file
70
src/tools/lsp/AGENTS.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# src/tools/lsp/ — LSP Tool Implementations
|
||||
|
||||
**Generated:** 2026-02-19
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
32 files. Full LSP (Language Server Protocol) client stack exposed as 6 tools. Custom implementation that manages server processes, opens files, and forwards requests — does NOT delegate to OpenCode's built-in LSP.
|
||||
|
||||
## TOOL EXPOSURE
|
||||
|
||||
| Tool | File | What It Does |
|
||||
|------|------|--------------|
|
||||
| `lsp_goto_definition` | `goto-definition-tool.ts` | Jump to symbol definition |
|
||||
| `lsp_find_references` | `find-references-tool.ts` | All usages of a symbol |
|
||||
| `lsp_symbols` | `symbols-tool.ts` | Document outline or workspace symbol search |
|
||||
| `lsp_diagnostics` | `diagnostics-tool.ts` | Errors/warnings from language server |
|
||||
| `lsp_prepare_rename` | `rename-tools.ts` | Validate rename before applying |
|
||||
| `lsp_rename` | `rename-tools.ts` | Apply safe rename across workspace |
|
||||
|
||||
All 6 are direct `ToolDefinition` objects (not factory functions) — registered directly in `tool-registry.ts`.
|
||||
|
||||
## ARCHITECTURE
|
||||
|
||||
```
|
||||
tools.ts (6 ToolDefinition exports)
|
||||
↓ uses
|
||||
LspClientWrapper (lsp-client-wrapper.ts)
|
||||
↓ wraps
|
||||
LSPClient (lsp-client.ts) extends LSPClientConnection (lsp-client-connection.ts)
|
||||
↓ communicates via
|
||||
LSPClientTransport (lsp-client-transport.ts)
|
||||
↓ talks to
|
||||
LSPProcess (lsp-process.ts) — spawns server binary
|
||||
```
|
||||
|
||||
## KEY FILES
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `lsp-client-wrapper.ts` | High-level entry: resolves server, opens file, runs request |
|
||||
| `lsp-client.ts` | `LSPClient` — file tracking, document sync (`didOpen`/`didChange`) |
|
||||
| `lsp-client-connection.ts` | JSON-RPC request/response/notification layer |
|
||||
| `lsp-client-transport.ts` | stdin/stdout byte-stream framing |
|
||||
| `lsp-process.ts` | Spawn + cleanup of LSP server process |
|
||||
| `lsp-manager-process-cleanup.ts` | Reap orphan LSP processes on exit |
|
||||
| `lsp-manager-temp-directory-cleanup.ts` | Clean temp dirs used by some servers |
|
||||
| `server-definitions.ts` | 40+ builtin servers synced from OpenCode's `server.ts` |
|
||||
| `server-config-loader.ts` | Load custom server config from `.opencode/lsp.json` |
|
||||
| `server-resolution.ts` | Resolve which server handles a file extension |
|
||||
| `server-installation.ts` | Detect missing binaries, surface install hints |
|
||||
| `language-mappings.ts` | Extension → language ID mapping |
|
||||
| `lsp-formatters.ts` | Format LSP responses into human-readable strings |
|
||||
| `workspace-edit.ts` | Apply `WorkspaceEdit` results to disk (for rename) |
|
||||
| `types.ts` | `LSPServerConfig`, `Position`, `Range`, `Location`, `Diagnostic` etc. |
|
||||
|
||||
## SERVER RESOLUTION
|
||||
|
||||
```
|
||||
file.ts → extension (.ts) → language-mappings → server ID (typescript)
|
||||
→ server-resolution: check user config (.opencode/lsp.json) → fall back to server-definitions.ts
|
||||
→ server-installation: verify binary exists (warn with install hint if not)
|
||||
→ LSPProcess.spawn(command[])
|
||||
```
|
||||
|
||||
## NOTES
|
||||
|
||||
- File must be opened via `didOpen` before any LSP request — `LSPClient.openFile()` handles this
|
||||
- 1s delay after `didOpen` for server initialization before sending requests
|
||||
- `lsp_servers` tool was removed — duplicates OpenCode's built-in `LspServers` tool
|
||||
- Synced with OpenCode's `server.ts` — when adding servers, check upstream first
|
||||
@@ -1,8 +1,9 @@
|
||||
export const TOOL_NAME = "skill" as const
|
||||
|
||||
export const TOOL_DESCRIPTION_NO_SKILLS = "Load a skill to get detailed instructions for a specific task. No skills are currently available."
|
||||
export const TOOL_DESCRIPTION_NO_SKILLS = "Load a skill or execute a slash command to get detailed instructions for a specific task. No skills are currently available."
|
||||
|
||||
export const TOOL_DESCRIPTION_PREFIX = `Load a skill to get detailed instructions for a specific task.
|
||||
export const TOOL_DESCRIPTION_PREFIX = `Load a skill or execute a command to get detailed instructions for a specific task.
|
||||
|
||||
Skills provide specialized knowledge and step-by-step guidance.
|
||||
Use this when a task matches an available skill's description.`
|
||||
Skills and commands provide specialized knowledge and step-by-step guidance.
|
||||
Use this when a task matches an available skill's or command's description.
|
||||
`
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as fs from "node:fs"
|
||||
import { createSkillTool } from "./tools"
|
||||
import { SkillMcpManager } from "../../features/skill-mcp-manager"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||
import type { CommandInfo } from "../slashcommand/types"
|
||||
import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
const originalReadFileSync = fs.readFileSync.bind(fs)
|
||||
@@ -105,6 +106,61 @@ describe("skill tool - synchronous description", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("skill tool - command listing format", () => {
|
||||
it("uses XML format for commands in description", () => {
|
||||
// given
|
||||
const commands: CommandInfo[] = [
|
||||
{
|
||||
name: "publish",
|
||||
path: "/test/commands/publish",
|
||||
metadata: { name: "publish", description: "Publish to npm", argumentHint: "<patch|minor|major>" },
|
||||
scope: "opencode-project",
|
||||
},
|
||||
{
|
||||
name: "commit",
|
||||
path: "/test/commands/commit",
|
||||
metadata: { name: "commit", description: "Commits changes" },
|
||||
scope: "builtin",
|
||||
},
|
||||
]
|
||||
|
||||
// when
|
||||
const tool = createSkillTool({ skills: [], commands })
|
||||
|
||||
// then
|
||||
expect(tool.description).toContain("<available_commands>")
|
||||
expect(tool.description).toContain("</available_commands>")
|
||||
expect(tool.description).toContain("<command>")
|
||||
expect(tool.description).toContain("<name>/publish <patch|minor|major></name>")
|
||||
expect(tool.description).toContain("<description>Publish to npm</description>")
|
||||
expect(tool.description).toContain("<scope>opencode-project</scope>")
|
||||
expect(tool.description).toContain("<name>/commit</name>")
|
||||
expect(tool.description).toContain("<scope>builtin</scope>")
|
||||
})
|
||||
|
||||
it("uses XML format for both skills and commands together", () => {
|
||||
// given
|
||||
const loadedSkills = [createMockSkill("test-skill")]
|
||||
const commands: CommandInfo[] = [
|
||||
{
|
||||
name: "deploy",
|
||||
path: "/test/commands/deploy",
|
||||
metadata: { name: "deploy", description: "Deploy app" },
|
||||
scope: "user",
|
||||
},
|
||||
]
|
||||
|
||||
// when
|
||||
const tool = createSkillTool({ skills: loadedSkills, commands })
|
||||
|
||||
// then
|
||||
expect(tool.description).toContain("<available_skills>")
|
||||
expect(tool.description).toContain("<available_commands>")
|
||||
expect(tool.description).toContain("<command>")
|
||||
expect(tool.description).toContain("<name>/deploy</name>")
|
||||
})
|
||||
})
|
||||
|
||||
describe("skill tool - agent restriction", () => {
|
||||
it("allows skill without agent restriction to any agent", async () => {
|
||||
// given
|
||||
|
||||
@@ -7,6 +7,9 @@ import { getAllSkills, extractSkillTemplate } from "../../features/opencode-skil
|
||||
import { injectGitMasterConfig } from "../../features/opencode-skill-loader/skill-content"
|
||||
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
|
||||
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { discoverCommandsSync } from "../slashcommand/command-discovery"
|
||||
import type { CommandInfo } from "../slashcommand/types"
|
||||
import { formatLoadedCommand } from "../slashcommand/command-output-formatter"
|
||||
|
||||
function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
|
||||
return {
|
||||
@@ -21,23 +24,45 @@ function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
|
||||
}
|
||||
}
|
||||
|
||||
function formatSkillsXml(skills: SkillInfo[]): string {
|
||||
if (skills.length === 0) return ""
|
||||
function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]): string {
|
||||
const lines: string[] = []
|
||||
|
||||
const skillsXml = skills.map(skill => {
|
||||
const lines = [
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
]
|
||||
if (skill.compatibility) {
|
||||
lines.push(` <compatibility>${skill.compatibility}</compatibility>`)
|
||||
}
|
||||
lines.push(" </skill>")
|
||||
return lines.join("\n")
|
||||
}).join("\n")
|
||||
if (skills.length === 0 && commands.length === 0) {
|
||||
return TOOL_DESCRIPTION_NO_SKILLS
|
||||
}
|
||||
|
||||
return `\n\n<available_skills>\n${skillsXml}\n</available_skills>`
|
||||
if (skills.length > 0) {
|
||||
const skillsXml = skills.map(skill => {
|
||||
const parts = [
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
]
|
||||
if (skill.compatibility) {
|
||||
parts.push(` <compatibility>${skill.compatibility}</compatibility>`)
|
||||
}
|
||||
parts.push(" </skill>")
|
||||
return parts.join("\n")
|
||||
}).join("\n")
|
||||
lines.push(`\n<available_skills>\n${skillsXml}\n</available_skills>`)
|
||||
}
|
||||
|
||||
if (commands.length > 0) {
|
||||
const commandsXml = commands.map(cmd => {
|
||||
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
|
||||
const parts = [
|
||||
" <command>",
|
||||
` <name>/${cmd.name}${hint}</name>`,
|
||||
` <description>${cmd.metadata.description || "(no description)"}</description>`,
|
||||
` <scope>${cmd.scope}</scope>`,
|
||||
" </command>",
|
||||
]
|
||||
return parts.join("\n")
|
||||
}).join("\n")
|
||||
lines.push(`\n<available_commands>\n${commandsXml}\n</available_commands>`)
|
||||
}
|
||||
|
||||
return TOOL_DESCRIPTION_PREFIX + lines.join("")
|
||||
}
|
||||
|
||||
async function extractSkillBody(skill: LoadedSkill): Promise<string> {
|
||||
@@ -128,6 +153,7 @@ async function formatMcpCapabilities(
|
||||
|
||||
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
|
||||
let cachedSkills: LoadedSkill[] | null = null
|
||||
let cachedCommands: CommandInfo[] | null = options.commands ?? null
|
||||
let cachedDescription: string | null = null
|
||||
|
||||
const getSkills = async (): Promise<LoadedSkill[]> => {
|
||||
@@ -137,23 +163,30 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
return cachedSkills
|
||||
}
|
||||
|
||||
const getDescription = async (): Promise<string> => {
|
||||
const getCommands = (): CommandInfo[] => {
|
||||
if (cachedCommands) return cachedCommands
|
||||
cachedCommands = discoverCommandsSync()
|
||||
return cachedCommands
|
||||
}
|
||||
|
||||
const buildDescription = async (): Promise<string> => {
|
||||
if (cachedDescription) return cachedDescription
|
||||
const skills = await getSkills()
|
||||
const commands = getCommands()
|
||||
const skillInfos = skills.map(loadedSkillToInfo)
|
||||
cachedDescription = skillInfos.length === 0
|
||||
? TOOL_DESCRIPTION_NO_SKILLS
|
||||
: TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos)
|
||||
cachedDescription = formatCombinedDescription(skillInfos, commands)
|
||||
return cachedDescription
|
||||
}
|
||||
|
||||
if (options.skills) {
|
||||
// Eagerly build description when callers pre-provide skills/commands.
|
||||
if (options.skills !== undefined) {
|
||||
const skillInfos = options.skills.map(loadedSkillToInfo)
|
||||
cachedDescription = skillInfos.length === 0
|
||||
? TOOL_DESCRIPTION_NO_SKILLS
|
||||
: TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos)
|
||||
const commandsForDescription = options.commands ?? []
|
||||
cachedDescription = formatCombinedDescription(skillInfos, commandsForDescription)
|
||||
} else if (options.commands !== undefined) {
|
||||
cachedDescription = formatCombinedDescription([], options.commands)
|
||||
} else {
|
||||
getDescription()
|
||||
void buildDescription()
|
||||
}
|
||||
|
||||
return tool({
|
||||
@@ -161,49 +194,79 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
|
||||
},
|
||||
args: {
|
||||
name: tool.schema.string().describe("The skill identifier from available_skills (e.g., 'code-review')"),
|
||||
name: tool.schema.string().describe("The skill or command name (e.g., 'code-review' or 'publish'). Use without leading slash for commands."),
|
||||
},
|
||||
async execute(args: SkillArgs, ctx?: { agent?: string }) {
|
||||
const skills = await getSkills()
|
||||
const skill = skills.find(s => s.name === args.name)
|
||||
const commands = getCommands()
|
||||
|
||||
if (!skill) {
|
||||
const available = skills.map(s => s.name).join(", ")
|
||||
throw new Error(`Skill "${args.name}" not found. Available skills: ${available || "none"}`)
|
||||
const requestedName = args.name.replace(/^\//, "")
|
||||
|
||||
// Check skills first (exact match, case-insensitive)
|
||||
const matchedSkill = skills.find(s => s.name.toLowerCase() === requestedName.toLowerCase())
|
||||
|
||||
if (matchedSkill) {
|
||||
if (matchedSkill.definition.agent && (!ctx?.agent || matchedSkill.definition.agent !== ctx.agent)) {
|
||||
throw new Error(`Skill "${matchedSkill.name}" is restricted to agent "${matchedSkill.definition.agent}"`)
|
||||
}
|
||||
|
||||
let body = await extractSkillBody(matchedSkill)
|
||||
|
||||
if (matchedSkill.name === "git-master") {
|
||||
body = injectGitMasterConfig(body, options.gitMasterConfig)
|
||||
}
|
||||
|
||||
const dir = matchedSkill.path ? dirname(matchedSkill.path) : matchedSkill.resolvedPath || process.cwd()
|
||||
|
||||
const output = [
|
||||
`## Skill: ${matchedSkill.name}`,
|
||||
"",
|
||||
`**Base directory**: ${dir}`,
|
||||
"",
|
||||
body,
|
||||
]
|
||||
|
||||
if (options.mcpManager && options.getSessionID && matchedSkill.mcpConfig) {
|
||||
const mcpInfo = await formatMcpCapabilities(
|
||||
matchedSkill,
|
||||
options.mcpManager,
|
||||
options.getSessionID()
|
||||
)
|
||||
if (mcpInfo) {
|
||||
output.push(mcpInfo)
|
||||
}
|
||||
}
|
||||
|
||||
return output.join("\n")
|
||||
}
|
||||
|
||||
if (skill.definition.agent && (!ctx?.agent || skill.definition.agent !== ctx.agent)) {
|
||||
throw new Error(`Skill "${args.name}" is restricted to agent "${skill.definition.agent}"`)
|
||||
// Check commands (exact match, case-insensitive)
|
||||
const matchedCommand = commands.find(c => c.name.toLowerCase() === requestedName.toLowerCase())
|
||||
|
||||
if (matchedCommand) {
|
||||
return await formatLoadedCommand(matchedCommand)
|
||||
}
|
||||
|
||||
let body = await extractSkillBody(skill)
|
||||
|
||||
if (args.name === "git-master") {
|
||||
body = injectGitMasterConfig(body, options.gitMasterConfig)
|
||||
}
|
||||
|
||||
const dir = skill.path ? dirname(skill.path) : skill.resolvedPath || process.cwd()
|
||||
|
||||
const output = [
|
||||
`## Skill: ${skill.name}`,
|
||||
"",
|
||||
`**Base directory**: ${dir}`,
|
||||
"",
|
||||
body,
|
||||
// No match found — provide helpful error with partial matches
|
||||
const allNames = [
|
||||
...skills.map(s => s.name),
|
||||
...commands.map(c => `/${c.name}`),
|
||||
]
|
||||
|
||||
if (options.mcpManager && options.getSessionID && skill.mcpConfig) {
|
||||
const mcpInfo = await formatMcpCapabilities(
|
||||
skill,
|
||||
options.mcpManager,
|
||||
options.getSessionID()
|
||||
const partialMatches = allNames.filter(n =>
|
||||
n.toLowerCase().includes(requestedName.toLowerCase())
|
||||
)
|
||||
|
||||
if (partialMatches.length > 0) {
|
||||
throw new Error(
|
||||
`Skill or command "${args.name}" not found. Did you mean: ${partialMatches.join(", ")}?`
|
||||
)
|
||||
if (mcpInfo) {
|
||||
output.push(mcpInfo)
|
||||
}
|
||||
}
|
||||
|
||||
return output.join("\n")
|
||||
const available = allNames.join(", ")
|
||||
throw new Error(
|
||||
`Skill or command "${args.name}" not found. Available: ${available || "none"}`
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SkillScope, LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||
import type { SkillMcpManager } from "../../features/skill-mcp-manager"
|
||||
import type { GitMasterConfig } from "../../config/schema"
|
||||
import type { CommandInfo } from "../slashcommand/types"
|
||||
|
||||
export interface SkillArgs {
|
||||
name: string
|
||||
@@ -22,6 +23,8 @@ export interface SkillLoadOptions {
|
||||
opencodeOnly?: boolean
|
||||
/** Pre-merged skills to use instead of discovering */
|
||||
skills?: LoadedSkill[]
|
||||
/** Pre-discovered commands to use instead of discovering */
|
||||
commands?: CommandInfo[]
|
||||
/** MCP manager for querying skill-embedded MCP servers */
|
||||
mcpManager?: SkillMcpManager
|
||||
/** Session ID getter for MCP client identification */
|
||||
|
||||
131
src/tools/slashcommand/skill-formatter.ts
Normal file
131
src/tools/slashcommand/skill-formatter.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { dirname } from "node:path"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
import { extractSkillTemplate } from "../../features/opencode-skill-loader/skill-content"
|
||||
import { injectGitMasterConfig as injectGitMasterConfigOriginal } from "../../features/opencode-skill-loader/skill-content"
|
||||
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
|
||||
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
||||
import type { GitMasterConfig } from "../../config/schema/git-master"
|
||||
|
||||
export async function extractSkillBody(skill: LoadedSkill): Promise<string> {
|
||||
if (skill.lazyContent) {
|
||||
const fullTemplate = await skill.lazyContent.load()
|
||||
const templateMatch = fullTemplate.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
|
||||
return templateMatch ? templateMatch[1].trim() : fullTemplate
|
||||
}
|
||||
|
||||
if (skill.path) {
|
||||
return extractSkillTemplate(skill)
|
||||
}
|
||||
|
||||
const templateMatch = skill.definition.template?.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
|
||||
return templateMatch ? templateMatch[1].trim() : skill.definition.template || ""
|
||||
}
|
||||
|
||||
export async function formatMcpCapabilities(
|
||||
skill: LoadedSkill,
|
||||
manager: SkillMcpManager,
|
||||
sessionID: string
|
||||
): Promise<string | null> {
|
||||
if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sections: string[] = ["", "## Available MCP Servers", ""]
|
||||
|
||||
for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName,
|
||||
skillName: skill.name,
|
||||
sessionID,
|
||||
}
|
||||
const context: SkillMcpServerContext = {
|
||||
config,
|
||||
skillName: skill.name,
|
||||
}
|
||||
|
||||
sections.push(`### ${serverName}`)
|
||||
sections.push("")
|
||||
|
||||
try {
|
||||
const [tools, resources, prompts] = await Promise.all([
|
||||
manager.listTools(info, context).catch(() => []),
|
||||
manager.listResources(info, context).catch(() => []),
|
||||
manager.listPrompts(info, context).catch(() => []),
|
||||
])
|
||||
|
||||
if (tools.length > 0) {
|
||||
sections.push("**Tools:**")
|
||||
sections.push("")
|
||||
for (const t of tools as Tool[]) {
|
||||
sections.push(`#### \`${t.name}\``)
|
||||
if (t.description) {
|
||||
sections.push(t.description)
|
||||
}
|
||||
sections.push("")
|
||||
sections.push("**inputSchema:**")
|
||||
sections.push("```json")
|
||||
sections.push(JSON.stringify(t.inputSchema, null, 2))
|
||||
sections.push("```")
|
||||
sections.push("")
|
||||
}
|
||||
}
|
||||
if (resources.length > 0) {
|
||||
sections.push(`**Resources**: ${resources.map((r: Resource) => r.uri).join(", ")}`)
|
||||
}
|
||||
if (prompts.length > 0) {
|
||||
sections.push(`**Prompts**: ${prompts.map((p: Prompt) => p.name).join(", ")}`)
|
||||
}
|
||||
|
||||
if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
|
||||
sections.push("*No capabilities discovered*")
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`)
|
||||
}
|
||||
|
||||
sections.push("")
|
||||
sections.push(`Use \`skill_mcp\` tool with \`mcp_name="${serverName}"\` to invoke.`)
|
||||
sections.push("")
|
||||
}
|
||||
|
||||
return sections.join("\n")
|
||||
}
|
||||
|
||||
export { injectGitMasterConfigOriginal as injectGitMasterConfig }
|
||||
|
||||
export async function formatSkillOutput(
|
||||
skill: LoadedSkill,
|
||||
mcpManager?: SkillMcpManager,
|
||||
getSessionID?: () => string,
|
||||
gitMasterConfig?: GitMasterConfig
|
||||
): Promise<string> {
|
||||
let body = await extractSkillBody(skill)
|
||||
|
||||
if (skill.name === "git-master") {
|
||||
body = injectGitMasterConfigOriginal(body, gitMasterConfig)
|
||||
}
|
||||
|
||||
const dir = skill.path ? dirname(skill.path) : skill.resolvedPath || process.cwd()
|
||||
|
||||
const output = [
|
||||
`## Skill: ${skill.name}`,
|
||||
"",
|
||||
`**Base directory**: ${dir}`,
|
||||
"",
|
||||
body,
|
||||
]
|
||||
|
||||
if (mcpManager && getSessionID && skill.mcpConfig) {
|
||||
const mcpInfo = await formatMcpCapabilities(
|
||||
skill,
|
||||
mcpManager,
|
||||
getSessionID()
|
||||
)
|
||||
if (mcpInfo) {
|
||||
output.push(mcpInfo)
|
||||
}
|
||||
}
|
||||
|
||||
return output.join("\n")
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { discoverCommandsSync } from "./command-discovery"
|
||||
import { buildDescriptionFromItems, TOOL_DESCRIPTION_PREFIX } from "./slashcommand-description"
|
||||
import { formatCommandList, formatLoadedCommand } from "./command-output-formatter"
|
||||
import { skillToCommandInfo } from "./skill-command-converter"
|
||||
import { formatSkillOutput } from "./skill-formatter"
|
||||
|
||||
export function createSlashcommandTool(options: SlashcommandToolOptions = {}): ToolDefinition {
|
||||
let cachedCommands: CommandInfo[] | null = options.commands ?? null
|
||||
@@ -75,6 +76,18 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T
|
||||
)
|
||||
|
||||
if (exactMatch) {
|
||||
const skills = await getSkills()
|
||||
const matchedSkill = skills.find(s => s.name === exactMatch.name)
|
||||
|
||||
if (matchedSkill) {
|
||||
return await formatSkillOutput(
|
||||
matchedSkill,
|
||||
options.mcpManager,
|
||||
options.getSessionID,
|
||||
options.gitMasterConfig
|
||||
)
|
||||
}
|
||||
|
||||
return await formatLoadedCommand(exactMatch, args.user_message)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { LoadedSkill, LazyContentLoader } from "../../features/opencode-skill-loader"
|
||||
import type { SkillMcpManager } from "../../features/skill-mcp-manager"
|
||||
import type { GitMasterConfig } from "../../config/schema/git-master"
|
||||
|
||||
export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
|
||||
|
||||
@@ -25,4 +27,10 @@ export interface SlashcommandToolOptions {
|
||||
commands?: CommandInfo[]
|
||||
/** Pre-loaded skills (skip discovery if provided) */
|
||||
skills?: LoadedSkill[]
|
||||
/** MCP manager for skill MCP capabilities */
|
||||
mcpManager?: SkillMcpManager
|
||||
/** Function to get current session ID */
|
||||
getSessionID?: () => string
|
||||
/** Git master configuration */
|
||||
gitMasterConfig?: GitMasterConfig
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user