* fix: background task completion detection and silent notifications - Fix TS2742 by adding explicit ToolDefinition type annotations - Add stability detection (3 consecutive stable polls after 10s minimum) - Remove early continue when sessionStatus is undefined - Add silent notification system via tool.execute.after hook injection - Change task retention from 200ms to 5 minutes for background_output retrieval - Fix formatTaskResult to sort messages by time descending Fixes hanging background tasks that never complete due to missing sessionStatus. * fix: improve background task completion detection and message extraction - Add stability-based completion detection (10s min + 3 stable polls) - Fix message extraction to recognize 'reasoning' parts from thinking models - Switch from promptAsync() to prompt() for proper agent initialization - Remove model parameter from prompt body (use agent's configured model) - Add fire-and-forget prompt pattern for sisyphus_task sync mode - Add silent notification via tool.execute.after hook injection - Fix indentation issues in manager.ts and index.ts Incorporates fixes from: - PR #592: Stability detection mechanism - PR #610: Model parameter passing (partially) - PR #628: Completion detection improvements Known limitation: Thinking models (e.g. claude-*-thinking-*) cause JSON Parse errors in child sessions. Use non-thinking models for background agents until OpenCode core resolves this. * fix: add tool_result handling and pendingByParent tracking for resume/external tasks Addresses code review feedback from PR #638: P1: Add tool_result type to validateSessionHasOutput() to prevent false negatives for tool-only background tasks that would otherwise timeout after 30 minutes despite having valid results. P2: Add pendingByParent tracking to resume() and registerExternalTask() to prevent premature 'ALL COMPLETE' notifications when mixing launched and resumed tasks. * fix: address code review feedback - log messages, model passthrough, sorting, race condition - Fix misleading log messages: 'promptAsync' -> 'prompt (fire-and-forget)' - Restore model passthrough in launch() for Sisyphus category configs - Fix call-omo-agent sorting: use time.created number instead of String(time) - Fix race condition: check promptError inside polling loop, not just after 100ms
253 lines
7.9 KiB
TypeScript
253 lines
7.9 KiB
TypeScript
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
|
import { existsSync, readdirSync, readFileSync } from "fs"
|
|
import { join, basename, dirname } from "path"
|
|
import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField } from "../../shared"
|
|
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
|
|
import { isMarkdownFile } from "../../shared/file-utils"
|
|
import { getClaudeConfigDir } from "../../shared"
|
|
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
|
|
import type { CommandScope, CommandMetadata, CommandInfo, SlashcommandToolOptions } from "./types"
|
|
|
|
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] {
|
|
if (!existsSync(commandsDir)) {
|
|
return []
|
|
}
|
|
|
|
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
|
const commands: CommandInfo[] = []
|
|
|
|
for (const entry of entries) {
|
|
if (!isMarkdownFile(entry)) continue
|
|
|
|
const commandPath = join(commandsDir, entry.name)
|
|
const commandName = basename(entry.name, ".md")
|
|
|
|
try {
|
|
const content = readFileSync(commandPath, "utf-8")
|
|
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
|
|
|
|
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
|
const metadata: CommandMetadata = {
|
|
name: commandName,
|
|
description: data.description || "",
|
|
argumentHint: data["argument-hint"],
|
|
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
|
agent: data.agent,
|
|
subtask: Boolean(data.subtask),
|
|
}
|
|
|
|
commands.push({
|
|
name: commandName,
|
|
path: commandPath,
|
|
metadata,
|
|
content: body,
|
|
scope,
|
|
})
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
|
|
return commands
|
|
}
|
|
|
|
export function discoverCommandsSync(): CommandInfo[] {
|
|
const { homedir } = require("os")
|
|
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
|
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
|
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command")
|
|
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
|
|
|
|
const userCommands = discoverCommandsFromDir(userCommandsDir, "user")
|
|
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")
|
|
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
|
|
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
|
|
|
|
return [...opencodeProjectCommands, ...projectCommands, ...opencodeGlobalCommands, ...userCommands]
|
|
}
|
|
|
|
function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
|
|
return {
|
|
name: skill.name,
|
|
path: skill.path,
|
|
metadata: {
|
|
name: skill.name,
|
|
description: skill.definition.description || "",
|
|
argumentHint: skill.definition.argumentHint,
|
|
model: skill.definition.model,
|
|
agent: skill.definition.agent,
|
|
subtask: skill.definition.subtask,
|
|
},
|
|
content: skill.definition.template,
|
|
scope: skill.scope,
|
|
lazyContentLoader: skill.lazyContent,
|
|
}
|
|
}
|
|
|
|
async function formatLoadedCommand(cmd: CommandInfo): Promise<string> {
|
|
const sections: string[] = []
|
|
|
|
sections.push(`# /${cmd.name} Command\n`)
|
|
|
|
if (cmd.metadata.description) {
|
|
sections.push(`**Description**: ${cmd.metadata.description}\n`)
|
|
}
|
|
|
|
if (cmd.metadata.argumentHint) {
|
|
sections.push(`**Usage**: /${cmd.name} ${cmd.metadata.argumentHint}\n`)
|
|
}
|
|
|
|
if (cmd.metadata.model) {
|
|
sections.push(`**Model**: ${cmd.metadata.model}\n`)
|
|
}
|
|
|
|
if (cmd.metadata.agent) {
|
|
sections.push(`**Agent**: ${cmd.metadata.agent}\n`)
|
|
}
|
|
|
|
if (cmd.metadata.subtask) {
|
|
sections.push(`**Subtask**: true\n`)
|
|
}
|
|
|
|
sections.push(`**Scope**: ${cmd.scope}\n`)
|
|
sections.push("---\n")
|
|
sections.push("## Command Instructions\n")
|
|
|
|
let content = cmd.content || ""
|
|
if (!content && cmd.lazyContentLoader) {
|
|
content = await cmd.lazyContentLoader.load()
|
|
}
|
|
|
|
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
|
|
const withFileRefs = await resolveFileReferencesInText(content, commandDir)
|
|
const resolvedContent = await resolveCommandsInText(withFileRefs)
|
|
sections.push(resolvedContent.trim())
|
|
|
|
return sections.join("\n")
|
|
}
|
|
|
|
function formatCommandList(items: CommandInfo[]): string {
|
|
if (items.length === 0) {
|
|
return "No commands or skills found."
|
|
}
|
|
|
|
const lines = ["# Available Commands & Skills\n"]
|
|
|
|
for (const cmd of items) {
|
|
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
|
|
lines.push(
|
|
`- **/${cmd.name}${hint}**: ${cmd.metadata.description || "(no description)"} (${cmd.scope})`
|
|
)
|
|
}
|
|
|
|
lines.push(`\n**Total**: ${items.length} items`)
|
|
return lines.join("\n")
|
|
}
|
|
|
|
const TOOL_DESCRIPTION_PREFIX = `Load a skill 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.
|
|
`
|
|
|
|
function buildDescriptionFromItems(items: CommandInfo[]): string {
|
|
const commandListForDescription = items
|
|
.map((cmd) => {
|
|
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
|
|
return `- /${cmd.name}${hint}: ${cmd.metadata.description} (${cmd.scope})`
|
|
})
|
|
.join("\n")
|
|
|
|
return `${TOOL_DESCRIPTION_PREFIX}
|
|
<available_skills>
|
|
${commandListForDescription}
|
|
</available_skills>`
|
|
}
|
|
|
|
export function createSlashcommandTool(options: SlashcommandToolOptions = {}): ToolDefinition {
|
|
let cachedCommands: CommandInfo[] | null = options.commands ?? null
|
|
let cachedSkills: LoadedSkill[] | null = options.skills ?? null
|
|
let cachedDescription: string | null = null
|
|
|
|
const getCommands = (): CommandInfo[] => {
|
|
if (cachedCommands) return cachedCommands
|
|
cachedCommands = discoverCommandsSync()
|
|
return cachedCommands
|
|
}
|
|
|
|
const getSkills = async (): Promise<LoadedSkill[]> => {
|
|
if (cachedSkills) return cachedSkills
|
|
cachedSkills = await discoverAllSkills()
|
|
return cachedSkills
|
|
}
|
|
|
|
const getAllItems = async (): Promise<CommandInfo[]> => {
|
|
const commands = getCommands()
|
|
const skills = await getSkills()
|
|
return [...commands, ...skills.map(skillToCommandInfo)]
|
|
}
|
|
|
|
const buildDescription = async (): Promise<string> => {
|
|
if (cachedDescription) return cachedDescription
|
|
const allItems = await getAllItems()
|
|
cachedDescription = buildDescriptionFromItems(allItems)
|
|
return cachedDescription
|
|
}
|
|
|
|
// Pre-warm the cache immediately
|
|
buildDescription()
|
|
|
|
return tool({
|
|
get description() {
|
|
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
|
|
},
|
|
|
|
args: {
|
|
command: tool.schema
|
|
.string()
|
|
.describe(
|
|
"The slash command to execute (without the leading slash). E.g., 'commit', 'plan', 'execute'."
|
|
),
|
|
},
|
|
|
|
async execute(args) {
|
|
const allItems = await getAllItems()
|
|
|
|
if (!args.command) {
|
|
return formatCommandList(allItems) + "\n\nProvide a command or skill name to execute."
|
|
}
|
|
|
|
const cmdName = args.command.replace(/^\//, "")
|
|
|
|
const exactMatch = allItems.find(
|
|
(cmd) => cmd.name.toLowerCase() === cmdName.toLowerCase()
|
|
)
|
|
|
|
if (exactMatch) {
|
|
return await formatLoadedCommand(exactMatch)
|
|
}
|
|
|
|
const partialMatches = allItems.filter((cmd) =>
|
|
cmd.name.toLowerCase().includes(cmdName.toLowerCase())
|
|
)
|
|
|
|
if (partialMatches.length > 0) {
|
|
const matchList = partialMatches.map((cmd) => `/${cmd.name}`).join(", ")
|
|
return (
|
|
`No exact match for "/${cmdName}". Did you mean: ${matchList}?\n\n` +
|
|
formatCommandList(allItems)
|
|
)
|
|
}
|
|
|
|
return (
|
|
`Command or skill "/${cmdName}" not found.\n\n` +
|
|
formatCommandList(allItems) +
|
|
"\n\nTry a different name."
|
|
)
|
|
},
|
|
})
|
|
}
|
|
|
|
// Default instance for backward compatibility (lazy loading)
|
|
export const slashcommand: ToolDefinition = createSlashcommandTool()
|