diff --git a/src/agents/prometheus/index.ts b/src/agents/prometheus/index.ts index ae1afbca2..4be93697f 100644 --- a/src/agents/prometheus/index.ts +++ b/src/agents/prometheus/index.ts @@ -1,50 +1,4 @@ -/** - * Prometheus Planner System Prompt - * - * Named after the Titan who gave fire (knowledge/foresight) to humanity. - * Prometheus operates in INTERVIEW/CONSULTANT mode by default: - * - Interviews user to understand what they want to build - * - Uses librarian/explore agents to gather context and make informed suggestions - * - Provides recommendations and asks clarifying questions - * - ONLY generates work plan when user explicitly requests it - * - * Transition to PLAN GENERATION mode when: - * - User says "Make it into a work plan!" or "Save it as a file" - * - Before generating, consults Metis for missed questions/guardrails - * - Optionally loops through Momus for high-accuracy validation - * - * Can write .md files only (enforced by prometheus-md-only hook). - */ - -import { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints" -import { PROMETHEUS_INTERVIEW_MODE } from "./interview-mode" -import { PROMETHEUS_PLAN_GENERATION } from "./plan-generation" -import { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode" -import { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template" -import { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary" - -/** - * Combined Prometheus system prompt. - * Assembled from modular sections for maintainability. - */ -export const PROMETHEUS_SYSTEM_PROMPT = `${PROMETHEUS_IDENTITY_CONSTRAINTS} -${PROMETHEUS_INTERVIEW_MODE} -${PROMETHEUS_PLAN_GENERATION} -${PROMETHEUS_HIGH_ACCURACY_MODE} -${PROMETHEUS_PLAN_TEMPLATE} -${PROMETHEUS_BEHAVIORAL_SUMMARY}` - -/** - * Prometheus planner permission configuration. - * Allows write/edit for plan files (.md only, enforced by prometheus-md-only hook). - * Question permission allows agent to ask user questions via OpenCode's QuestionTool. - */ -export const PROMETHEUS_PERMISSION = { - edit: "allow" as const, - bash: "allow" as const, - webfetch: "allow" as const, - question: "allow" as const, -} +export { PROMETHEUS_SYSTEM_PROMPT, PROMETHEUS_PERMISSION } from "./system-prompt" // Re-export individual sections for granular access export { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints" diff --git a/src/agents/prometheus/system-prompt.ts b/src/agents/prometheus/system-prompt.ts new file mode 100644 index 000000000..079c68c23 --- /dev/null +++ b/src/agents/prometheus/system-prompt.ts @@ -0,0 +1,29 @@ +import { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints" +import { PROMETHEUS_INTERVIEW_MODE } from "./interview-mode" +import { PROMETHEUS_PLAN_GENERATION } from "./plan-generation" +import { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode" +import { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template" +import { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary" + +/** + * Combined Prometheus system prompt. + * Assembled from modular sections for maintainability. + */ +export const PROMETHEUS_SYSTEM_PROMPT = `${PROMETHEUS_IDENTITY_CONSTRAINTS} +${PROMETHEUS_INTERVIEW_MODE} +${PROMETHEUS_PLAN_GENERATION} +${PROMETHEUS_HIGH_ACCURACY_MODE} +${PROMETHEUS_PLAN_TEMPLATE} +${PROMETHEUS_BEHAVIORAL_SUMMARY}` + +/** + * Prometheus planner permission configuration. + * Allows write/edit for plan files (.md only, enforced by prometheus-md-only hook). + * Question permission allows agent to ask user questions via OpenCode's QuestionTool. + */ +export const PROMETHEUS_PERMISSION = { + edit: "allow" as const, + bash: "allow" as const, + webfetch: "allow" as const, + question: "allow" as const, +} diff --git a/src/cli/cli-program.ts b/src/cli/cli-program.ts new file mode 100644 index 000000000..8cc80be04 --- /dev/null +++ b/src/cli/cli-program.ts @@ -0,0 +1,191 @@ +import { Command } from "commander" +import { install } from "./install" +import { run } from "./run" +import { getLocalVersion } from "./get-local-version" +import { doctor } from "./doctor" +import { createMcpOAuthCommand } from "./mcp-oauth" +import type { InstallArgs } from "./types" +import type { RunOptions } from "./run" +import type { GetLocalVersionOptions } from "./get-local-version/types" +import type { DoctorOptions } from "./doctor" +import packageJson from "../../package.json" with { type: "json" } + +const VERSION = packageJson.version + +const program = new Command() + +program + .name("oh-my-opencode") + .description("The ultimate OpenCode plugin - multi-model orchestration, LSP tools, and more") + .version(VERSION, "-v, --version", "Show version number") + .enablePositionalOptions() + +program + .command("install") + .description("Install and configure oh-my-opencode with interactive setup") + .option("--no-tui", "Run in non-interactive mode (requires all options)") + .option("--claude ", "Claude subscription: no, yes, max20") + .option("--openai ", "OpenAI/ChatGPT subscription: no, yes (default: no)") + .option("--gemini ", "Gemini integration: no, yes") + .option("--copilot ", "GitHub Copilot subscription: no, yes") + .option("--opencode-zen ", "OpenCode Zen access: no, yes (default: no)") + .option("--zai-coding-plan ", "Z.ai Coding Plan subscription: no, yes (default: no)") + .option("--kimi-for-coding ", "Kimi For Coding subscription: no, yes (default: no)") + .option("--skip-auth", "Skip authentication setup hints") + .addHelpText("after", ` +Examples: + $ bunx oh-my-opencode install + $ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no + $ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes + +Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi): + Claude Native anthropic/ models (Opus, Sonnet, Haiku) + OpenAI Native openai/ models (GPT-5.2 for Oracle) + Gemini Native google/ models (Gemini 3 Pro, Flash) + Copilot github-copilot/ models (fallback) + OpenCode Zen opencode/ models (opencode/claude-opus-4-6, etc.) + Z.ai zai-coding-plan/glm-4.7 (Librarian priority) + Kimi kimi-for-coding/k2p5 (Sisyphus/Prometheus fallback) +`) + .action(async (options) => { + const args: InstallArgs = { + tui: options.tui !== false, + claude: options.claude, + openai: options.openai, + gemini: options.gemini, + copilot: options.copilot, + opencodeZen: options.opencodeZen, + zaiCodingPlan: options.zaiCodingPlan, + kimiForCoding: options.kimiForCoding, + skipAuth: options.skipAuth ?? false, + } + const exitCode = await install(args) + process.exit(exitCode) + }) + +program + .command("run ") + .allowUnknownOption() + .passThroughOptions() + .description("Run opencode with todo/background task completion enforcement") + .option("-a, --agent ", "Agent to use (default: from CLI/env/config, fallback: Sisyphus)") + .option("-d, --directory ", "Working directory") + .option("-t, --timeout ", "Timeout in milliseconds (default: 30 minutes)", parseInt) + .option("-p, --port ", "Server port (attaches if port already in use)", parseInt) + .option("--attach ", "Attach to existing opencode server URL") + .option("--on-complete ", "Shell command to run after completion") + .option("--json", "Output structured JSON result to stdout") + .option("--session-id ", "Resume existing session instead of creating new one") + .addHelpText("after", ` +Examples: + $ bunx oh-my-opencode run "Fix the bug in index.ts" + $ bunx oh-my-opencode run --agent Sisyphus "Implement feature X" + $ bunx oh-my-opencode run --timeout 3600000 "Large refactoring task" + $ bunx oh-my-opencode run --port 4321 "Fix the bug" + $ bunx oh-my-opencode run --attach http://127.0.0.1:4321 "Fix the bug" + $ bunx oh-my-opencode run --json "Fix the bug" | jq .sessionId + $ bunx oh-my-opencode run --on-complete "notify-send Done" "Fix the bug" + $ bunx oh-my-opencode run --session-id ses_abc123 "Continue the work" + +Agent resolution order: + 1) --agent flag + 2) OPENCODE_DEFAULT_AGENT + 3) oh-my-opencode.json "default_run_agent" + 4) Sisyphus (fallback) + +Available core agents: + Sisyphus, Hephaestus, Prometheus, Atlas + +Unlike 'opencode run', this command waits until: + - All todos are completed or cancelled + - All child sessions (background tasks) are idle +`) + .action(async (message: string, options) => { + if (options.port && options.attach) { + console.error("Error: --port and --attach are mutually exclusive") + process.exit(1) + } + const runOptions: RunOptions = { + message, + agent: options.agent, + directory: options.directory, + timeout: options.timeout, + port: options.port, + attach: options.attach, + onComplete: options.onComplete, + json: options.json ?? false, + sessionId: options.sessionId, + } + const exitCode = await run(runOptions) + process.exit(exitCode) + }) + +program + .command("get-local-version") + .description("Show current installed version and check for updates") + .option("-d, --directory ", "Working directory to check config from") + .option("--json", "Output in JSON format for scripting") + .addHelpText("after", ` +Examples: + $ bunx oh-my-opencode get-local-version + $ bunx oh-my-opencode get-local-version --json + $ bunx oh-my-opencode get-local-version --directory /path/to/project + +This command shows: + - Current installed version + - Latest available version on npm + - Whether you're up to date + - Special modes (local dev, pinned version) +`) + .action(async (options) => { + const versionOptions: GetLocalVersionOptions = { + directory: options.directory, + json: options.json ?? false, + } + const exitCode = await getLocalVersion(versionOptions) + process.exit(exitCode) + }) + +program + .command("doctor") + .description("Check oh-my-opencode installation health and diagnose issues") + .option("--verbose", "Show detailed diagnostic information") + .option("--json", "Output results in JSON format") + .option("--category ", "Run only specific category") + .addHelpText("after", ` +Examples: + $ bunx oh-my-opencode doctor + $ bunx oh-my-opencode doctor --verbose + $ bunx oh-my-opencode doctor --json + $ bunx oh-my-opencode doctor --category authentication + +Categories: + installation Check OpenCode and plugin installation + configuration Validate configuration files + authentication Check auth provider status + dependencies Check external dependencies + tools Check LSP and MCP servers + updates Check for version updates +`) + .action(async (options) => { + const doctorOptions: DoctorOptions = { + verbose: options.verbose ?? false, + json: options.json ?? false, + category: options.category, + } + const exitCode = await doctor(doctorOptions) + process.exit(exitCode) + }) + +program + .command("version") + .description("Show version information") + .action(() => { + console.log(`oh-my-opencode v${VERSION}`) + }) + +program.addCommand(createMcpOAuthCommand()) + +export function runCli(): void { + program.parse() +} diff --git a/src/cli/config-manager.ts b/src/cli/config-manager.ts index eac107bf5..cfb6a9178 100644 --- a/src/cli/config-manager.ts +++ b/src/cli/config-manager.ts @@ -1,657 +1,23 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs" -import { parseJsonc, getOpenCodeConfigPaths } from "../shared" -import type { - OpenCodeBinaryType, - OpenCodeConfigPaths, -} from "../shared/opencode-config-dir-types" -import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types" -import { generateModelConfig } from "./model-fallback" +export type { ConfigContext } from "./config-manager/config-context" +export { + initConfigContext, + getConfigContext, + resetConfigContext, +} from "./config-manager/config-context" -const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const +export { fetchNpmDistTags } from "./config-manager/npm-dist-tags" +export { getPluginNameWithVersion } from "./config-manager/plugin-name-with-version" +export { addPluginToOpenCodeConfig } from "./config-manager/add-plugin-to-opencode-config" -interface ConfigContext { - binary: OpenCodeBinaryType - version: string | null - paths: OpenCodeConfigPaths -} +export { generateOmoConfig } from "./config-manager/generate-omo-config" +export { writeOmoConfig } from "./config-manager/write-omo-config" -let configContext: ConfigContext | null = null +export { isOpenCodeInstalled, getOpenCodeVersion } from "./config-manager/opencode-binary" -export function initConfigContext(binary: OpenCodeBinaryType, version: string | null): void { - const paths = getOpenCodeConfigPaths({ binary, version }) - configContext = { binary, version, paths } -} +export { fetchLatestVersion, addAuthPlugins } from "./config-manager/auth-plugins" +export { ANTIGRAVITY_PROVIDER_CONFIG } from "./config-manager/antigravity-provider-configuration" +export { addProviderConfig } from "./config-manager/add-provider-config" +export { detectCurrentConfig } from "./config-manager/detect-current-config" -export function getConfigContext(): ConfigContext { - if (!configContext) { - const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null }) - configContext = { binary: "opencode", version: null, paths } - } - return configContext -} - -export function resetConfigContext(): void { - configContext = null -} - -function getConfigDir(): string { - return getConfigContext().paths.configDir -} - -function getConfigJson(): string { - return getConfigContext().paths.configJson -} - -function getConfigJsonc(): string { - return getConfigContext().paths.configJsonc -} - -function getOmoConfig(): string { - return getConfigContext().paths.omoConfig -} - -const BUN_INSTALL_TIMEOUT_SECONDS = 60 -const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000 - -interface NodeError extends Error { - code?: string -} - -function isPermissionError(err: unknown): boolean { - const nodeErr = err as NodeError - return nodeErr?.code === "EACCES" || nodeErr?.code === "EPERM" -} - -function isFileNotFoundError(err: unknown): boolean { - const nodeErr = err as NodeError - return nodeErr?.code === "ENOENT" -} - -function formatErrorWithSuggestion(err: unknown, context: string): string { - if (isPermissionError(err)) { - return `Permission denied: Cannot ${context}. Try running with elevated permissions or check file ownership.` - } - - if (isFileNotFoundError(err)) { - return `File not found while trying to ${context}. The file may have been deleted or moved.` - } - - if (err instanceof SyntaxError) { - return `JSON syntax error while trying to ${context}: ${err.message}. Check for missing commas, brackets, or invalid characters.` - } - - const message = err instanceof Error ? err.message : String(err) - - if (message.includes("ENOSPC")) { - return `Disk full: Cannot ${context}. Free up disk space and try again.` - } - - if (message.includes("EROFS")) { - return `Read-only filesystem: Cannot ${context}. Check if the filesystem is mounted read-only.` - } - - return `Failed to ${context}: ${message}` -} - -export async function fetchLatestVersion(packageName: string): Promise { - try { - const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`) - if (!res.ok) return null - const data = await res.json() as { version: string } - return data.version - } catch { - return null - } -} - -interface NpmDistTags { - latest?: string - beta?: string - next?: string - [tag: string]: string | undefined -} - -const NPM_FETCH_TIMEOUT_MS = 5000 - -export async function fetchNpmDistTags(packageName: string): Promise { - try { - const res = await fetch(`https://registry.npmjs.org/-/package/${packageName}/dist-tags`, { - signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS), - }) - if (!res.ok) return null - const data = await res.json() as NpmDistTags - return data - } catch { - return null - } -} - -const PACKAGE_NAME = "oh-my-opencode" - -const PRIORITIZED_TAGS = ["latest", "beta", "next"] as const - -export async function getPluginNameWithVersion(currentVersion: string): Promise { - const distTags = await fetchNpmDistTags(PACKAGE_NAME) - - if (distTags) { - const allTags = new Set([...PRIORITIZED_TAGS, ...Object.keys(distTags)]) - for (const tag of allTags) { - if (distTags[tag] === currentVersion) { - return `${PACKAGE_NAME}@${tag}` - } - } - } - - return `${PACKAGE_NAME}@${currentVersion}` -} - -type ConfigFormat = "json" | "jsonc" | "none" - -interface OpenCodeConfig { - plugin?: string[] - [key: string]: unknown -} - -export function detectConfigFormat(): { format: ConfigFormat; path: string } { - const configJsonc = getConfigJsonc() - const configJson = getConfigJson() - - if (existsSync(configJsonc)) { - return { format: "jsonc", path: configJsonc } - } - if (existsSync(configJson)) { - return { format: "json", path: configJson } - } - return { format: "none", path: configJson } -} - -interface ParseConfigResult { - config: OpenCodeConfig | null - error?: string -} - -function isEmptyOrWhitespace(content: string): boolean { - return content.trim().length === 0 -} - -function parseConfigWithError(path: string): ParseConfigResult { - try { - const stat = statSync(path) - if (stat.size === 0) { - return { config: null, error: `Config file is empty: ${path}. Delete it or add valid JSON content.` } - } - - const content = readFileSync(path, "utf-8") - - if (isEmptyOrWhitespace(content)) { - return { config: null, error: `Config file contains only whitespace: ${path}. Delete it or add valid JSON content.` } - } - - const config = parseJsonc(content) - - if (config === null || config === undefined) { - return { config: null, error: `Config file parsed to null/undefined: ${path}. Ensure it contains valid JSON.` } - } - - if (typeof config !== "object" || Array.isArray(config)) { - return { config: null, error: `Config file must contain a JSON object, not ${Array.isArray(config) ? "an array" : typeof config}: ${path}` } - } - - return { config } - } catch (err) { - return { config: null, error: formatErrorWithSuggestion(err, `parse config file ${path}`) } - } -} - -function ensureConfigDir(): void { - const configDir = getConfigDir() - if (!existsSync(configDir)) { - mkdirSync(configDir, { recursive: true }) - } -} - -export async function addPluginToOpenCodeConfig(currentVersion: string): Promise { - try { - ensureConfigDir() - } catch (err) { - return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") } - } - - const { format, path } = detectConfigFormat() - const pluginEntry = await getPluginNameWithVersion(currentVersion) - - try { - if (format === "none") { - const config: OpenCodeConfig = { plugin: [pluginEntry] } - writeFileSync(path, JSON.stringify(config, null, 2) + "\n") - return { success: true, configPath: path } - } - - const parseResult = parseConfigWithError(path) - if (!parseResult.config) { - return { success: false, configPath: path, error: parseResult.error ?? "Failed to parse config file" } - } - - const config = parseResult.config - const plugins = config.plugin ?? [] - const existingIndex = plugins.findIndex((p) => p === PACKAGE_NAME || p.startsWith(`${PACKAGE_NAME}@`)) - - if (existingIndex !== -1) { - if (plugins[existingIndex] === pluginEntry) { - return { success: true, configPath: path } - } - plugins[existingIndex] = pluginEntry - } else { - plugins.push(pluginEntry) - } - - config.plugin = plugins - - if (format === "jsonc") { - const content = readFileSync(path, "utf-8") - const pluginArrayRegex = /"plugin"\s*:\s*\[([\s\S]*?)\]/ - const match = content.match(pluginArrayRegex) - - if (match) { - const formattedPlugins = plugins.map((p) => `"${p}"`).join(",\n ") - const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${formattedPlugins}\n ]`) - writeFileSync(path, newContent) - } else { - const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginEntry}"],`) - writeFileSync(path, newContent) - } - } else { - writeFileSync(path, JSON.stringify(config, null, 2) + "\n") - } - - return { success: true, configPath: path } - } catch (err) { - return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "update opencode config") } - } -} - -function deepMerge>(target: T, source: Partial): T { - const result = { ...target } - - for (const key of Object.keys(source) as Array) { - const sourceValue = source[key] - const targetValue = result[key] - - if ( - sourceValue !== null && - typeof sourceValue === "object" && - !Array.isArray(sourceValue) && - targetValue !== null && - typeof targetValue === "object" && - !Array.isArray(targetValue) - ) { - result[key] = deepMerge( - targetValue as Record, - sourceValue as Record - ) as T[keyof T] - } else if (sourceValue !== undefined) { - result[key] = sourceValue as T[keyof T] - } - } - - return result -} - -export function generateOmoConfig(installConfig: InstallConfig): Record { - return generateModelConfig(installConfig) -} - -export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult { - try { - ensureConfigDir() - } catch (err) { - return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") } - } - - const omoConfigPath = getOmoConfig() - - try { - const newConfig = generateOmoConfig(installConfig) - - if (existsSync(omoConfigPath)) { - try { - const stat = statSync(omoConfigPath) - const content = readFileSync(omoConfigPath, "utf-8") - - if (stat.size === 0 || isEmptyOrWhitespace(content)) { - writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") - return { success: true, configPath: omoConfigPath } - } - - const existing = parseJsonc>(content) - if (!existing || typeof existing !== "object" || Array.isArray(existing)) { - writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") - return { success: true, configPath: omoConfigPath } - } - - const merged = deepMerge(existing, newConfig) - writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n") - } catch (parseErr) { - if (parseErr instanceof SyntaxError) { - writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") - return { success: true, configPath: omoConfigPath } - } - throw parseErr - } - } else { - writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") - } - - return { success: true, configPath: omoConfigPath } - } catch (err) { - return { success: false, configPath: omoConfigPath, error: formatErrorWithSuggestion(err, "write oh-my-opencode config") } - } -} - -interface OpenCodeBinaryResult { - binary: OpenCodeBinaryType - version: string -} - -async function findOpenCodeBinaryWithVersion(): Promise { - for (const binary of OPENCODE_BINARIES) { - try { - const proc = Bun.spawn([binary, "--version"], { - stdout: "pipe", - stderr: "pipe", - }) - const output = await new Response(proc.stdout).text() - await proc.exited - if (proc.exitCode === 0) { - const version = output.trim() - initConfigContext(binary, version) - return { binary, version } - } - } catch { - continue - } - } - return null -} - -export async function isOpenCodeInstalled(): Promise { - const result = await findOpenCodeBinaryWithVersion() - return result !== null -} - -export async function getOpenCodeVersion(): Promise { - const result = await findOpenCodeBinaryWithVersion() - return result?.version ?? null -} - -export async function addAuthPlugins(config: InstallConfig): Promise { - try { - ensureConfigDir() - } catch (err) { - return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") } - } - - const { format, path } = detectConfigFormat() - - try { - let existingConfig: OpenCodeConfig | null = null - if (format !== "none") { - const parseResult = parseConfigWithError(path) - if (parseResult.error && !parseResult.config) { - existingConfig = {} - } else { - existingConfig = parseResult.config - } - } - - const plugins: string[] = existingConfig?.plugin ?? [] - - if (config.hasGemini) { - const version = await fetchLatestVersion("opencode-antigravity-auth") - const pluginEntry = version ? `opencode-antigravity-auth@${version}` : "opencode-antigravity-auth" - if (!plugins.some((p) => p.startsWith("opencode-antigravity-auth"))) { - plugins.push(pluginEntry) - } - } - - - - const newConfig = { ...(existingConfig ?? {}), plugin: plugins } - writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n") - return { success: true, configPath: path } - } catch (err) { - return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "add auth plugins to config") } - } -} - -export interface BunInstallResult { - success: boolean - timedOut?: boolean - error?: string -} - -export async function runBunInstall(): Promise { - const result = await runBunInstallWithDetails() - return result.success -} - -export async function runBunInstallWithDetails(): Promise { - try { - const proc = Bun.spawn(["bun", "install"], { - cwd: getConfigDir(), - stdout: "pipe", - stderr: "pipe", - }) - - const timeoutPromise = new Promise<"timeout">((resolve) => - setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS) - ) - - const exitPromise = proc.exited.then(() => "completed" as const) - - const result = await Promise.race([exitPromise, timeoutPromise]) - - if (result === "timeout") { - try { - proc.kill() - } catch { - /* intentionally empty - process may have already exited */ - } - return { - success: false, - timedOut: true, - error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ~/.config/opencode && bun i`, - } - } - - if (proc.exitCode !== 0) { - const stderr = await new Response(proc.stderr).text() - return { - success: false, - error: stderr.trim() || `bun install failed with exit code ${proc.exitCode}`, - } - } - - return { success: true } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - return { - success: false, - error: `bun install failed: ${message}. Is bun installed? Try: curl -fsSL https://bun.sh/install | bash`, - } - } -} - -/** - * Antigravity Provider Configuration - * - * IMPORTANT: Model names MUST use `antigravity-` prefix for stability. - * - * Since opencode-antigravity-auth v1.3.0, models use a variant system: - * - `antigravity-gemini-3-pro` with variants: low, high - * - `antigravity-gemini-3-flash` with variants: minimal, low, medium, high - * - * Legacy tier-suffixed names (e.g., `antigravity-gemini-3-pro-high`) still work - * but variants are the recommended approach. - * - * @see https://github.com/NoeFabris/opencode-antigravity-auth#models - */ -export const ANTIGRAVITY_PROVIDER_CONFIG = { - google: { - name: "Google", - models: { - "antigravity-gemini-3-pro": { - name: "Gemini 3 Pro (Antigravity)", - limit: { context: 1048576, output: 65535 }, - modalities: { input: ["text", "image", "pdf"], output: ["text"] }, - variants: { - low: { thinkingLevel: "low" }, - high: { thinkingLevel: "high" }, - }, - }, - "antigravity-gemini-3-flash": { - name: "Gemini 3 Flash (Antigravity)", - limit: { context: 1048576, output: 65536 }, - modalities: { input: ["text", "image", "pdf"], output: ["text"] }, - variants: { - minimal: { thinkingLevel: "minimal" }, - low: { thinkingLevel: "low" }, - medium: { thinkingLevel: "medium" }, - high: { thinkingLevel: "high" }, - }, - }, - "antigravity-claude-sonnet-4-5": { - name: "Claude Sonnet 4.5 (Antigravity)", - limit: { context: 200000, output: 64000 }, - modalities: { input: ["text", "image", "pdf"], output: ["text"] }, - }, - "antigravity-claude-sonnet-4-5-thinking": { - name: "Claude Sonnet 4.5 Thinking (Antigravity)", - limit: { context: 200000, output: 64000 }, - modalities: { input: ["text", "image", "pdf"], output: ["text"] }, - variants: { - low: { thinkingConfig: { thinkingBudget: 8192 } }, - max: { thinkingConfig: { thinkingBudget: 32768 } }, - }, - }, - "antigravity-claude-opus-4-5-thinking": { - name: "Claude Opus 4.5 Thinking (Antigravity)", - limit: { context: 200000, output: 64000 }, - modalities: { input: ["text", "image", "pdf"], output: ["text"] }, - variants: { - low: { thinkingConfig: { thinkingBudget: 8192 } }, - max: { thinkingConfig: { thinkingBudget: 32768 } }, - }, - }, - }, - }, -} - - - -export function addProviderConfig(config: InstallConfig): ConfigMergeResult { - try { - ensureConfigDir() - } catch (err) { - return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") } - } - - const { format, path } = detectConfigFormat() - - try { - let existingConfig: OpenCodeConfig | null = null - if (format !== "none") { - const parseResult = parseConfigWithError(path) - if (parseResult.error && !parseResult.config) { - existingConfig = {} - } else { - existingConfig = parseResult.config - } - } - - const newConfig = { ...(existingConfig ?? {}) } - - const providers = (newConfig.provider ?? {}) as Record - - if (config.hasGemini) { - providers.google = ANTIGRAVITY_PROVIDER_CONFIG.google - } - - if (Object.keys(providers).length > 0) { - newConfig.provider = providers - } - - writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n") - return { success: true, configPath: path } - } catch (err) { - return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "add provider config") } - } -} - -function detectProvidersFromOmoConfig(): { hasOpenAI: boolean; hasOpencodeZen: boolean; hasZaiCodingPlan: boolean; hasKimiForCoding: boolean } { - const omoConfigPath = getOmoConfig() - if (!existsSync(omoConfigPath)) { - return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false } - } - - try { - const content = readFileSync(omoConfigPath, "utf-8") - const omoConfig = parseJsonc>(content) - if (!omoConfig || typeof omoConfig !== "object") { - return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false } - } - - const configStr = JSON.stringify(omoConfig) - const hasOpenAI = configStr.includes('"openai/') - const hasOpencodeZen = configStr.includes('"opencode/') - const hasZaiCodingPlan = configStr.includes('"zai-coding-plan/') - const hasKimiForCoding = configStr.includes('"kimi-for-coding/') - - return { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding } - } catch { - return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false } - } -} - -export function detectCurrentConfig(): DetectedConfig { - const result: DetectedConfig = { - isInstalled: false, - hasClaude: true, - isMax20: true, - hasOpenAI: true, - hasGemini: false, - hasCopilot: false, - hasOpencodeZen: true, - hasZaiCodingPlan: false, - hasKimiForCoding: false, - } - - const { format, path } = detectConfigFormat() - if (format === "none") { - return result - } - - const parseResult = parseConfigWithError(path) - if (!parseResult.config) { - return result - } - - const openCodeConfig = parseResult.config - const plugins = openCodeConfig.plugin ?? [] - result.isInstalled = plugins.some((p) => p.startsWith("oh-my-opencode")) - - if (!result.isInstalled) { - return result - } - - // Gemini auth plugin detection still works via plugin presence - result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth")) - - const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding } = detectProvidersFromOmoConfig() - result.hasOpenAI = hasOpenAI - result.hasOpencodeZen = hasOpencodeZen - result.hasZaiCodingPlan = hasZaiCodingPlan - result.hasKimiForCoding = hasKimiForCoding - - return result -} +export type { BunInstallResult } from "./config-manager/bun-install" +export { runBunInstall, runBunInstallWithDetails } from "./config-manager/bun-install" diff --git a/src/cli/config-manager/add-plugin-to-opencode-config.ts b/src/cli/config-manager/add-plugin-to-opencode-config.ts new file mode 100644 index 000000000..0262cc53f --- /dev/null +++ b/src/cli/config-manager/add-plugin-to-opencode-config.ts @@ -0,0 +1,82 @@ +import { readFileSync, writeFileSync } from "node:fs" +import type { ConfigMergeResult } from "../types" +import { getConfigDir } from "./config-context" +import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists" +import { formatErrorWithSuggestion } from "./format-error-with-suggestion" +import { detectConfigFormat } from "./opencode-config-format" +import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file" +import { getPluginNameWithVersion } from "./plugin-name-with-version" + +const PACKAGE_NAME = "oh-my-opencode" + +export async function addPluginToOpenCodeConfig(currentVersion: string): Promise { + try { + ensureConfigDirectoryExists() + } catch (err) { + return { + success: false, + configPath: getConfigDir(), + error: formatErrorWithSuggestion(err, "create config directory"), + } + } + + const { format, path } = detectConfigFormat() + const pluginEntry = await getPluginNameWithVersion(currentVersion) + + try { + if (format === "none") { + const config: OpenCodeConfig = { plugin: [pluginEntry] } + writeFileSync(path, JSON.stringify(config, null, 2) + "\n") + return { success: true, configPath: path } + } + + const parseResult = parseOpenCodeConfigFileWithError(path) + if (!parseResult.config) { + return { + success: false, + configPath: path, + error: parseResult.error ?? "Failed to parse config file", + } + } + + const config = parseResult.config + const plugins = config.plugin ?? [] + const existingIndex = plugins.findIndex((p) => p === PACKAGE_NAME || p.startsWith(`${PACKAGE_NAME}@`)) + + if (existingIndex !== -1) { + if (plugins[existingIndex] === pluginEntry) { + return { success: true, configPath: path } + } + plugins[existingIndex] = pluginEntry + } else { + plugins.push(pluginEntry) + } + + config.plugin = plugins + + if (format === "jsonc") { + const content = readFileSync(path, "utf-8") + const pluginArrayRegex = /"plugin"\s*:\s*\[([\s\S]*?)\]/ + const match = content.match(pluginArrayRegex) + + if (match) { + const formattedPlugins = plugins.map((p) => `"${p}"`).join(",\n ") + const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${formattedPlugins}\n ]`) + writeFileSync(path, newContent) + } else { + const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginEntry}"],`) + writeFileSync(path, newContent) + } + } else { + writeFileSync(path, JSON.stringify(config, null, 2) + "\n") + } + + return { success: true, configPath: path } + } catch (err) { + return { + success: false, + configPath: path, + error: formatErrorWithSuggestion(err, "update opencode config"), + } + } +} diff --git a/src/cli/config-manager/add-provider-config.ts b/src/cli/config-manager/add-provider-config.ts new file mode 100644 index 000000000..bc25c7a7a --- /dev/null +++ b/src/cli/config-manager/add-provider-config.ts @@ -0,0 +1,54 @@ +import { writeFileSync } from "node:fs" +import type { ConfigMergeResult, InstallConfig } from "../types" +import { getConfigDir } from "./config-context" +import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists" +import { formatErrorWithSuggestion } from "./format-error-with-suggestion" +import { detectConfigFormat } from "./opencode-config-format" +import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file" +import { ANTIGRAVITY_PROVIDER_CONFIG } from "./antigravity-provider-configuration" + +export function addProviderConfig(config: InstallConfig): ConfigMergeResult { + try { + ensureConfigDirectoryExists() + } catch (err) { + return { + success: false, + configPath: getConfigDir(), + error: formatErrorWithSuggestion(err, "create config directory"), + } + } + + const { format, path } = detectConfigFormat() + + try { + let existingConfig: OpenCodeConfig | null = null + if (format !== "none") { + const parseResult = parseOpenCodeConfigFileWithError(path) + if (parseResult.error && !parseResult.config) { + existingConfig = {} + } else { + existingConfig = parseResult.config + } + } + + const newConfig = { ...(existingConfig ?? {}) } + const providers = (newConfig.provider ?? {}) as Record + + if (config.hasGemini) { + providers.google = ANTIGRAVITY_PROVIDER_CONFIG.google + } + + if (Object.keys(providers).length > 0) { + newConfig.provider = providers + } + + writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n") + return { success: true, configPath: path } + } catch (err) { + return { + success: false, + configPath: path, + error: formatErrorWithSuggestion(err, "add provider config"), + } + } +} diff --git a/src/cli/config-manager/antigravity-provider-configuration.ts b/src/cli/config-manager/antigravity-provider-configuration.ts new file mode 100644 index 000000000..192113917 --- /dev/null +++ b/src/cli/config-manager/antigravity-provider-configuration.ts @@ -0,0 +1,64 @@ +/** + * Antigravity Provider Configuration + * + * IMPORTANT: Model names MUST use `antigravity-` prefix for stability. + * + * Since opencode-antigravity-auth v1.3.0, models use a variant system: + * - `antigravity-gemini-3-pro` with variants: low, high + * - `antigravity-gemini-3-flash` with variants: minimal, low, medium, high + * + * Legacy tier-suffixed names (e.g., `antigravity-gemini-3-pro-high`) still work + * but variants are the recommended approach. + * + * @see https://github.com/NoeFabris/opencode-antigravity-auth#models + */ +export const ANTIGRAVITY_PROVIDER_CONFIG = { + google: { + name: "Google", + models: { + "antigravity-gemini-3-pro": { + name: "Gemini 3 Pro (Antigravity)", + limit: { context: 1048576, output: 65535 }, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + low: { thinkingLevel: "low" }, + high: { thinkingLevel: "high" }, + }, + }, + "antigravity-gemini-3-flash": { + name: "Gemini 3 Flash (Antigravity)", + limit: { context: 1048576, output: 65536 }, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + minimal: { thinkingLevel: "minimal" }, + low: { thinkingLevel: "low" }, + medium: { thinkingLevel: "medium" }, + high: { thinkingLevel: "high" }, + }, + }, + "antigravity-claude-sonnet-4-5": { + name: "Claude Sonnet 4.5 (Antigravity)", + limit: { context: 200000, output: 64000 }, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + }, + "antigravity-claude-sonnet-4-5-thinking": { + name: "Claude Sonnet 4.5 Thinking (Antigravity)", + limit: { context: 200000, output: 64000 }, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + low: { thinkingConfig: { thinkingBudget: 8192 } }, + max: { thinkingConfig: { thinkingBudget: 32768 } }, + }, + }, + "antigravity-claude-opus-4-5-thinking": { + name: "Claude Opus 4.5 Thinking (Antigravity)", + limit: { context: 200000, output: 64000 }, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + variants: { + low: { thinkingConfig: { thinkingBudget: 8192 } }, + max: { thinkingConfig: { thinkingBudget: 32768 } }, + }, + }, + }, + }, +} diff --git a/src/cli/config-manager/auth-plugins.ts b/src/cli/config-manager/auth-plugins.ts new file mode 100644 index 000000000..77a38369d --- /dev/null +++ b/src/cli/config-manager/auth-plugins.ts @@ -0,0 +1,64 @@ +import { writeFileSync } from "node:fs" +import type { ConfigMergeResult, InstallConfig } from "../types" +import { getConfigDir } from "./config-context" +import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists" +import { formatErrorWithSuggestion } from "./format-error-with-suggestion" +import { detectConfigFormat } from "./opencode-config-format" +import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file" + +export async function fetchLatestVersion(packageName: string): Promise { + try { + const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`) + if (!res.ok) return null + const data = (await res.json()) as { version: string } + return data.version + } catch { + return null + } +} + +export async function addAuthPlugins(config: InstallConfig): Promise { + try { + ensureConfigDirectoryExists() + } catch (err) { + return { + success: false, + configPath: getConfigDir(), + error: formatErrorWithSuggestion(err, "create config directory"), + } + } + + const { format, path } = detectConfigFormat() + + try { + let existingConfig: OpenCodeConfig | null = null + if (format !== "none") { + const parseResult = parseOpenCodeConfigFileWithError(path) + if (parseResult.error && !parseResult.config) { + existingConfig = {} + } else { + existingConfig = parseResult.config + } + } + + const plugins: string[] = existingConfig?.plugin ?? [] + + if (config.hasGemini) { + const version = await fetchLatestVersion("opencode-antigravity-auth") + const pluginEntry = version ? `opencode-antigravity-auth@${version}` : "opencode-antigravity-auth" + if (!plugins.some((p) => p.startsWith("opencode-antigravity-auth"))) { + plugins.push(pluginEntry) + } + } + + const newConfig = { ...(existingConfig ?? {}), plugin: plugins } + writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n") + return { success: true, configPath: path } + } catch (err) { + return { + success: false, + configPath: path, + error: formatErrorWithSuggestion(err, "add auth plugins to config"), + } + } +} diff --git a/src/cli/config-manager/bun-install.ts b/src/cli/config-manager/bun-install.ts new file mode 100644 index 000000000..a9401f100 --- /dev/null +++ b/src/cli/config-manager/bun-install.ts @@ -0,0 +1,60 @@ +import { getConfigDir } from "./config-context" + +const BUN_INSTALL_TIMEOUT_SECONDS = 60 +const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000 + +export interface BunInstallResult { + success: boolean + timedOut?: boolean + error?: string +} + +export async function runBunInstall(): Promise { + const result = await runBunInstallWithDetails() + return result.success +} + +export async function runBunInstallWithDetails(): Promise { + try { + const proc = Bun.spawn(["bun", "install"], { + cwd: getConfigDir(), + stdout: "pipe", + stderr: "pipe", + }) + + const timeoutPromise = new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS) + ) + const exitPromise = proc.exited.then(() => "completed" as const) + const result = await Promise.race([exitPromise, timeoutPromise]) + + if (result === "timeout") { + try { + proc.kill() + } catch { + /* intentionally empty - process may have already exited */ + } + return { + success: false, + timedOut: true, + error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ~/.config/opencode && bun i`, + } + } + + if (proc.exitCode !== 0) { + const stderr = await new Response(proc.stderr).text() + return { + success: false, + error: stderr.trim() || `bun install failed with exit code ${proc.exitCode}`, + } + } + + return { success: true } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { + success: false, + error: `bun install failed: ${message}. Is bun installed? Try: curl -fsSL https://bun.sh/install | bash`, + } + } +} diff --git a/src/cli/config-manager/config-context.ts b/src/cli/config-manager/config-context.ts new file mode 100644 index 000000000..78eb88d77 --- /dev/null +++ b/src/cli/config-manager/config-context.ts @@ -0,0 +1,46 @@ +import { getOpenCodeConfigPaths } from "../../shared" +import type { + OpenCodeBinaryType, + OpenCodeConfigPaths, +} from "../../shared/opencode-config-dir-types" + +export interface ConfigContext { + binary: OpenCodeBinaryType + version: string | null + paths: OpenCodeConfigPaths +} + +let configContext: ConfigContext | null = null + +export function initConfigContext(binary: OpenCodeBinaryType, version: string | null): void { + const paths = getOpenCodeConfigPaths({ binary, version }) + configContext = { binary, version, paths } +} + +export function getConfigContext(): ConfigContext { + if (!configContext) { + const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null }) + configContext = { binary: "opencode", version: null, paths } + } + return configContext +} + +export function resetConfigContext(): void { + configContext = null +} + +export function getConfigDir(): string { + return getConfigContext().paths.configDir +} + +export function getConfigJson(): string { + return getConfigContext().paths.configJson +} + +export function getConfigJsonc(): string { + return getConfigContext().paths.configJsonc +} + +export function getOmoConfigPath(): string { + return getConfigContext().paths.omoConfig +} diff --git a/src/cli/config-manager/deep-merge-record.ts b/src/cli/config-manager/deep-merge-record.ts new file mode 100644 index 000000000..54c0daa57 --- /dev/null +++ b/src/cli/config-manager/deep-merge-record.ts @@ -0,0 +1,29 @@ +export function deepMergeRecord>( + target: TTarget, + source: Partial +): TTarget { + const result: TTarget = { ...target } + + for (const key of Object.keys(source) as Array) { + const sourceValue = source[key] + const targetValue = result[key] + + if ( + sourceValue !== null && + typeof sourceValue === "object" && + !Array.isArray(sourceValue) && + targetValue !== null && + typeof targetValue === "object" && + !Array.isArray(targetValue) + ) { + result[key] = deepMergeRecord( + targetValue as Record, + sourceValue as Record + ) as TTarget[keyof TTarget] + } else if (sourceValue !== undefined) { + result[key] = sourceValue as TTarget[keyof TTarget] + } + } + + return result +} diff --git a/src/cli/config-manager/detect-current-config.ts b/src/cli/config-manager/detect-current-config.ts new file mode 100644 index 000000000..fd7855336 --- /dev/null +++ b/src/cli/config-manager/detect-current-config.ts @@ -0,0 +1,78 @@ +import { existsSync, readFileSync } from "node:fs" +import { parseJsonc } from "../../shared" +import type { DetectedConfig } from "../types" +import { getOmoConfigPath } from "./config-context" +import { detectConfigFormat } from "./opencode-config-format" +import { parseOpenCodeConfigFileWithError } from "./parse-opencode-config-file" + +function detectProvidersFromOmoConfig(): { + hasOpenAI: boolean + hasOpencodeZen: boolean + hasZaiCodingPlan: boolean + hasKimiForCoding: boolean +} { + const omoConfigPath = getOmoConfigPath() + if (!existsSync(omoConfigPath)) { + return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false } + } + + try { + const content = readFileSync(omoConfigPath, "utf-8") + const omoConfig = parseJsonc>(content) + if (!omoConfig || typeof omoConfig !== "object") { + return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false } + } + + const configStr = JSON.stringify(omoConfig) + const hasOpenAI = configStr.includes('"openai/') + const hasOpencodeZen = configStr.includes('"opencode/') + const hasZaiCodingPlan = configStr.includes('"zai-coding-plan/') + const hasKimiForCoding = configStr.includes('"kimi-for-coding/') + + return { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding } + } catch { + return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false } + } +} + +export function detectCurrentConfig(): DetectedConfig { + const result: DetectedConfig = { + isInstalled: false, + hasClaude: true, + isMax20: true, + hasOpenAI: true, + hasGemini: false, + hasCopilot: false, + hasOpencodeZen: true, + hasZaiCodingPlan: false, + hasKimiForCoding: false, + } + + const { format, path } = detectConfigFormat() + if (format === "none") { + return result + } + + const parseResult = parseOpenCodeConfigFileWithError(path) + if (!parseResult.config) { + return result + } + + const openCodeConfig = parseResult.config + const plugins = openCodeConfig.plugin ?? [] + result.isInstalled = plugins.some((p) => p.startsWith("oh-my-opencode")) + + if (!result.isInstalled) { + return result + } + + result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth")) + + const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding } = detectProvidersFromOmoConfig() + result.hasOpenAI = hasOpenAI + result.hasOpencodeZen = hasOpencodeZen + result.hasZaiCodingPlan = hasZaiCodingPlan + result.hasKimiForCoding = hasKimiForCoding + + return result +} diff --git a/src/cli/config-manager/ensure-config-directory-exists.ts b/src/cli/config-manager/ensure-config-directory-exists.ts new file mode 100644 index 000000000..bd2c8b1ab --- /dev/null +++ b/src/cli/config-manager/ensure-config-directory-exists.ts @@ -0,0 +1,9 @@ +import { existsSync, mkdirSync } from "node:fs" +import { getConfigDir } from "./config-context" + +export function ensureConfigDirectoryExists(): void { + const configDir = getConfigDir() + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }) + } +} diff --git a/src/cli/config-manager/format-error-with-suggestion.ts b/src/cli/config-manager/format-error-with-suggestion.ts new file mode 100644 index 000000000..ca5533e5a --- /dev/null +++ b/src/cli/config-manager/format-error-with-suggestion.ts @@ -0,0 +1,39 @@ +interface NodeError extends Error { + code?: string +} + +function isPermissionError(err: unknown): boolean { + const nodeErr = err as NodeError + return nodeErr?.code === "EACCES" || nodeErr?.code === "EPERM" +} + +function isFileNotFoundError(err: unknown): boolean { + const nodeErr = err as NodeError + return nodeErr?.code === "ENOENT" +} + +export function formatErrorWithSuggestion(err: unknown, context: string): string { + if (isPermissionError(err)) { + return `Permission denied: Cannot ${context}. Try running with elevated permissions or check file ownership.` + } + + if (isFileNotFoundError(err)) { + return `File not found while trying to ${context}. The file may have been deleted or moved.` + } + + if (err instanceof SyntaxError) { + return `JSON syntax error while trying to ${context}: ${err.message}. Check for missing commas, brackets, or invalid characters.` + } + + const message = err instanceof Error ? err.message : String(err) + + if (message.includes("ENOSPC")) { + return `Disk full: Cannot ${context}. Free up disk space and try again.` + } + + if (message.includes("EROFS")) { + return `Read-only filesystem: Cannot ${context}. Check if the filesystem is mounted read-only.` + } + + return `Failed to ${context}: ${message}` +} diff --git a/src/cli/config-manager/generate-omo-config.ts b/src/cli/config-manager/generate-omo-config.ts new file mode 100644 index 000000000..c7060dad2 --- /dev/null +++ b/src/cli/config-manager/generate-omo-config.ts @@ -0,0 +1,6 @@ +import type { InstallConfig } from "../types" +import { generateModelConfig } from "../model-fallback" + +export function generateOmoConfig(installConfig: InstallConfig): Record { + return generateModelConfig(installConfig) +} diff --git a/src/cli/config-manager/npm-dist-tags.ts b/src/cli/config-manager/npm-dist-tags.ts new file mode 100644 index 000000000..f653fc2fc --- /dev/null +++ b/src/cli/config-manager/npm-dist-tags.ts @@ -0,0 +1,21 @@ +export interface NpmDistTags { + latest?: string + beta?: string + next?: string + [tag: string]: string | undefined +} + +const NPM_FETCH_TIMEOUT_MS = 5000 + +export async function fetchNpmDistTags(packageName: string): Promise { + try { + const res = await fetch(`https://registry.npmjs.org/-/package/${packageName}/dist-tags`, { + signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS), + }) + if (!res.ok) return null + const data = (await res.json()) as NpmDistTags + return data + } catch { + return null + } +} diff --git a/src/cli/config-manager/opencode-binary.ts b/src/cli/config-manager/opencode-binary.ts new file mode 100644 index 000000000..6d889faee --- /dev/null +++ b/src/cli/config-manager/opencode-binary.ts @@ -0,0 +1,40 @@ +import type { OpenCodeBinaryType } from "../../shared/opencode-config-dir-types" +import { initConfigContext } from "./config-context" + +const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const + +interface OpenCodeBinaryResult { + binary: OpenCodeBinaryType + version: string +} + +async function findOpenCodeBinaryWithVersion(): Promise { + for (const binary of OPENCODE_BINARIES) { + try { + const proc = Bun.spawn([binary, "--version"], { + stdout: "pipe", + stderr: "pipe", + }) + const output = await new Response(proc.stdout).text() + await proc.exited + if (proc.exitCode === 0) { + const version = output.trim() + initConfigContext(binary, version) + return { binary, version } + } + } catch { + continue + } + } + return null +} + +export async function isOpenCodeInstalled(): Promise { + const result = await findOpenCodeBinaryWithVersion() + return result !== null +} + +export async function getOpenCodeVersion(): Promise { + const result = await findOpenCodeBinaryWithVersion() + return result?.version ?? null +} diff --git a/src/cli/config-manager/opencode-config-format.ts b/src/cli/config-manager/opencode-config-format.ts new file mode 100644 index 000000000..135cb511c --- /dev/null +++ b/src/cli/config-manager/opencode-config-format.ts @@ -0,0 +1,17 @@ +import { existsSync } from "node:fs" +import { getConfigJson, getConfigJsonc } from "./config-context" + +export type ConfigFormat = "json" | "jsonc" | "none" + +export function detectConfigFormat(): { format: ConfigFormat; path: string } { + const configJsonc = getConfigJsonc() + const configJson = getConfigJson() + + if (existsSync(configJsonc)) { + return { format: "jsonc", path: configJsonc } + } + if (existsSync(configJson)) { + return { format: "json", path: configJson } + } + return { format: "none", path: configJson } +} diff --git a/src/cli/config-manager/parse-opencode-config-file.ts b/src/cli/config-manager/parse-opencode-config-file.ts new file mode 100644 index 000000000..3e399d847 --- /dev/null +++ b/src/cli/config-manager/parse-opencode-config-file.ts @@ -0,0 +1,48 @@ +import { readFileSync, statSync } from "node:fs" +import { parseJsonc } from "../../shared" +import { formatErrorWithSuggestion } from "./format-error-with-suggestion" + +interface ParseConfigResult { + config: OpenCodeConfig | null + error?: string +} + +export interface OpenCodeConfig { + plugin?: string[] + [key: string]: unknown +} + +function isEmptyOrWhitespace(content: string): boolean { + return content.trim().length === 0 +} + +export function parseOpenCodeConfigFileWithError(path: string): ParseConfigResult { + try { + const stat = statSync(path) + if (stat.size === 0) { + return { config: null, error: `Config file is empty: ${path}. Delete it or add valid JSON content.` } + } + + const content = readFileSync(path, "utf-8") + if (isEmptyOrWhitespace(content)) { + return { config: null, error: `Config file contains only whitespace: ${path}. Delete it or add valid JSON content.` } + } + + const config = parseJsonc(content) + + if (config === null || config === undefined) { + return { config: null, error: `Config file parsed to null/undefined: ${path}. Ensure it contains valid JSON.` } + } + + if (typeof config !== "object" || Array.isArray(config)) { + return { + config: null, + error: `Config file must contain a JSON object, not ${Array.isArray(config) ? "an array" : typeof config}: ${path}`, + } + } + + return { config } + } catch (err) { + return { config: null, error: formatErrorWithSuggestion(err, `parse config file ${path}`) } + } +} diff --git a/src/cli/config-manager/plugin-name-with-version.ts b/src/cli/config-manager/plugin-name-with-version.ts new file mode 100644 index 000000000..a80ada643 --- /dev/null +++ b/src/cli/config-manager/plugin-name-with-version.ts @@ -0,0 +1,19 @@ +import { fetchNpmDistTags } from "./npm-dist-tags" + +const PACKAGE_NAME = "oh-my-opencode" +const PRIORITIZED_TAGS = ["latest", "beta", "next"] as const + +export async function getPluginNameWithVersion(currentVersion: string): Promise { + const distTags = await fetchNpmDistTags(PACKAGE_NAME) + + if (distTags) { + const allTags = new Set([...PRIORITIZED_TAGS, ...Object.keys(distTags)]) + for (const tag of allTags) { + if (distTags[tag] === currentVersion) { + return `${PACKAGE_NAME}@${tag}` + } + } + } + + return `${PACKAGE_NAME}@${currentVersion}` +} diff --git a/src/cli/config-manager/write-omo-config.ts b/src/cli/config-manager/write-omo-config.ts new file mode 100644 index 000000000..09fcce15b --- /dev/null +++ b/src/cli/config-manager/write-omo-config.ts @@ -0,0 +1,67 @@ +import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs" +import { parseJsonc } from "../../shared" +import type { ConfigMergeResult, InstallConfig } from "../types" +import { getConfigDir, getOmoConfigPath } from "./config-context" +import { deepMergeRecord } from "./deep-merge-record" +import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists" +import { formatErrorWithSuggestion } from "./format-error-with-suggestion" +import { generateOmoConfig } from "./generate-omo-config" + +function isEmptyOrWhitespace(content: string): boolean { + return content.trim().length === 0 +} + +export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult { + try { + ensureConfigDirectoryExists() + } catch (err) { + return { + success: false, + configPath: getConfigDir(), + error: formatErrorWithSuggestion(err, "create config directory"), + } + } + + const omoConfigPath = getOmoConfigPath() + + try { + const newConfig = generateOmoConfig(installConfig) + + if (existsSync(omoConfigPath)) { + try { + const stat = statSync(omoConfigPath) + const content = readFileSync(omoConfigPath, "utf-8") + + if (stat.size === 0 || isEmptyOrWhitespace(content)) { + writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") + return { success: true, configPath: omoConfigPath } + } + + const existing = parseJsonc>(content) + if (!existing || typeof existing !== "object" || Array.isArray(existing)) { + writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") + return { success: true, configPath: omoConfigPath } + } + + const merged = deepMergeRecord(existing, newConfig) + writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n") + } catch (parseErr) { + if (parseErr instanceof SyntaxError) { + writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") + return { success: true, configPath: omoConfigPath } + } + throw parseErr + } + } else { + writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") + } + + return { success: true, configPath: omoConfigPath } + } catch (err) { + return { + success: false, + configPath: omoConfigPath, + error: formatErrorWithSuggestion(err, "write oh-my-opencode config"), + } + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 90b413be6..46d39a309 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,190 +1,4 @@ #!/usr/bin/env bun -import { Command } from "commander" -import { install } from "./install" -import { run } from "./run" -import { getLocalVersion } from "./get-local-version" -import { doctor } from "./doctor" -import { createMcpOAuthCommand } from "./mcp-oauth" -import type { InstallArgs } from "./types" -import type { RunOptions } from "./run" -import type { GetLocalVersionOptions } from "./get-local-version/types" -import type { DoctorOptions } from "./doctor" -import packageJson from "../../package.json" with { type: "json" } +import { runCli } from "./cli-program" -const VERSION = packageJson.version - -const program = new Command() - -program - .name("oh-my-opencode") - .description("The ultimate OpenCode plugin - multi-model orchestration, LSP tools, and more") - .version(VERSION, "-v, --version", "Show version number") - .enablePositionalOptions() - -program - .command("install") - .description("Install and configure oh-my-opencode with interactive setup") - .option("--no-tui", "Run in non-interactive mode (requires all options)") - .option("--claude ", "Claude subscription: no, yes, max20") - .option("--openai ", "OpenAI/ChatGPT subscription: no, yes (default: no)") - .option("--gemini ", "Gemini integration: no, yes") - .option("--copilot ", "GitHub Copilot subscription: no, yes") - .option("--opencode-zen ", "OpenCode Zen access: no, yes (default: no)") - .option("--zai-coding-plan ", "Z.ai Coding Plan subscription: no, yes (default: no)") - .option("--kimi-for-coding ", "Kimi For Coding subscription: no, yes (default: no)") - .option("--skip-auth", "Skip authentication setup hints") - .addHelpText("after", ` -Examples: - $ bunx oh-my-opencode install - $ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no - $ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes - -Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi): - Claude Native anthropic/ models (Opus, Sonnet, Haiku) - OpenAI Native openai/ models (GPT-5.2 for Oracle) - Gemini Native google/ models (Gemini 3 Pro, Flash) - Copilot github-copilot/ models (fallback) - OpenCode Zen opencode/ models (opencode/claude-opus-4-6, etc.) - Z.ai zai-coding-plan/glm-4.7 (Librarian priority) - Kimi kimi-for-coding/k2p5 (Sisyphus/Prometheus fallback) -`) - .action(async (options) => { - const args: InstallArgs = { - tui: options.tui !== false, - claude: options.claude, - openai: options.openai, - gemini: options.gemini, - copilot: options.copilot, - opencodeZen: options.opencodeZen, - zaiCodingPlan: options.zaiCodingPlan, - kimiForCoding: options.kimiForCoding, - skipAuth: options.skipAuth ?? false, - } - const exitCode = await install(args) - process.exit(exitCode) - }) - -program - .command("run ") - .allowUnknownOption() - .passThroughOptions() - .description("Run opencode with todo/background task completion enforcement") - .option("-a, --agent ", "Agent to use (default: from CLI/env/config, fallback: Sisyphus)") - .option("-d, --directory ", "Working directory") - .option("-t, --timeout ", "Timeout in milliseconds (default: 30 minutes)", parseInt) - .option("-p, --port ", "Server port (attaches if port already in use)", parseInt) - .option("--attach ", "Attach to existing opencode server URL") - .option("--on-complete ", "Shell command to run after completion") - .option("--json", "Output structured JSON result to stdout") - .option("--session-id ", "Resume existing session instead of creating new one") - .addHelpText("after", ` -Examples: - $ bunx oh-my-opencode run "Fix the bug in index.ts" - $ bunx oh-my-opencode run --agent Sisyphus "Implement feature X" - $ bunx oh-my-opencode run --timeout 3600000 "Large refactoring task" - $ bunx oh-my-opencode run --port 4321 "Fix the bug" - $ bunx oh-my-opencode run --attach http://127.0.0.1:4321 "Fix the bug" - $ bunx oh-my-opencode run --json "Fix the bug" | jq .sessionId - $ bunx oh-my-opencode run --on-complete "notify-send Done" "Fix the bug" - $ bunx oh-my-opencode run --session-id ses_abc123 "Continue the work" - -Agent resolution order: - 1) --agent flag - 2) OPENCODE_DEFAULT_AGENT - 3) oh-my-opencode.json "default_run_agent" - 4) Sisyphus (fallback) - -Available core agents: - Sisyphus, Hephaestus, Prometheus, Atlas - -Unlike 'opencode run', this command waits until: - - All todos are completed or cancelled - - All child sessions (background tasks) are idle -`) - .action(async (message: string, options) => { - if (options.port && options.attach) { - console.error("Error: --port and --attach are mutually exclusive") - process.exit(1) - } - const runOptions: RunOptions = { - message, - agent: options.agent, - directory: options.directory, - timeout: options.timeout, - port: options.port, - attach: options.attach, - onComplete: options.onComplete, - json: options.json ?? false, - sessionId: options.sessionId, - } - const exitCode = await run(runOptions) - process.exit(exitCode) - }) - -program - .command("get-local-version") - .description("Show current installed version and check for updates") - .option("-d, --directory ", "Working directory to check config from") - .option("--json", "Output in JSON format for scripting") - .addHelpText("after", ` -Examples: - $ bunx oh-my-opencode get-local-version - $ bunx oh-my-opencode get-local-version --json - $ bunx oh-my-opencode get-local-version --directory /path/to/project - -This command shows: - - Current installed version - - Latest available version on npm - - Whether you're up to date - - Special modes (local dev, pinned version) -`) - .action(async (options) => { - const versionOptions: GetLocalVersionOptions = { - directory: options.directory, - json: options.json ?? false, - } - const exitCode = await getLocalVersion(versionOptions) - process.exit(exitCode) - }) - -program - .command("doctor") - .description("Check oh-my-opencode installation health and diagnose issues") - .option("--verbose", "Show detailed diagnostic information") - .option("--json", "Output results in JSON format") - .option("--category ", "Run only specific category") - .addHelpText("after", ` -Examples: - $ bunx oh-my-opencode doctor - $ bunx oh-my-opencode doctor --verbose - $ bunx oh-my-opencode doctor --json - $ bunx oh-my-opencode doctor --category authentication - -Categories: - installation Check OpenCode and plugin installation - configuration Validate configuration files - authentication Check auth provider status - dependencies Check external dependencies - tools Check LSP and MCP servers - updates Check for version updates -`) - .action(async (options) => { - const doctorOptions: DoctorOptions = { - verbose: options.verbose ?? false, - json: options.json ?? false, - category: options.category, - } - const exitCode = await doctor(doctorOptions) - process.exit(exitCode) - }) - -program - .command("version") - .description("Show version information") - .action(() => { - console.log(`oh-my-opencode v${VERSION}`) - }) - -program.addCommand(createMcpOAuthCommand()) - -program.parse() +runCli() diff --git a/src/config/schema.ts b/src/config/schema.ts index 34ec376be..e4c55c6ff 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,464 +1,23 @@ -import { z } from "zod" -import { AnyMcpNameSchema, McpNameSchema } from "../mcp/types" - -const PermissionValue = z.enum(["ask", "allow", "deny"]) - -const BashPermission = z.union([ - PermissionValue, - z.record(z.string(), PermissionValue), -]) - -const AgentPermissionSchema = z.object({ - edit: PermissionValue.optional(), - bash: BashPermission.optional(), - webfetch: PermissionValue.optional(), - task: PermissionValue.optional(), - doom_loop: PermissionValue.optional(), - external_directory: PermissionValue.optional(), -}) - -export const BuiltinAgentNameSchema = z.enum([ - "sisyphus", - "hephaestus", - "prometheus", - "oracle", - "librarian", - "explore", - "multimodal-looker", - "metis", - "momus", - "atlas", -]) - -export const BuiltinSkillNameSchema = z.enum([ - "playwright", - "agent-browser", - "dev-browser", - "frontend-ui-ux", - "git-master", -]) - -export const OverridableAgentNameSchema = z.enum([ - "build", - "plan", - "sisyphus", - "hephaestus", - "sisyphus-junior", - "OpenCode-Builder", - "prometheus", - "metis", - "momus", - "oracle", - "librarian", - "explore", - "multimodal-looker", - "atlas", -]) - -export const AgentNameSchema = BuiltinAgentNameSchema - -export const HookNameSchema = z.enum([ - "todo-continuation-enforcer", - "context-window-monitor", - "session-recovery", - "session-notification", - "comment-checker", - "grep-output-truncator", - "tool-output-truncator", - "question-label-truncator", - "directory-agents-injector", - "directory-readme-injector", - "empty-task-response-detector", - "think-mode", - "subagent-question-blocker", - "anthropic-context-window-limit-recovery", - "preemptive-compaction", - "rules-injector", - "background-notification", - "auto-update-checker", - "startup-toast", - "keyword-detector", - "agent-usage-reminder", - "non-interactive-env", - "interactive-bash-session", - - "thinking-block-validator", - "ralph-loop", - "category-skill-reminder", - - "compaction-context-injector", - "compaction-todo-preserver", - "claude-code-hooks", - "auto-slash-command", - "edit-error-recovery", - "delegate-task-retry", - "prometheus-md-only", - "sisyphus-junior-notepad", - "start-work", - "atlas", - "unstable-agent-babysitter", - "task-reminder", - "task-resume-info", - "stop-continuation-guard", - "tasks-todowrite-disabler", - "write-existing-file-guard", - "anthropic-effort", -]) - -export const BuiltinCommandNameSchema = z.enum([ - "init-deep", - "ralph-loop", - "ulw-loop", - "cancel-ralph", - "refactor", - "start-work", - "stop-continuation", -]) - -export const AgentOverrideConfigSchema = z.object({ - /** @deprecated Use `category` instead. Model is inherited from category defaults. */ - model: z.string().optional(), - variant: z.string().optional(), - /** Category name to inherit model and other settings from CategoryConfig */ - category: z.string().optional(), - /** Skill names to inject into agent prompt */ - skills: z.array(z.string()).optional(), - temperature: z.number().min(0).max(2).optional(), - top_p: z.number().min(0).max(1).optional(), - prompt: z.string().optional(), - prompt_append: z.string().optional(), - tools: z.record(z.string(), z.boolean()).optional(), - disable: z.boolean().optional(), - description: z.string().optional(), - mode: z.enum(["subagent", "primary", "all"]).optional(), - color: z - .string() - .regex(/^#[0-9A-Fa-f]{6}$/) - .optional(), - permission: AgentPermissionSchema.optional(), - /** Maximum tokens for response. Passed directly to OpenCode SDK. */ - maxTokens: z.number().optional(), - /** Extended thinking configuration (Anthropic). Overrides category and default settings. */ - thinking: z.object({ - type: z.enum(["enabled", "disabled"]), - budgetTokens: z.number().optional(), - }).optional(), - /** Reasoning effort level (OpenAI). Overrides category and default settings. */ - reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(), - /** Text verbosity level. */ - textVerbosity: z.enum(["low", "medium", "high"]).optional(), - /** Provider-specific options. Passed directly to OpenCode SDK. */ - providerOptions: z.record(z.string(), z.unknown()).optional(), -}) - -export const AgentOverridesSchema = z.object({ - build: AgentOverrideConfigSchema.optional(), - plan: AgentOverrideConfigSchema.optional(), - sisyphus: AgentOverrideConfigSchema.optional(), - hephaestus: AgentOverrideConfigSchema.optional(), - "sisyphus-junior": AgentOverrideConfigSchema.optional(), - "OpenCode-Builder": AgentOverrideConfigSchema.optional(), - prometheus: AgentOverrideConfigSchema.optional(), - metis: AgentOverrideConfigSchema.optional(), - momus: AgentOverrideConfigSchema.optional(), - oracle: AgentOverrideConfigSchema.optional(), - librarian: AgentOverrideConfigSchema.optional(), - explore: AgentOverrideConfigSchema.optional(), - "multimodal-looker": AgentOverrideConfigSchema.optional(), - atlas: AgentOverrideConfigSchema.optional(), -}) - -export const ClaudeCodeConfigSchema = z.object({ - mcp: z.boolean().optional(), - commands: z.boolean().optional(), - skills: z.boolean().optional(), - agents: z.boolean().optional(), - hooks: z.boolean().optional(), - plugins: z.boolean().optional(), - plugins_override: z.record(z.string(), z.boolean()).optional(), -}) - -export const SisyphusAgentConfigSchema = z.object({ - disabled: z.boolean().optional(), - default_builder_enabled: z.boolean().optional(), - planner_enabled: z.boolean().optional(), - replace_plan: z.boolean().optional(), -}) - -export const CategoryConfigSchema = z.object({ - /** Human-readable description of the category's purpose. Shown in task prompt. */ - description: z.string().optional(), - model: z.string().optional(), - variant: z.string().optional(), - temperature: z.number().min(0).max(2).optional(), - top_p: z.number().min(0).max(1).optional(), - maxTokens: z.number().optional(), - thinking: z.object({ - type: z.enum(["enabled", "disabled"]), - budgetTokens: z.number().optional(), - }).optional(), - reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(), - textVerbosity: z.enum(["low", "medium", "high"]).optional(), - tools: z.record(z.string(), z.boolean()).optional(), - prompt_append: z.string().optional(), - /** Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini/minimax models. */ - is_unstable_agent: z.boolean().optional(), -}) - -export const BuiltinCategoryNameSchema = z.enum([ - "visual-engineering", - "ultrabrain", - "deep", - "artistry", - "quick", - "unspecified-low", - "unspecified-high", - "writing", -]) - -export const CategoriesConfigSchema = z.record(z.string(), CategoryConfigSchema) - -export const CommentCheckerConfigSchema = z.object({ - /** Custom prompt to replace the default warning message. Use {{comments}} placeholder for detected comments XML. */ - custom_prompt: z.string().optional(), -}) - -export const DynamicContextPruningConfigSchema = z.object({ - /** Enable dynamic context pruning (default: false) */ - enabled: z.boolean().default(false), - /** Notification level: off, minimal, or detailed (default: detailed) */ - notification: z.enum(["off", "minimal", "detailed"]).default("detailed"), - /** Turn protection - prevent pruning recent tool outputs */ - turn_protection: z.object({ - enabled: z.boolean().default(true), - turns: z.number().min(1).max(10).default(3), - }).optional(), - /** Tools that should never be pruned */ - protected_tools: z.array(z.string()).default([ - "task", "todowrite", "todoread", - "lsp_rename", - "session_read", "session_write", "session_search", - ]), - /** Pruning strategies configuration */ - strategies: z.object({ - /** Remove duplicate tool calls (same tool + same args) */ - deduplication: z.object({ - enabled: z.boolean().default(true), - }).optional(), - /** Prune write inputs when file subsequently read */ - supersede_writes: z.object({ - enabled: z.boolean().default(true), - /** Aggressive mode: prune any write if ANY subsequent read */ - aggressive: z.boolean().default(false), - }).optional(), - /** Prune errored tool inputs after N turns */ - purge_errors: z.object({ - enabled: z.boolean().default(true), - turns: z.number().min(1).max(20).default(5), - }).optional(), - }).optional(), -}) - -export const ExperimentalConfigSchema = z.object({ - aggressive_truncation: z.boolean().optional(), - auto_resume: z.boolean().optional(), - preemptive_compaction: z.boolean().optional(), - /** Truncate all tool outputs, not just whitelisted tools (default: false). Tool output truncator is enabled by default - disable via disabled_hooks. */ - truncate_all_tool_outputs: z.boolean().optional(), - /** Dynamic context pruning configuration */ - dynamic_context_pruning: DynamicContextPruningConfigSchema.optional(), - /** Enable experimental task system for Todowrite disabler hook */ - task_system: z.boolean().optional(), - /** Timeout in ms for loadAllPluginComponents during config handler init (default: 10000, min: 1000) */ - plugin_load_timeout_ms: z.number().min(1000).optional(), - /** Wrap hook creation in try/catch to prevent one failing hook from crashing the plugin (default: true at call site) */ - safe_hook_creation: z.boolean().optional(), -}) - -export const SkillSourceSchema = z.union([ - z.string(), - z.object({ - path: z.string(), - recursive: z.boolean().optional(), - glob: z.string().optional(), - }), -]) - -export const SkillDefinitionSchema = z.object({ - description: z.string().optional(), - template: z.string().optional(), - from: z.string().optional(), - model: z.string().optional(), - agent: z.string().optional(), - subtask: z.boolean().optional(), - "argument-hint": z.string().optional(), - license: z.string().optional(), - compatibility: z.string().optional(), - metadata: z.record(z.string(), z.unknown()).optional(), - "allowed-tools": z.array(z.string()).optional(), - disable: z.boolean().optional(), -}) - -export const SkillEntrySchema = z.union([ - z.boolean(), - SkillDefinitionSchema, -]) - -export const SkillsConfigSchema = z.union([ - z.array(z.string()), - z.record(z.string(), SkillEntrySchema).and(z.object({ - sources: z.array(SkillSourceSchema).optional(), - enable: z.array(z.string()).optional(), - disable: z.array(z.string()).optional(), - }).partial()), -]) - -export const RalphLoopConfigSchema = z.object({ - /** Enable ralph loop functionality (default: false - opt-in feature) */ - enabled: z.boolean().default(false), - /** Default max iterations if not specified in command (default: 100) */ - default_max_iterations: z.number().min(1).max(1000).default(100), - /** Custom state file directory relative to project root (default: .opencode/) */ - state_dir: z.string().optional(), -}) - -export const BackgroundTaskConfigSchema = z.object({ - defaultConcurrency: z.number().min(1).optional(), - providerConcurrency: z.record(z.string(), z.number().min(0)).optional(), - modelConcurrency: z.record(z.string(), z.number().min(0)).optional(), - /** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */ - staleTimeoutMs: z.number().min(60000).optional(), -}) - -export const NotificationConfigSchema = z.object({ - /** Force enable session-notification even if external notification plugins are detected (default: false) */ - force_enable: z.boolean().optional(), -}) - -export const BabysittingConfigSchema = z.object({ - timeout_ms: z.number().default(120000), -}) - -export const GitMasterConfigSchema = z.object({ - /** Add "Ultraworked with Sisyphus" footer to commit messages (default: true). Can be boolean or custom string. */ - commit_footer: z.union([z.boolean(), z.string()]).default(true), - /** Add "Co-authored-by: Sisyphus" trailer to commit messages (default: true) */ - include_co_authored_by: z.boolean().default(true), -}) - -export const BrowserAutomationProviderSchema = z.enum(["playwright", "agent-browser", "dev-browser"]) - -export const BrowserAutomationConfigSchema = z.object({ - /** - * Browser automation provider to use for the "playwright" skill. - * - "playwright": Uses Playwright MCP server (@playwright/mcp) - default - * - "agent-browser": Uses Vercel's agent-browser CLI (requires: bun add -g agent-browser) - * - "dev-browser": Uses dev-browser skill with persistent browser state - */ - provider: BrowserAutomationProviderSchema.default("playwright"), -}) - -export const WebsearchProviderSchema = z.enum(["exa", "tavily"]) - -export const WebsearchConfigSchema = z.object({ - /** - * Websearch provider to use. - * - "exa": Uses Exa websearch (default, works without API key) - * - "tavily": Uses Tavily websearch (requires TAVILY_API_KEY) - */ - provider: WebsearchProviderSchema.optional(), -}) - -export const TmuxLayoutSchema = z.enum([ - 'main-horizontal', // main pane top, agent panes bottom stack - 'main-vertical', // main pane left, agent panes right stack (default) - 'tiled', // all panes same size grid - 'even-horizontal', // all panes horizontal row - 'even-vertical', // all panes vertical stack -]) - -export const TmuxConfigSchema = z.object({ - enabled: z.boolean().default(false), - layout: TmuxLayoutSchema.default('main-vertical'), - main_pane_size: z.number().min(20).max(80).default(60), - main_pane_min_width: z.number().min(40).default(120), - agent_pane_min_width: z.number().min(20).default(40), -}) - -export const SisyphusTasksConfigSchema = z.object({ - /** Absolute or relative storage path override. When set, bypasses global config dir. */ - storage_path: z.string().optional(), - /** Force task list ID (alternative to env ULTRAWORK_TASK_LIST_ID) */ - task_list_id: z.string().optional(), - /** Enable Claude Code path compatibility mode */ - claude_code_compat: z.boolean().default(false), -}) - -export const SisyphusConfigSchema = z.object({ - tasks: SisyphusTasksConfigSchema.optional(), -}) -export const OhMyOpenCodeConfigSchema = z.object({ - $schema: z.string().optional(), - /** Enable new task system (default: false) */ - new_task_system_enabled: z.boolean().optional(), - /** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */ - default_run_agent: z.string().optional(), - disabled_mcps: z.array(AnyMcpNameSchema).optional(), - disabled_agents: z.array(BuiltinAgentNameSchema).optional(), - disabled_skills: z.array(BuiltinSkillNameSchema).optional(), - disabled_hooks: z.array(HookNameSchema).optional(), - disabled_commands: z.array(BuiltinCommandNameSchema).optional(), - /** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */ - disabled_tools: z.array(z.string()).optional(), - agents: AgentOverridesSchema.optional(), - categories: CategoriesConfigSchema.optional(), - claude_code: ClaudeCodeConfigSchema.optional(), - sisyphus_agent: SisyphusAgentConfigSchema.optional(), - comment_checker: CommentCheckerConfigSchema.optional(), - experimental: ExperimentalConfigSchema.optional(), - auto_update: z.boolean().optional(), - skills: SkillsConfigSchema.optional(), - ralph_loop: RalphLoopConfigSchema.optional(), - background_task: BackgroundTaskConfigSchema.optional(), - notification: NotificationConfigSchema.optional(), - babysitting: BabysittingConfigSchema.optional(), - git_master: GitMasterConfigSchema.optional(), - browser_automation_engine: BrowserAutomationConfigSchema.optional(), - websearch: WebsearchConfigSchema.optional(), - tmux: TmuxConfigSchema.optional(), - sisyphus: SisyphusConfigSchema.optional(), - /** Migration history to prevent re-applying migrations (e.g., model version upgrades) */ - _migrations: z.array(z.string()).optional(), -}) - -export type OhMyOpenCodeConfig = z.infer -export type AgentOverrideConfig = z.infer -export type AgentOverrides = z.infer -export type BackgroundTaskConfig = z.infer -export type AgentName = z.infer -export type HookName = z.infer -export type BuiltinCommandName = z.infer -export type BuiltinSkillName = z.infer -export type SisyphusAgentConfig = z.infer -export type CommentCheckerConfig = z.infer -export type ExperimentalConfig = z.infer -export type DynamicContextPruningConfig = z.infer -export type SkillsConfig = z.infer -export type SkillDefinition = z.infer -export type RalphLoopConfig = z.infer -export type NotificationConfig = z.infer -export type BabysittingConfig = z.infer -export type CategoryConfig = z.infer -export type CategoriesConfig = z.infer -export type BuiltinCategoryName = z.infer -export type GitMasterConfig = z.infer -export type BrowserAutomationProvider = z.infer -export type BrowserAutomationConfig = z.infer -export type WebsearchProvider = z.infer -export type WebsearchConfig = z.infer -export type TmuxConfig = z.infer -export type TmuxLayout = z.infer -export type SisyphusTasksConfig = z.infer -export type SisyphusConfig = z.infer +export * from "./schema/agent-names" +export * from "./schema/agent-overrides" +export * from "./schema/babysitting" +export * from "./schema/background-task" +export * from "./schema/browser-automation" +export * from "./schema/categories" +export * from "./schema/claude-code" +export * from "./schema/comment-checker" +export * from "./schema/commands" +export * from "./schema/dynamic-context-pruning" +export * from "./schema/experimental" +export * from "./schema/git-master" +export * from "./schema/hooks" +export * from "./schema/notification" +export * from "./schema/oh-my-opencode-config" +export * from "./schema/ralph-loop" +export * from "./schema/skills" +export * from "./schema/sisyphus" +export * from "./schema/sisyphus-agent" +export * from "./schema/tmux" +export * from "./schema/websearch" export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types" diff --git a/src/config/schema/agent-names.ts b/src/config/schema/agent-names.ts new file mode 100644 index 000000000..814077d88 --- /dev/null +++ b/src/config/schema/agent-names.ts @@ -0,0 +1,44 @@ +import { z } from "zod" + +export const BuiltinAgentNameSchema = z.enum([ + "sisyphus", + "hephaestus", + "prometheus", + "oracle", + "librarian", + "explore", + "multimodal-looker", + "metis", + "momus", + "atlas", +]) + +export const BuiltinSkillNameSchema = z.enum([ + "playwright", + "agent-browser", + "dev-browser", + "frontend-ui-ux", + "git-master", +]) + +export const OverridableAgentNameSchema = z.enum([ + "build", + "plan", + "sisyphus", + "hephaestus", + "sisyphus-junior", + "OpenCode-Builder", + "prometheus", + "metis", + "momus", + "oracle", + "librarian", + "explore", + "multimodal-looker", + "atlas", +]) + +export const AgentNameSchema = BuiltinAgentNameSchema +export type AgentName = z.infer + +export type BuiltinSkillName = z.infer diff --git a/src/config/schema/agent-overrides.ts b/src/config/schema/agent-overrides.ts new file mode 100644 index 000000000..8fd48e330 --- /dev/null +++ b/src/config/schema/agent-overrides.ts @@ -0,0 +1,60 @@ +import { z } from "zod" +import { AgentPermissionSchema } from "./internal/permission" + +export const AgentOverrideConfigSchema = z.object({ + /** @deprecated Use `category` instead. Model is inherited from category defaults. */ + model: z.string().optional(), + variant: z.string().optional(), + /** Category name to inherit model and other settings from CategoryConfig */ + category: z.string().optional(), + /** Skill names to inject into agent prompt */ + skills: z.array(z.string()).optional(), + temperature: z.number().min(0).max(2).optional(), + top_p: z.number().min(0).max(1).optional(), + prompt: z.string().optional(), + prompt_append: z.string().optional(), + tools: z.record(z.string(), z.boolean()).optional(), + disable: z.boolean().optional(), + description: z.string().optional(), + mode: z.enum(["subagent", "primary", "all"]).optional(), + color: z + .string() + .regex(/^#[0-9A-Fa-f]{6}$/) + .optional(), + permission: AgentPermissionSchema.optional(), + /** Maximum tokens for response. Passed directly to OpenCode SDK. */ + maxTokens: z.number().optional(), + /** Extended thinking configuration (Anthropic). Overrides category and default settings. */ + thinking: z + .object({ + type: z.enum(["enabled", "disabled"]), + budgetTokens: z.number().optional(), + }) + .optional(), + /** Reasoning effort level (OpenAI). Overrides category and default settings. */ + reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(), + /** Text verbosity level. */ + textVerbosity: z.enum(["low", "medium", "high"]).optional(), + /** Provider-specific options. Passed directly to OpenCode SDK. */ + providerOptions: z.record(z.string(), z.unknown()).optional(), +}) + +export const AgentOverridesSchema = z.object({ + build: AgentOverrideConfigSchema.optional(), + plan: AgentOverrideConfigSchema.optional(), + sisyphus: AgentOverrideConfigSchema.optional(), + hephaestus: AgentOverrideConfigSchema.optional(), + "sisyphus-junior": AgentOverrideConfigSchema.optional(), + "OpenCode-Builder": AgentOverrideConfigSchema.optional(), + prometheus: AgentOverrideConfigSchema.optional(), + metis: AgentOverrideConfigSchema.optional(), + momus: AgentOverrideConfigSchema.optional(), + oracle: AgentOverrideConfigSchema.optional(), + librarian: AgentOverrideConfigSchema.optional(), + explore: AgentOverrideConfigSchema.optional(), + "multimodal-looker": AgentOverrideConfigSchema.optional(), + atlas: AgentOverrideConfigSchema.optional(), +}) + +export type AgentOverrideConfig = z.infer +export type AgentOverrides = z.infer diff --git a/src/config/schema/babysitting.ts b/src/config/schema/babysitting.ts new file mode 100644 index 000000000..76b5d0ac8 --- /dev/null +++ b/src/config/schema/babysitting.ts @@ -0,0 +1,7 @@ +import { z } from "zod" + +export const BabysittingConfigSchema = z.object({ + timeout_ms: z.number().default(120000), +}) + +export type BabysittingConfig = z.infer diff --git a/src/config/schema/background-task.ts b/src/config/schema/background-task.ts new file mode 100644 index 000000000..6e6ad331d --- /dev/null +++ b/src/config/schema/background-task.ts @@ -0,0 +1,11 @@ +import { z } from "zod" + +export const BackgroundTaskConfigSchema = z.object({ + defaultConcurrency: z.number().min(1).optional(), + providerConcurrency: z.record(z.string(), z.number().min(0)).optional(), + modelConcurrency: z.record(z.string(), z.number().min(0)).optional(), + /** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */ + staleTimeoutMs: z.number().min(60000).optional(), +}) + +export type BackgroundTaskConfig = z.infer diff --git a/src/config/schema/browser-automation.ts b/src/config/schema/browser-automation.ts new file mode 100644 index 000000000..294dcb965 --- /dev/null +++ b/src/config/schema/browser-automation.ts @@ -0,0 +1,22 @@ +import { z } from "zod" + +export const BrowserAutomationProviderSchema = z.enum([ + "playwright", + "agent-browser", + "dev-browser", +]) + +export const BrowserAutomationConfigSchema = z.object({ + /** + * Browser automation provider to use for the "playwright" skill. + * - "playwright": Uses Playwright MCP server (@playwright/mcp) - default + * - "agent-browser": Uses Vercel's agent-browser CLI (requires: bun add -g agent-browser) + * - "dev-browser": Uses dev-browser skill with persistent browser state + */ + provider: BrowserAutomationProviderSchema.default("playwright"), +}) + +export type BrowserAutomationProvider = z.infer< + typeof BrowserAutomationProviderSchema +> +export type BrowserAutomationConfig = z.infer diff --git a/src/config/schema/categories.ts b/src/config/schema/categories.ts new file mode 100644 index 000000000..b8028c572 --- /dev/null +++ b/src/config/schema/categories.ts @@ -0,0 +1,40 @@ +import { z } from "zod" + +export const CategoryConfigSchema = z.object({ + /** Human-readable description of the category's purpose. Shown in task prompt. */ + description: z.string().optional(), + model: z.string().optional(), + variant: z.string().optional(), + temperature: z.number().min(0).max(2).optional(), + top_p: z.number().min(0).max(1).optional(), + maxTokens: z.number().optional(), + thinking: z + .object({ + type: z.enum(["enabled", "disabled"]), + budgetTokens: z.number().optional(), + }) + .optional(), + reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(), + textVerbosity: z.enum(["low", "medium", "high"]).optional(), + tools: z.record(z.string(), z.boolean()).optional(), + prompt_append: z.string().optional(), + /** Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini/minimax models. */ + is_unstable_agent: z.boolean().optional(), +}) + +export const BuiltinCategoryNameSchema = z.enum([ + "visual-engineering", + "ultrabrain", + "deep", + "artistry", + "quick", + "unspecified-low", + "unspecified-high", + "writing", +]) + +export const CategoriesConfigSchema = z.record(z.string(), CategoryConfigSchema) + +export type CategoryConfig = z.infer +export type CategoriesConfig = z.infer +export type BuiltinCategoryName = z.infer diff --git a/src/config/schema/claude-code.ts b/src/config/schema/claude-code.ts new file mode 100644 index 000000000..90cab9a95 --- /dev/null +++ b/src/config/schema/claude-code.ts @@ -0,0 +1,13 @@ +import { z } from "zod" + +export const ClaudeCodeConfigSchema = z.object({ + mcp: z.boolean().optional(), + commands: z.boolean().optional(), + skills: z.boolean().optional(), + agents: z.boolean().optional(), + hooks: z.boolean().optional(), + plugins: z.boolean().optional(), + plugins_override: z.record(z.string(), z.boolean()).optional(), +}) + +export type ClaudeCodeConfig = z.infer diff --git a/src/config/schema/commands.ts b/src/config/schema/commands.ts new file mode 100644 index 000000000..967254538 --- /dev/null +++ b/src/config/schema/commands.ts @@ -0,0 +1,13 @@ +import { z } from "zod" + +export const BuiltinCommandNameSchema = z.enum([ + "init-deep", + "ralph-loop", + "ulw-loop", + "cancel-ralph", + "refactor", + "start-work", + "stop-continuation", +]) + +export type BuiltinCommandName = z.infer diff --git a/src/config/schema/comment-checker.ts b/src/config/schema/comment-checker.ts new file mode 100644 index 000000000..ca8b81cd5 --- /dev/null +++ b/src/config/schema/comment-checker.ts @@ -0,0 +1,8 @@ +import { z } from "zod" + +export const CommentCheckerConfigSchema = z.object({ + /** Custom prompt to replace the default warning message. Use {{comments}} placeholder for detected comments XML. */ + custom_prompt: z.string().optional(), +}) + +export type CommentCheckerConfig = z.infer diff --git a/src/config/schema/dynamic-context-pruning.ts b/src/config/schema/dynamic-context-pruning.ts new file mode 100644 index 000000000..1d99c95c2 --- /dev/null +++ b/src/config/schema/dynamic-context-pruning.ts @@ -0,0 +1,55 @@ +import { z } from "zod" + +export const DynamicContextPruningConfigSchema = z.object({ + /** Enable dynamic context pruning (default: false) */ + enabled: z.boolean().default(false), + /** Notification level: off, minimal, or detailed (default: detailed) */ + notification: z.enum(["off", "minimal", "detailed"]).default("detailed"), + /** Turn protection - prevent pruning recent tool outputs */ + turn_protection: z + .object({ + enabled: z.boolean().default(true), + turns: z.number().min(1).max(10).default(3), + }) + .optional(), + /** Tools that should never be pruned */ + protected_tools: z.array(z.string()).default([ + "task", + "todowrite", + "todoread", + "lsp_rename", + "session_read", + "session_write", + "session_search", + ]), + /** Pruning strategies configuration */ + strategies: z + .object({ + /** Remove duplicate tool calls (same tool + same args) */ + deduplication: z + .object({ + enabled: z.boolean().default(true), + }) + .optional(), + /** Prune write inputs when file subsequently read */ + supersede_writes: z + .object({ + enabled: z.boolean().default(true), + /** Aggressive mode: prune any write if ANY subsequent read */ + aggressive: z.boolean().default(false), + }) + .optional(), + /** Prune errored tool inputs after N turns */ + purge_errors: z + .object({ + enabled: z.boolean().default(true), + turns: z.number().min(1).max(20).default(5), + }) + .optional(), + }) + .optional(), +}) + +export type DynamicContextPruningConfig = z.infer< + typeof DynamicContextPruningConfigSchema +> diff --git a/src/config/schema/experimental.ts b/src/config/schema/experimental.ts new file mode 100644 index 000000000..52747aae9 --- /dev/null +++ b/src/config/schema/experimental.ts @@ -0,0 +1,20 @@ +import { z } from "zod" +import { DynamicContextPruningConfigSchema } from "./dynamic-context-pruning" + +export const ExperimentalConfigSchema = z.object({ + aggressive_truncation: z.boolean().optional(), + auto_resume: z.boolean().optional(), + preemptive_compaction: z.boolean().optional(), + /** Truncate all tool outputs, not just whitelisted tools (default: false). Tool output truncator is enabled by default - disable via disabled_hooks. */ + truncate_all_tool_outputs: z.boolean().optional(), + /** Dynamic context pruning configuration */ + dynamic_context_pruning: DynamicContextPruningConfigSchema.optional(), + /** Enable experimental task system for Todowrite disabler hook */ + task_system: z.boolean().optional(), + /** Timeout in ms for loadAllPluginComponents during config handler init (default: 10000, min: 1000) */ + plugin_load_timeout_ms: z.number().min(1000).optional(), + /** Wrap hook creation in try/catch to prevent one failing hook from crashing the plugin (default: true at call site) */ + safe_hook_creation: z.boolean().optional(), +}) + +export type ExperimentalConfig = z.infer diff --git a/src/config/schema/git-master.ts b/src/config/schema/git-master.ts new file mode 100644 index 000000000..0574de860 --- /dev/null +++ b/src/config/schema/git-master.ts @@ -0,0 +1,10 @@ +import { z } from "zod" + +export const GitMasterConfigSchema = z.object({ + /** Add "Ultraworked with Sisyphus" footer to commit messages (default: true). Can be boolean or custom string. */ + commit_footer: z.union([z.boolean(), z.string()]).default(true), + /** Add "Co-authored-by: Sisyphus" trailer to commit messages (default: true) */ + include_co_authored_by: z.boolean().default(true), +}) + +export type GitMasterConfig = z.infer diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts new file mode 100644 index 000000000..bb5f6bdb0 --- /dev/null +++ b/src/config/schema/hooks.ts @@ -0,0 +1,51 @@ +import { z } from "zod" + +export const HookNameSchema = z.enum([ + "todo-continuation-enforcer", + "context-window-monitor", + "session-recovery", + "session-notification", + "comment-checker", + "grep-output-truncator", + "tool-output-truncator", + "question-label-truncator", + "directory-agents-injector", + "directory-readme-injector", + "empty-task-response-detector", + "think-mode", + "subagent-question-blocker", + "anthropic-context-window-limit-recovery", + "preemptive-compaction", + "rules-injector", + "background-notification", + "auto-update-checker", + "startup-toast", + "keyword-detector", + "agent-usage-reminder", + "non-interactive-env", + "interactive-bash-session", + + "thinking-block-validator", + "ralph-loop", + "category-skill-reminder", + + "compaction-context-injector", + "compaction-todo-preserver", + "claude-code-hooks", + "auto-slash-command", + "edit-error-recovery", + "delegate-task-retry", + "prometheus-md-only", + "sisyphus-junior-notepad", + "start-work", + "atlas", + "unstable-agent-babysitter", + "task-reminder", + "task-resume-info", + "stop-continuation-guard", + "tasks-todowrite-disabler", + "write-existing-file-guard", + "anthropic-effort", +]) + +export type HookName = z.infer diff --git a/src/config/schema/internal/permission.ts b/src/config/schema/internal/permission.ts new file mode 100644 index 000000000..66e76cc5d --- /dev/null +++ b/src/config/schema/internal/permission.ts @@ -0,0 +1,20 @@ +import { z } from "zod" + +export const PermissionValueSchema = z.enum(["ask", "allow", "deny"]) +export type PermissionValue = z.infer + +const BashPermissionSchema = z.union([ + PermissionValueSchema, + z.record(z.string(), PermissionValueSchema), +]) + +export const AgentPermissionSchema = z.object({ + edit: PermissionValueSchema.optional(), + bash: BashPermissionSchema.optional(), + webfetch: PermissionValueSchema.optional(), + task: PermissionValueSchema.optional(), + doom_loop: PermissionValueSchema.optional(), + external_directory: PermissionValueSchema.optional(), +}) + +export type AgentPermission = z.infer diff --git a/src/config/schema/notification.ts b/src/config/schema/notification.ts new file mode 100644 index 000000000..48b73da35 --- /dev/null +++ b/src/config/schema/notification.ts @@ -0,0 +1,8 @@ +import { z } from "zod" + +export const NotificationConfigSchema = z.object({ + /** Force enable session-notification even if external notification plugins are detected (default: false) */ + force_enable: z.boolean().optional(), +}) + +export type NotificationConfig = z.infer diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts new file mode 100644 index 000000000..be0ebd914 --- /dev/null +++ b/src/config/schema/oh-my-opencode-config.ts @@ -0,0 +1,57 @@ +import { z } from "zod" +import { AnyMcpNameSchema } from "../../mcp/types" +import { BuiltinAgentNameSchema, BuiltinSkillNameSchema } from "./agent-names" +import { AgentOverridesSchema } from "./agent-overrides" +import { BabysittingConfigSchema } from "./babysitting" +import { BackgroundTaskConfigSchema } from "./background-task" +import { BrowserAutomationConfigSchema } from "./browser-automation" +import { CategoriesConfigSchema } from "./categories" +import { ClaudeCodeConfigSchema } from "./claude-code" +import { CommentCheckerConfigSchema } from "./comment-checker" +import { BuiltinCommandNameSchema } from "./commands" +import { ExperimentalConfigSchema } from "./experimental" +import { GitMasterConfigSchema } from "./git-master" +import { HookNameSchema } from "./hooks" +import { NotificationConfigSchema } from "./notification" +import { RalphLoopConfigSchema } from "./ralph-loop" +import { SkillsConfigSchema } from "./skills" +import { SisyphusConfigSchema } from "./sisyphus" +import { SisyphusAgentConfigSchema } from "./sisyphus-agent" +import { TmuxConfigSchema } from "./tmux" +import { WebsearchConfigSchema } from "./websearch" + +export const OhMyOpenCodeConfigSchema = z.object({ + $schema: z.string().optional(), + /** Enable new task system (default: false) */ + new_task_system_enabled: z.boolean().optional(), + /** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */ + default_run_agent: z.string().optional(), + disabled_mcps: z.array(AnyMcpNameSchema).optional(), + disabled_agents: z.array(BuiltinAgentNameSchema).optional(), + disabled_skills: z.array(BuiltinSkillNameSchema).optional(), + disabled_hooks: z.array(HookNameSchema).optional(), + disabled_commands: z.array(BuiltinCommandNameSchema).optional(), + /** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */ + disabled_tools: z.array(z.string()).optional(), + agents: AgentOverridesSchema.optional(), + categories: CategoriesConfigSchema.optional(), + claude_code: ClaudeCodeConfigSchema.optional(), + sisyphus_agent: SisyphusAgentConfigSchema.optional(), + comment_checker: CommentCheckerConfigSchema.optional(), + experimental: ExperimentalConfigSchema.optional(), + auto_update: z.boolean().optional(), + skills: SkillsConfigSchema.optional(), + ralph_loop: RalphLoopConfigSchema.optional(), + background_task: BackgroundTaskConfigSchema.optional(), + notification: NotificationConfigSchema.optional(), + babysitting: BabysittingConfigSchema.optional(), + git_master: GitMasterConfigSchema.optional(), + browser_automation_engine: BrowserAutomationConfigSchema.optional(), + websearch: WebsearchConfigSchema.optional(), + tmux: TmuxConfigSchema.optional(), + sisyphus: SisyphusConfigSchema.optional(), + /** Migration history to prevent re-applying migrations (e.g., model version upgrades) */ + _migrations: z.array(z.string()).optional(), +}) + +export type OhMyOpenCodeConfig = z.infer diff --git a/src/config/schema/ralph-loop.ts b/src/config/schema/ralph-loop.ts new file mode 100644 index 000000000..1dbcde4fc --- /dev/null +++ b/src/config/schema/ralph-loop.ts @@ -0,0 +1,12 @@ +import { z } from "zod" + +export const RalphLoopConfigSchema = z.object({ + /** Enable ralph loop functionality (default: false - opt-in feature) */ + enabled: z.boolean().default(false), + /** Default max iterations if not specified in command (default: 100) */ + default_max_iterations: z.number().min(1).max(1000).default(100), + /** Custom state file directory relative to project root (default: .opencode/) */ + state_dir: z.string().optional(), +}) + +export type RalphLoopConfig = z.infer diff --git a/src/config/schema/sisyphus-agent.ts b/src/config/schema/sisyphus-agent.ts new file mode 100644 index 000000000..76ee2c373 --- /dev/null +++ b/src/config/schema/sisyphus-agent.ts @@ -0,0 +1,10 @@ +import { z } from "zod" + +export const SisyphusAgentConfigSchema = z.object({ + disabled: z.boolean().optional(), + default_builder_enabled: z.boolean().optional(), + planner_enabled: z.boolean().optional(), + replace_plan: z.boolean().optional(), +}) + +export type SisyphusAgentConfig = z.infer diff --git a/src/config/schema/sisyphus.ts b/src/config/schema/sisyphus.ts new file mode 100644 index 000000000..9ac3d2d14 --- /dev/null +++ b/src/config/schema/sisyphus.ts @@ -0,0 +1,17 @@ +import { z } from "zod" + +export const SisyphusTasksConfigSchema = z.object({ + /** Absolute or relative storage path override. When set, bypasses global config dir. */ + storage_path: z.string().optional(), + /** Force task list ID (alternative to env ULTRAWORK_TASK_LIST_ID) */ + task_list_id: z.string().optional(), + /** Enable Claude Code path compatibility mode */ + claude_code_compat: z.boolean().default(false), +}) + +export const SisyphusConfigSchema = z.object({ + tasks: SisyphusTasksConfigSchema.optional(), +}) + +export type SisyphusTasksConfig = z.infer +export type SisyphusConfig = z.infer diff --git a/src/config/schema/skills.ts b/src/config/schema/skills.ts new file mode 100644 index 000000000..0e7fbaa8a --- /dev/null +++ b/src/config/schema/skills.ts @@ -0,0 +1,45 @@ +import { z } from "zod" + +export const SkillSourceSchema = z.union([ + z.string(), + z.object({ + path: z.string(), + recursive: z.boolean().optional(), + glob: z.string().optional(), + }), +]) + +export const SkillDefinitionSchema = z.object({ + description: z.string().optional(), + template: z.string().optional(), + from: z.string().optional(), + model: z.string().optional(), + agent: z.string().optional(), + subtask: z.boolean().optional(), + "argument-hint": z.string().optional(), + license: z.string().optional(), + compatibility: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + "allowed-tools": z.array(z.string()).optional(), + disable: z.boolean().optional(), +}) + +export const SkillEntrySchema = z.union([z.boolean(), SkillDefinitionSchema]) + +export const SkillsConfigSchema = z.union([ + z.array(z.string()), + z + .record(z.string(), SkillEntrySchema) + .and( + z + .object({ + sources: z.array(SkillSourceSchema).optional(), + enable: z.array(z.string()).optional(), + disable: z.array(z.string()).optional(), + }) + .partial() + ), +]) + +export type SkillsConfig = z.infer +export type SkillDefinition = z.infer diff --git a/src/config/schema/tmux.ts b/src/config/schema/tmux.ts new file mode 100644 index 000000000..17685f025 --- /dev/null +++ b/src/config/schema/tmux.ts @@ -0,0 +1,20 @@ +import { z } from "zod" + +export const TmuxLayoutSchema = z.enum([ + "main-horizontal", // main pane top, agent panes bottom stack + "main-vertical", // main pane left, agent panes right stack (default) + "tiled", // all panes same size grid + "even-horizontal", // all panes horizontal row + "even-vertical", // all panes vertical stack +]) + +export const TmuxConfigSchema = z.object({ + enabled: z.boolean().default(false), + layout: TmuxLayoutSchema.default("main-vertical"), + main_pane_size: z.number().min(20).max(80).default(60), + main_pane_min_width: z.number().min(40).default(120), + agent_pane_min_width: z.number().min(20).default(40), +}) + +export type TmuxConfig = z.infer +export type TmuxLayout = z.infer diff --git a/src/config/schema/websearch.ts b/src/config/schema/websearch.ts new file mode 100644 index 000000000..1f9efee6f --- /dev/null +++ b/src/config/schema/websearch.ts @@ -0,0 +1,15 @@ +import { z } from "zod" + +export const WebsearchProviderSchema = z.enum(["exa", "tavily"]) + +export const WebsearchConfigSchema = z.object({ + /** + * Websearch provider to use. + * - "exa": Uses Exa websearch (default, works without API key) + * - "tavily": Uses Tavily websearch (requires TAVILY_API_KEY) + */ + provider: WebsearchProviderSchema.optional(), +}) + +export type WebsearchProvider = z.infer +export type WebsearchConfig = z.infer diff --git a/src/hooks/atlas/atlas-hook.ts b/src/hooks/atlas/atlas-hook.ts new file mode 100644 index 000000000..5d8c47f49 --- /dev/null +++ b/src/hooks/atlas/atlas-hook.ts @@ -0,0 +1,25 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { createAtlasEventHandler } from "./event-handler" +import { createToolExecuteAfterHandler } from "./tool-execute-after" +import { createToolExecuteBeforeHandler } from "./tool-execute-before" +import type { AtlasHookOptions, SessionState } from "./types" + +export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) { + const sessions = new Map() + const pendingFilePaths = new Map() + + function getState(sessionID: string): SessionState { + let state = sessions.get(sessionID) + if (!state) { + state = { promptFailureCount: 0 } + sessions.set(sessionID, state) + } + return state + } + + return { + handler: createAtlasEventHandler({ ctx, options, sessions, getState }), + "tool.execute.before": createToolExecuteBeforeHandler({ pendingFilePaths }), + "tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths }), + } +} diff --git a/src/hooks/atlas/boulder-continuation-injector.ts b/src/hooks/atlas/boulder-continuation-injector.ts new file mode 100644 index 000000000..93ccaefac --- /dev/null +++ b/src/hooks/atlas/boulder-continuation-injector.ts @@ -0,0 +1,68 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import type { BackgroundManager } from "../../features/background-agent" +import { log } from "../../shared/logger" +import { HOOK_NAME } from "./hook-name" +import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates" +import { resolveRecentModelForSession } from "./recent-model-resolver" +import type { SessionState } from "./types" + +export async function injectBoulderContinuation(input: { + ctx: PluginInput + sessionID: string + planName: string + remaining: number + total: number + agent?: string + backgroundManager?: BackgroundManager + sessionState: SessionState +}): Promise { + const { + ctx, + sessionID, + planName, + remaining, + total, + agent, + backgroundManager, + sessionState, + } = input + + const hasRunningBgTasks = backgroundManager + ? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => t.status === "running") + : false + + if (hasRunningBgTasks) { + log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID }) + return + } + + const prompt = + BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) + + `\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` + + try { + log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining }) + + const model = await resolveRecentModelForSession(ctx, sessionID) + + await ctx.client.session.promptAsync({ + path: { id: sessionID }, + body: { + agent: agent ?? "atlas", + ...(model !== undefined ? { model } : {}), + parts: [{ type: "text", text: prompt }], + }, + query: { directory: ctx.directory }, + }) + + sessionState.promptFailureCount = 0 + log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID }) + } catch (err) { + sessionState.promptFailureCount += 1 + log(`[${HOOK_NAME}] Boulder continuation failed`, { + sessionID, + error: String(err), + promptFailureCount: sessionState.promptFailureCount, + }) + } +} diff --git a/src/hooks/atlas/event-handler.ts b/src/hooks/atlas/event-handler.ts new file mode 100644 index 000000000..a9d26a329 --- /dev/null +++ b/src/hooks/atlas/event-handler.ts @@ -0,0 +1,187 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { getPlanProgress, readBoulderState } from "../../features/boulder-state" +import { getMainSessionID, subagentSessions } from "../../features/claude-code-session-state" +import { log } from "../../shared/logger" +import { HOOK_NAME } from "./hook-name" +import { isAbortError } from "./is-abort-error" +import { injectBoulderContinuation } from "./boulder-continuation-injector" +import { getLastAgentFromSession } from "./session-last-agent" +import type { AtlasHookOptions, SessionState } from "./types" + +const CONTINUATION_COOLDOWN_MS = 5000 + +export function createAtlasEventHandler(input: { + ctx: PluginInput + options?: AtlasHookOptions + sessions: Map + getState: (sessionID: string) => SessionState +}): (arg: { event: { type: string; properties?: unknown } }) => Promise { + const { ctx, options, sessions, getState } = input + + return async ({ event }): Promise => { + const props = event.properties as Record | undefined + + if (event.type === "session.error") { + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + + const state = getState(sessionID) + const isAbort = isAbortError(props?.error) + state.lastEventWasAbortError = isAbort + + log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort }) + return + } + + if (event.type === "session.idle") { + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + + log(`[${HOOK_NAME}] session.idle`, { sessionID }) + + // Read boulder state FIRST to check if this session is part of an active boulder + const boulderState = readBoulderState(ctx.directory) + const isBoulderSession = boulderState?.session_ids.includes(sessionID) ?? false + + const mainSessionID = getMainSessionID() + const isMainSession = sessionID === mainSessionID + const isBackgroundTaskSession = subagentSessions.has(sessionID) + + // Allow continuation if: main session OR background task OR boulder session + if (mainSessionID && !isMainSession && !isBackgroundTaskSession && !isBoulderSession) { + log(`[${HOOK_NAME}] Skipped: not main, background task, or boulder session`, { sessionID }) + return + } + + const state = getState(sessionID) + + if (state.lastEventWasAbortError) { + state.lastEventWasAbortError = false + log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID }) + return + } + + if (state.promptFailureCount >= 2) { + log(`[${HOOK_NAME}] Skipped: continuation disabled after repeated prompt failures`, { + sessionID, + promptFailureCount: state.promptFailureCount, + }) + return + } + + const backgroundManager = options?.backgroundManager + const hasRunningBgTasks = backgroundManager + ? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => t.status === "running") + : false + + if (hasRunningBgTasks) { + log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID }) + return + } + + if (!boulderState) { + log(`[${HOOK_NAME}] No active boulder`, { sessionID }) + return + } + + if (options?.isContinuationStopped?.(sessionID)) { + log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) + return + } + + const requiredAgent = (boulderState.agent ?? "atlas").toLowerCase() + const lastAgent = getLastAgentFromSession(sessionID) + if (!lastAgent || lastAgent !== requiredAgent) { + log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, { + sessionID, + lastAgent: lastAgent ?? "unknown", + requiredAgent, + }) + return + } + + const progress = getPlanProgress(boulderState.active_plan) + if (progress.isComplete) { + log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name }) + return + } + + const now = Date.now() + if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) { + log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, { + sessionID, + cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - state.lastContinuationInjectedAt), + }) + return + } + + state.lastContinuationInjectedAt = now + const remaining = progress.total - progress.completed + injectBoulderContinuation({ + ctx, + sessionID, + planName: boulderState.plan_name, + remaining, + total: progress.total, + agent: boulderState.agent, + backgroundManager, + sessionState: state, + }) + return + } + + if (event.type === "message.updated") { + const info = props?.info as Record | undefined + const sessionID = info?.sessionID as string | undefined + if (!sessionID) return + + const state = sessions.get(sessionID) + if (state) { + state.lastEventWasAbortError = false + } + return + } + + if (event.type === "message.part.updated") { + const info = props?.info as Record | undefined + const sessionID = info?.sessionID as string | undefined + const role = info?.role as string | undefined + + if (sessionID && role === "assistant") { + const state = sessions.get(sessionID) + if (state) { + state.lastEventWasAbortError = false + } + } + return + } + + if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { + const sessionID = props?.sessionID as string | undefined + if (sessionID) { + const state = sessions.get(sessionID) + if (state) { + state.lastEventWasAbortError = false + } + } + return + } + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id) { + sessions.delete(sessionInfo.id) + log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) + } + return + } + + if (event.type === "session.compacted") { + const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined + if (sessionID) { + sessions.delete(sessionID) + log(`[${HOOK_NAME}] Session compacted: cleaned up`, { sessionID }) + } + } + } +} diff --git a/src/hooks/atlas/git-diff-stats.ts b/src/hooks/atlas/git-diff-stats.ts new file mode 100644 index 000000000..404942938 --- /dev/null +++ b/src/hooks/atlas/git-diff-stats.ts @@ -0,0 +1,108 @@ +import { execSync } from "node:child_process" + +interface GitFileStat { + path: string + added: number + removed: number + status: "modified" | "added" | "deleted" +} + +export function getGitDiffStats(directory: string): GitFileStat[] { + try { + const output = execSync("git diff --numstat HEAD", { + cwd: directory, + encoding: "utf-8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }).trim() + + if (!output) return [] + + const statusOutput = execSync("git status --porcelain", { + cwd: directory, + encoding: "utf-8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }).trim() + + const statusMap = new Map() + for (const line of statusOutput.split("\n")) { + if (!line) continue + const status = line.substring(0, 2).trim() + const filePath = line.substring(3) + if (status === "A" || status === "??") { + statusMap.set(filePath, "added") + } else if (status === "D") { + statusMap.set(filePath, "deleted") + } else { + statusMap.set(filePath, "modified") + } + } + + const stats: GitFileStat[] = [] + for (const line of output.split("\n")) { + const parts = line.split("\t") + if (parts.length < 3) continue + + const [addedStr, removedStr, path] = parts + const added = addedStr === "-" ? 0 : parseInt(addedStr, 10) + const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10) + + stats.push({ + path, + added, + removed, + status: statusMap.get(path) ?? "modified", + }) + } + + return stats + } catch { + return [] + } +} + +export function formatFileChanges(stats: GitFileStat[], notepadPath?: string): string { + if (stats.length === 0) return "[FILE CHANGES SUMMARY]\nNo file changes detected.\n" + + const modified = stats.filter((s) => s.status === "modified") + const added = stats.filter((s) => s.status === "added") + const deleted = stats.filter((s) => s.status === "deleted") + + const lines: string[] = ["[FILE CHANGES SUMMARY]"] + + if (modified.length > 0) { + lines.push("Modified files:") + for (const f of modified) { + lines.push(` ${f.path} (+${f.added}, -${f.removed})`) + } + lines.push("") + } + + if (added.length > 0) { + lines.push("Created files:") + for (const f of added) { + lines.push(` ${f.path} (+${f.added})`) + } + lines.push("") + } + + if (deleted.length > 0) { + lines.push("Deleted files:") + for (const f of deleted) { + lines.push(` ${f.path} (-${f.removed})`) + } + lines.push("") + } + + if (notepadPath) { + const notepadStat = stats.find((s) => s.path.includes("notepad") || s.path.includes(".sisyphus")) + if (notepadStat) { + lines.push("[NOTEPAD UPDATED]") + lines.push(` ${notepadStat.path} (+${notepadStat.added})`) + lines.push("") + } + } + + return lines.join("\n") +} diff --git a/src/hooks/atlas/hook-name.ts b/src/hooks/atlas/hook-name.ts new file mode 100644 index 000000000..569fcd46b --- /dev/null +++ b/src/hooks/atlas/hook-name.ts @@ -0,0 +1 @@ +export const HOOK_NAME = "atlas" diff --git a/src/hooks/atlas/index.ts b/src/hooks/atlas/index.ts index ffad04598..ab8074167 100644 --- a/src/hooks/atlas/index.ts +++ b/src/hooks/atlas/index.ts @@ -1,804 +1,3 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { execSync } from "node:child_process" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import { - readBoulderState, - appendSessionId, - getPlanProgress, -} from "../../features/boulder-state" -import { getMainSessionID, subagentSessions } from "../../features/claude-code-session-state" -import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" -import { log } from "../../shared/logger" -import { createSystemDirective, SYSTEM_DIRECTIVE_PREFIX, SystemDirectiveTypes } from "../../shared/system-directive" -import { isCallerOrchestrator, getMessageDir } from "../../shared/session-utils" -import type { BackgroundManager } from "../../features/background-agent" - -export const HOOK_NAME = "atlas" - -/** - * Cross-platform check if a path is inside .sisyphus/ directory. - * Handles both forward slashes (Unix) and backslashes (Windows). - */ -function isSisyphusPath(filePath: string): boolean { - return /\.sisyphus[/\\]/.test(filePath) -} - -const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"] - -function getLastAgentFromSession(sessionID: string): string | null { - const messageDir = getMessageDir(sessionID) - if (!messageDir) return null - const nearest = findNearestMessageWithFields(messageDir) - return nearest?.agent?.toLowerCase() ?? null -} - -const DIRECT_WORK_REMINDER = ` - ---- - -${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)} - -You just performed direct file modifications outside \`.sisyphus/\`. - -**You are an ORCHESTRATOR, not an IMPLEMENTER.** - -As an orchestrator, you should: -- **DELEGATE** implementation work to subagents via \`task\` -- **VERIFY** the work done by subagents -- **COORDINATE** multiple tasks and ensure completion - -You should NOT: -- Write code directly (except for \`.sisyphus/\` files like plans and notepads) -- Make direct file edits outside \`.sisyphus/\` -- Implement features yourself - -**If you need to make changes:** -1. Use \`task\` to delegate to an appropriate subagent -2. Provide clear instructions in the prompt -3. Verify the subagent's work after completion - ---- -` - -const BOULDER_CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.BOULDER_CONTINUATION)} - -You have an active work plan with incomplete tasks. Continue working. - -RULES: -- Proceed without asking for permission -- Change \`- [ ]\` to \`- [x]\` in the plan file when done -- Use the notepad at .sisyphus/notepads/{PLAN_NAME}/ to record learnings -- Do not stop until all tasks are complete -- If blocked, document the blocker and move to the next task` - -const VERIFICATION_REMINDER = `**MANDATORY: WHAT YOU MUST DO RIGHT NOW** - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -CRITICAL: Subagents FREQUENTLY LIE about completion. -Tests FAILING, code has ERRORS, implementation INCOMPLETE - but they say "done". - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -**STEP 1: VERIFY WITH YOUR OWN TOOL CALLS (DO THIS NOW)** - -Run these commands YOURSELF - do NOT trust agent's claims: -1. \`lsp_diagnostics\` on changed files → Must be CLEAN -2. \`bash\` to run tests → Must PASS -3. \`bash\` to run build/typecheck → Must succeed -4. \`Read\` the actual code → Must match requirements - -**STEP 2: DETERMINE IF HANDS-ON QA IS NEEDED** - -| Deliverable Type | QA Method | Tool | -|------------------|-----------|------| -| **Frontend/UI** | Browser interaction | \`/playwright\` skill | -| **TUI/CLI** | Run interactively | \`interactive_bash\` (tmux) | -| **API/Backend** | Send real requests | \`bash\` with curl | - -Static analysis CANNOT catch: visual bugs, animation issues, user flow breakages. - -**STEP 3: IF QA IS NEEDED - ADD TO TODO IMMEDIATELY** - -\`\`\` -todowrite([ - { id: "qa-X", content: "HANDS-ON QA: [specific verification action]", status: "pending", priority: "high" } -]) -\`\`\` - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -**BLOCKING: DO NOT proceed to Step 4 until Steps 1-3 are VERIFIED.**` - -const ORCHESTRATOR_DELEGATION_REQUIRED = ` - ---- - -${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)} - -**STOP. YOU ARE VIOLATING ORCHESTRATOR PROTOCOL.** - -You (Atlas) are attempting to directly modify a file outside \`.sisyphus/\`. - -**Path attempted:** $FILE_PATH - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -**THIS IS FORBIDDEN** (except for VERIFICATION purposes) - -As an ORCHESTRATOR, you MUST: -1. **DELEGATE** all implementation work via \`task\` -2. **VERIFY** the work done by subagents (reading files is OK) -3. **COORDINATE** - you orchestrate, you don't implement - -**ALLOWED direct file operations:** -- Files inside \`.sisyphus/\` (plans, notepads, drafts) -- Reading files for verification -- Running diagnostics/tests - -**FORBIDDEN direct file operations:** -- Writing/editing source code -- Creating new files outside \`.sisyphus/\` -- Any implementation work - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -**IF THIS IS FOR VERIFICATION:** -Proceed if you are verifying subagent work by making a small fix. -But for any substantial changes, USE \`task\`. - -**CORRECT APPROACH:** -\`\`\` -task( - category="...", - prompt="[specific single task with clear acceptance criteria]" -) -\`\`\` - -DELEGATE. DON'T IMPLEMENT. - ---- -` - -const SINGLE_TASK_DIRECTIVE = ` - -${createSystemDirective(SystemDirectiveTypes.SINGLE_TASK_ONLY)} - -**STOP. READ THIS BEFORE PROCEEDING.** - -If you were NOT given **exactly ONE atomic task**, you MUST: -1. **IMMEDIATELY REFUSE** this request -2. **DEMAND** the orchestrator provide a single, specific task - -**Your response if multiple tasks detected:** -> "I refuse to proceed. You provided multiple tasks. An orchestrator's impatience destroys work quality. -> -> PROVIDE EXACTLY ONE TASK. One file. One change. One verification. -> -> Your rushing will cause: incomplete work, missed edge cases, broken tests, wasted context." - -**WARNING TO ORCHESTRATOR:** -- Your hasty batching RUINS deliverables -- Each task needs FULL attention and PROPER verification -- Batch delegation = sloppy work = rework = wasted tokens - -**REFUSE multi-task requests. DEMAND single-task clarity.** -` - -function buildVerificationReminder(sessionId: string): string { - return `${VERIFICATION_REMINDER} - ---- - -**If ANY verification fails, use this immediately:** -\`\`\` -task(session_id="${sessionId}", prompt="fix: [describe the specific failure]") -\`\`\`` -} - -function buildOrchestratorReminder(planName: string, progress: { total: number; completed: number }, sessionId: string): string { - const remaining = progress.total - progress.completed - return ` ---- - -**BOULDER STATE:** Plan: \`${planName}\` | ${progress.completed}/${progress.total} done | ${remaining} remaining - ---- - -${buildVerificationReminder(sessionId)} - -**STEP 4: MARK COMPLETION IN PLAN FILE (IMMEDIATELY)** - -RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY. - -Update the plan file \`.sisyphus/tasks/${planName}.yaml\`: -- Change \`- [ ]\` to \`- [x]\` for the completed task -- Use \`Edit\` tool to modify the checkbox - -**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.** - -**STEP 5: COMMIT ATOMIC UNIT** - -- Stage ONLY the verified changes -- Commit with clear message describing what was done - -**STEP 6: PROCEED TO NEXT TASK** - -- Read the plan file to identify the next \`- [ ]\` task -- Start immediately - DO NOT STOP - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -**${remaining} tasks remain. Keep bouldering.**` -} - -function buildStandaloneVerificationReminder(sessionId: string): string { - return ` ---- - -${buildVerificationReminder(sessionId)} - -**STEP 4: UPDATE TODO STATUS (IMMEDIATELY)** - -RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY. - -1. Run \`todoread\` to see your todo list -2. Mark the completed task as \`completed\` using \`todowrite\` - -**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.** - -**STEP 5: EXECUTE QA TASKS (IF ANY)** - -If QA tasks exist in your todo list: -- Execute them BEFORE proceeding -- Mark each QA task complete after successful verification - -**STEP 6: PROCEED TO NEXT PENDING TASK** - -- Identify the next \`pending\` task from your todo list -- Start immediately - DO NOT STOP - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -**NO TODO = NO TRACKING = INCOMPLETE WORK. Use todowrite aggressively.**` -} - -function extractSessionIdFromOutput(output: string): string { - const match = output.match(/Session ID:\s*(ses_[a-zA-Z0-9]+)/) - return match?.[1] ?? "" -} - -interface GitFileStat { - path: string - added: number - removed: number - status: "modified" | "added" | "deleted" -} - -function getGitDiffStats(directory: string): GitFileStat[] { - try { - const output = execSync("git diff --numstat HEAD", { - cwd: directory, - encoding: "utf-8", - timeout: 5000, - stdio: ["pipe", "pipe", "pipe"], - }).trim() - - if (!output) return [] - - const statusOutput = execSync("git status --porcelain", { - cwd: directory, - encoding: "utf-8", - timeout: 5000, - stdio: ["pipe", "pipe", "pipe"], - }).trim() - - const statusMap = new Map() - for (const line of statusOutput.split("\n")) { - if (!line) continue - const status = line.substring(0, 2).trim() - const filePath = line.substring(3) - if (status === "A" || status === "??") { - statusMap.set(filePath, "added") - } else if (status === "D") { - statusMap.set(filePath, "deleted") - } else { - statusMap.set(filePath, "modified") - } - } - - const stats: GitFileStat[] = [] - for (const line of output.split("\n")) { - const parts = line.split("\t") - if (parts.length < 3) continue - - const [addedStr, removedStr, path] = parts - const added = addedStr === "-" ? 0 : parseInt(addedStr, 10) - const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10) - - stats.push({ - path, - added, - removed, - status: statusMap.get(path) ?? "modified", - }) - } - - return stats - } catch { - return [] - } -} - -function formatFileChanges(stats: GitFileStat[], notepadPath?: string): string { - if (stats.length === 0) return "[FILE CHANGES SUMMARY]\nNo file changes detected.\n" - - const modified = stats.filter((s) => s.status === "modified") - const added = stats.filter((s) => s.status === "added") - const deleted = stats.filter((s) => s.status === "deleted") - - const lines: string[] = ["[FILE CHANGES SUMMARY]"] - - if (modified.length > 0) { - lines.push("Modified files:") - for (const f of modified) { - lines.push(` ${f.path} (+${f.added}, -${f.removed})`) - } - lines.push("") - } - - if (added.length > 0) { - lines.push("Created files:") - for (const f of added) { - lines.push(` ${f.path} (+${f.added})`) - } - lines.push("") - } - - if (deleted.length > 0) { - lines.push("Deleted files:") - for (const f of deleted) { - lines.push(` ${f.path} (-${f.removed})`) - } - lines.push("") - } - - if (notepadPath) { - const notepadStat = stats.find((s) => s.path.includes("notepad") || s.path.includes(".sisyphus")) - if (notepadStat) { - lines.push("[NOTEPAD UPDATED]") - lines.push(` ${notepadStat.path} (+${notepadStat.added})`) - lines.push("") - } - } - - return lines.join("\n") -} - -interface ToolExecuteAfterInput { - tool: string - sessionID?: string - callID?: string -} - -interface ToolExecuteAfterOutput { - title: string - output: string - metadata: Record -} - -interface SessionState { - lastEventWasAbortError?: boolean - lastContinuationInjectedAt?: number - promptFailureCount: number -} - -const CONTINUATION_COOLDOWN_MS = 5000 - -export interface AtlasHookOptions { - directory: string - backgroundManager?: BackgroundManager - isContinuationStopped?: (sessionID: string) => boolean -} - -function isAbortError(error: unknown): boolean { - if (!error) return false - - if (typeof error === "object") { - const errObj = error as Record - const name = errObj.name as string | undefined - const message = (errObj.message as string | undefined)?.toLowerCase() ?? "" - - if (name === "MessageAbortedError" || name === "AbortError") return true - if (name === "DOMException" && message.includes("abort")) return true - if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true - } - - if (typeof error === "string") { - const lower = error.toLowerCase() - return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt") - } - - return false -} - -export function createAtlasHook( - ctx: PluginInput, - options?: AtlasHookOptions -) { - const backgroundManager = options?.backgroundManager - const sessions = new Map() - const pendingFilePaths = new Map() - - function getState(sessionID: string): SessionState { - let state = sessions.get(sessionID) - if (!state) { - state = { promptFailureCount: 0 } - sessions.set(sessionID, state) - } - return state - } - - async function injectContinuation(sessionID: string, planName: string, remaining: number, total: number, agent?: string): Promise { - const state = getState(sessionID) - const hasRunningBgTasks = backgroundManager - ? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running") - : false - - if (hasRunningBgTasks) { - log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID }) - return - } - - const prompt = BOULDER_CONTINUATION_PROMPT - .replace(/{PLAN_NAME}/g, planName) + - `\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` - - try { - log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining }) - - let model: { providerID: string; modelID: string } | undefined - try { - const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } }) - const messages = (messagesResp.data ?? []) as Array<{ - info?: { model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string } - }> - for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - const msgModel = info?.model - if (msgModel?.providerID && msgModel?.modelID) { - model = { providerID: msgModel.providerID, modelID: msgModel.modelID } - break - } - if (info?.providerID && info?.modelID) { - model = { providerID: info.providerID, modelID: info.modelID } - break - } - } - } catch { - const messageDir = getMessageDir(sessionID) - const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - model = currentMessage?.model?.providerID && currentMessage?.model?.modelID - ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } - : undefined - } - - await ctx.client.session.promptAsync({ - path: { id: sessionID }, - body: { - agent: agent ?? "atlas", - ...(model !== undefined ? { model } : {}), - parts: [{ type: "text", text: prompt }], - }, - query: { directory: ctx.directory }, - }) - - state.promptFailureCount = 0 - - log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID }) - } catch (err) { - state.promptFailureCount += 1 - log(`[${HOOK_NAME}] Boulder continuation failed`, { - sessionID, - error: String(err), - promptFailureCount: state.promptFailureCount, - }) - } - } - - return { - handler: async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { - const props = event.properties as Record | undefined - - if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - const state = getState(sessionID) - const isAbort = isAbortError(props?.error) - state.lastEventWasAbortError = isAbort - - log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort }) - return - } - - if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - log(`[${HOOK_NAME}] session.idle`, { sessionID }) - - // Read boulder state FIRST to check if this session is part of an active boulder - const boulderState = readBoulderState(ctx.directory) - const isBoulderSession = boulderState?.session_ids.includes(sessionID) ?? false - - const mainSessionID = getMainSessionID() - const isMainSession = sessionID === mainSessionID - const isBackgroundTaskSession = subagentSessions.has(sessionID) - - // Allow continuation if: main session OR background task OR boulder session - if (mainSessionID && !isMainSession && !isBackgroundTaskSession && !isBoulderSession) { - log(`[${HOOK_NAME}] Skipped: not main, background task, or boulder session`, { sessionID }) - return - } - - const state = getState(sessionID) - - if (state.lastEventWasAbortError) { - state.lastEventWasAbortError = false - log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID }) - return - } - - if (state.promptFailureCount >= 2) { - log(`[${HOOK_NAME}] Skipped: continuation disabled after repeated prompt failures`, { - sessionID, - promptFailureCount: state.promptFailureCount, - }) - return - } - - const hasRunningBgTasks = backgroundManager - ? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running") - : false - - if (hasRunningBgTasks) { - log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID }) - return - } - - - if (!boulderState) { - log(`[${HOOK_NAME}] No active boulder`, { sessionID }) - return - } - - if (options?.isContinuationStopped?.(sessionID)) { - log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) - return - } - - const requiredAgent = (boulderState.agent ?? "atlas").toLowerCase() - const lastAgent = getLastAgentFromSession(sessionID) - if (!lastAgent || lastAgent !== requiredAgent) { - log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, { - sessionID, - lastAgent: lastAgent ?? "unknown", - requiredAgent, - }) - return - } - - const progress = getPlanProgress(boulderState.active_plan) - if (progress.isComplete) { - log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name }) - return - } - - const now = Date.now() - if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) { - log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, { sessionID, cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - state.lastContinuationInjectedAt) }) - return - } - - state.lastContinuationInjectedAt = now - const remaining = progress.total - progress.completed - injectContinuation(sessionID, boulderState.plan_name, remaining, progress.total, boulderState.agent) - return - } - - if (event.type === "message.updated") { - const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined - - if (!sessionID) return - - const state = sessions.get(sessionID) - if (state) { - state.lastEventWasAbortError = false - } - return - } - - if (event.type === "message.part.updated") { - const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined - const role = info?.role as string | undefined - - if (sessionID && role === "assistant") { - const state = sessions.get(sessionID) - if (state) { - state.lastEventWasAbortError = false - } - } - return - } - - if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { - const sessionID = props?.sessionID as string | undefined - if (sessionID) { - const state = sessions.get(sessionID) - if (state) { - state.lastEventWasAbortError = false - } - } - return - } - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - sessions.delete(sessionInfo.id) - log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) - } - return - } - - if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as - | string - | undefined - if (sessionID) { - sessions.delete(sessionID) - log(`[${HOOK_NAME}] Session compacted: cleaned up`, { sessionID }) - } - return - } - }, - - "tool.execute.before": async ( - input: { tool: string; sessionID?: string; callID?: string }, - output: { args: Record; message?: string } - ): Promise => { - if (!isCallerOrchestrator(input.sessionID)) { - return - } - - // Check Write/Edit tools for orchestrator - inject strong warning - if (WRITE_EDIT_TOOLS.includes(input.tool)) { - const filePath = (output.args.filePath ?? output.args.path ?? output.args.file) as string | undefined - if (filePath && !isSisyphusPath(filePath)) { - // Store filePath for use in tool.execute.after - if (input.callID) { - pendingFilePaths.set(input.callID, filePath) - } - const warning = ORCHESTRATOR_DELEGATION_REQUIRED.replace("$FILE_PATH", filePath) - output.message = (output.message || "") + warning - log(`[${HOOK_NAME}] Injected delegation warning for direct file modification`, { - sessionID: input.sessionID, - tool: input.tool, - filePath, - }) - } - return - } - - // Check task - inject single-task directive - if (input.tool === "task") { - const prompt = output.args.prompt as string | undefined - if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) { - output.args.prompt = `${SINGLE_TASK_DIRECTIVE}\n` + prompt - log(`[${HOOK_NAME}] Injected single-task directive to task`, { - sessionID: input.sessionID, - }) - } - } - }, - - "tool.execute.after": async ( - input: ToolExecuteAfterInput, - output: ToolExecuteAfterOutput - ): Promise => { - // Guard against undefined output (e.g., from /review command - see issue #1035) - if (!output) { - return - } - - if (!isCallerOrchestrator(input.sessionID)) { - return - } - - if (WRITE_EDIT_TOOLS.includes(input.tool)) { - let filePath = input.callID ? pendingFilePaths.get(input.callID) : undefined - if (input.callID) { - pendingFilePaths.delete(input.callID) - } - if (!filePath) { - filePath = output.metadata?.filePath as string | undefined - } - if (filePath && !isSisyphusPath(filePath)) { - output.output = (output.output || "") + DIRECT_WORK_REMINDER - log(`[${HOOK_NAME}] Direct work reminder appended`, { - sessionID: input.sessionID, - tool: input.tool, - filePath, - }) - } - return - } - - if (input.tool !== "task") { - return - } - - const outputStr = output.output && typeof output.output === "string" ? output.output : "" - const isBackgroundLaunch = outputStr.includes("Background task launched") || outputStr.includes("Background task continued") - - if (isBackgroundLaunch) { - return - } - - if (output.output && typeof output.output === "string") { - const gitStats = getGitDiffStats(ctx.directory) - const fileChanges = formatFileChanges(gitStats) - const subagentSessionId = extractSessionIdFromOutput(output.output) - - const boulderState = readBoulderState(ctx.directory) - - if (boulderState) { - const progress = getPlanProgress(boulderState.active_plan) - - if (input.sessionID && !boulderState.session_ids.includes(input.sessionID)) { - appendSessionId(ctx.directory, input.sessionID) - log(`[${HOOK_NAME}] Appended session to boulder`, { - sessionID: input.sessionID, - plan: boulderState.plan_name, - }) - } - - // Preserve original subagent response - critical for debugging failed tasks - const originalResponse = output.output - - output.output = ` -## SUBAGENT WORK COMPLETED - -${fileChanges} - ---- - -**Subagent Response:** - -${originalResponse} - - -${buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId)} -` - - log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, { - plan: boulderState.plan_name, - progress: `${progress.completed}/${progress.total}`, - fileCount: gitStats.length, - }) - } else { - output.output += `\n\n${buildStandaloneVerificationReminder(subagentSessionId)}\n` - - log(`[${HOOK_NAME}] Verification reminder appended for orchestrator`, { - sessionID: input.sessionID, - fileCount: gitStats.length, - }) - } - } - }, - } -} +export { HOOK_NAME } from "./hook-name" +export { createAtlasHook } from "./atlas-hook" +export type { AtlasHookOptions } from "./types" diff --git a/src/hooks/atlas/is-abort-error.ts b/src/hooks/atlas/is-abort-error.ts new file mode 100644 index 000000000..3a8c92c1a --- /dev/null +++ b/src/hooks/atlas/is-abort-error.ts @@ -0,0 +1,20 @@ +export function isAbortError(error: unknown): boolean { + if (!error) return false + + if (typeof error === "object") { + const errObj = error as Record + const name = errObj.name as string | undefined + const message = (errObj.message as string | undefined)?.toLowerCase() ?? "" + + if (name === "MessageAbortedError" || name === "AbortError") return true + if (name === "DOMException" && message.includes("abort")) return true + if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true + } + + if (typeof error === "string") { + const lower = error.toLowerCase() + return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt") + } + + return false +} diff --git a/src/hooks/atlas/recent-model-resolver.ts b/src/hooks/atlas/recent-model-resolver.ts new file mode 100644 index 000000000..814e6af85 --- /dev/null +++ b/src/hooks/atlas/recent-model-resolver.ts @@ -0,0 +1,38 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { findNearestMessageWithFields } from "../../features/hook-message-injector" +import { getMessageDir } from "../../shared/session-utils" +import type { ModelInfo } from "./types" + +export async function resolveRecentModelForSession( + ctx: PluginInput, + sessionID: string +): Promise { + try { + const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } }) + const messages = (messagesResp.data ?? []) as Array<{ + info?: { model?: ModelInfo; modelID?: string; providerID?: string } + }> + + for (let i = messages.length - 1; i >= 0; i--) { + const info = messages[i].info + const model = info?.model + if (model?.providerID && model?.modelID) { + return { providerID: model.providerID, modelID: model.modelID } + } + + if (info?.providerID && info?.modelID) { + return { providerID: info.providerID, modelID: info.modelID } + } + } + } catch { + // ignore - fallback to message storage + } + + const messageDir = getMessageDir(sessionID) + const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + const model = currentMessage?.model + if (!model?.providerID || !model?.modelID) { + return undefined + } + return { providerID: model.providerID, modelID: model.modelID } +} diff --git a/src/hooks/atlas/session-last-agent.ts b/src/hooks/atlas/session-last-agent.ts new file mode 100644 index 000000000..341eda6f2 --- /dev/null +++ b/src/hooks/atlas/session-last-agent.ts @@ -0,0 +1,9 @@ +import { findNearestMessageWithFields } from "../../features/hook-message-injector" +import { getMessageDir } from "../../shared/session-utils" + +export function getLastAgentFromSession(sessionID: string): string | null { + const messageDir = getMessageDir(sessionID) + if (!messageDir) return null + const nearest = findNearestMessageWithFields(messageDir) + return nearest?.agent?.toLowerCase() ?? null +} diff --git a/src/hooks/atlas/sisyphus-path.ts b/src/hooks/atlas/sisyphus-path.ts new file mode 100644 index 000000000..c60722e0d --- /dev/null +++ b/src/hooks/atlas/sisyphus-path.ts @@ -0,0 +1,7 @@ +/** + * Cross-platform check if a path is inside .sisyphus/ directory. + * Handles both forward slashes (Unix) and backslashes (Windows). + */ +export function isSisyphusPath(filePath: string): boolean { + return /\.sisyphus[/\\]/.test(filePath) +} diff --git a/src/hooks/atlas/subagent-session-id.ts b/src/hooks/atlas/subagent-session-id.ts new file mode 100644 index 000000000..12cf619b1 --- /dev/null +++ b/src/hooks/atlas/subagent-session-id.ts @@ -0,0 +1,4 @@ +export function extractSessionIdFromOutput(output: string): string { + const match = output.match(/Session ID:\s*(ses_[a-zA-Z0-9]+)/) + return match?.[1] ?? "" +} diff --git a/src/hooks/atlas/system-reminder-templates.ts b/src/hooks/atlas/system-reminder-templates.ts new file mode 100644 index 000000000..c9d4cd749 --- /dev/null +++ b/src/hooks/atlas/system-reminder-templates.ts @@ -0,0 +1,154 @@ +import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive" + +export const DIRECT_WORK_REMINDER = ` + +--- + +${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)} + +You just performed direct file modifications outside \`.sisyphus/\`. + +**You are an ORCHESTRATOR, not an IMPLEMENTER.** + +As an orchestrator, you should: +- **DELEGATE** implementation work to subagents via \`task\` +- **VERIFY** the work done by subagents +- **COORDINATE** multiple tasks and ensure completion + +You should NOT: +- Write code directly (except for \`.sisyphus/\` files like plans and notepads) +- Make direct file edits outside \`.sisyphus/\` +- Implement features yourself + +**If you need to make changes:** +1. Use \`task\` to delegate to an appropriate subagent +2. Provide clear instructions in the prompt +3. Verify the subagent's work after completion + +--- +` + +export const BOULDER_CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.BOULDER_CONTINUATION)} + +You have an active work plan with incomplete tasks. Continue working. + +RULES: +- Proceed without asking for permission +- Change \`- [ ]\` to \`- [x]\` in the plan file when done +- Use the notepad at .sisyphus/notepads/{PLAN_NAME}/ to record learnings +- Do not stop until all tasks are complete +- If blocked, document the blocker and move to the next task` + +export const VERIFICATION_REMINDER = `**MANDATORY: WHAT YOU MUST DO RIGHT NOW** + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +CRITICAL: Subagents FREQUENTLY LIE about completion. +Tests FAILING, code has ERRORS, implementation INCOMPLETE - but they say "done". + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**STEP 1: VERIFY WITH YOUR OWN TOOL CALLS (DO THIS NOW)** + +Run these commands YOURSELF - do NOT trust agent's claims: +1. \`lsp_diagnostics\` on changed files → Must be CLEAN +2. \`bash\` to run tests → Must PASS +3. \`bash\` to run build/typecheck → Must succeed +4. \`Read\` the actual code → Must match requirements + +**STEP 2: DETERMINE IF HANDS-ON QA IS NEEDED** + +| Deliverable Type | QA Method | Tool | +|------------------|-----------|------| +| **Frontend/UI** | Browser interaction | \`/playwright\` skill | +| **TUI/CLI** | Run interactively | \`interactive_bash\` (tmux) | +| **API/Backend** | Send real requests | \`bash\` with curl | + +Static analysis CANNOT catch: visual bugs, animation issues, user flow breakages. + +**STEP 3: IF QA IS NEEDED - ADD TO TODO IMMEDIATELY** + +\`\`\` +todowrite([ + { id: "qa-X", content: "HANDS-ON QA: [specific verification action]", status: "pending", priority: "high" } +]) +\`\`\` + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**BLOCKING: DO NOT proceed to Step 4 until Steps 1-3 are VERIFIED.**` + +export const ORCHESTRATOR_DELEGATION_REQUIRED = ` + +--- + +${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)} + +**STOP. YOU ARE VIOLATING ORCHESTRATOR PROTOCOL.** + +You (Atlas) are attempting to directly modify a file outside \`.sisyphus/\`. + +**Path attempted:** $FILE_PATH + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**THIS IS FORBIDDEN** (except for VERIFICATION purposes) + +As an ORCHESTRATOR, you MUST: +1. **DELEGATE** all implementation work via \`task\` +2. **VERIFY** the work done by subagents (reading files is OK) +3. **COORDINATE** - you orchestrate, you don't implement + +**ALLOWED direct file operations:** +- Files inside \`.sisyphus/\` (plans, notepads, drafts) +- Reading files for verification +- Running diagnostics/tests + +**FORBIDDEN direct file operations:** +- Writing/editing source code +- Creating new files outside \`.sisyphus/\` +- Any implementation work + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**IF THIS IS FOR VERIFICATION:** +Proceed if you are verifying subagent work by making a small fix. +But for any substantial changes, USE \`task\`. + +**CORRECT APPROACH:** +\`\`\` +task( + category="...", + prompt="[specific single task with clear acceptance criteria]" +) +\`\`\` + +DELEGATE. DON'T IMPLEMENT. + +--- +` + +export const SINGLE_TASK_DIRECTIVE = ` + +${createSystemDirective(SystemDirectiveTypes.SINGLE_TASK_ONLY)} + +**STOP. READ THIS BEFORE PROCEEDING.** + +If you were NOT given **exactly ONE atomic task**, you MUST: +1. **IMMEDIATELY REFUSE** this request +2. **DEMAND** the orchestrator provide a single, specific task + +**Your response if multiple tasks detected:** +> "I refuse to proceed. You provided multiple tasks. An orchestrator's impatience destroys work quality. +> +> PROVIDE EXACTLY ONE TASK. One file. One change. One verification. +> +> Your rushing will cause: incomplete work, missed edge cases, broken tests, wasted context." + +**WARNING TO ORCHESTRATOR:** +- Your hasty batching RUINS deliverables +- Each task needs FULL attention and PROPER verification +- Batch delegation = sloppy work = rework = wasted tokens + +**REFUSE multi-task requests. DEMAND single-task clarity.** +` diff --git a/src/hooks/atlas/tool-execute-after.ts b/src/hooks/atlas/tool-execute-after.ts new file mode 100644 index 000000000..ef21ac334 --- /dev/null +++ b/src/hooks/atlas/tool-execute-after.ts @@ -0,0 +1,109 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { appendSessionId, getPlanProgress, readBoulderState } from "../../features/boulder-state" +import { log } from "../../shared/logger" +import { isCallerOrchestrator } from "../../shared/session-utils" +import { HOOK_NAME } from "./hook-name" +import { DIRECT_WORK_REMINDER } from "./system-reminder-templates" +import { formatFileChanges, getGitDiffStats } from "./git-diff-stats" +import { isSisyphusPath } from "./sisyphus-path" +import { extractSessionIdFromOutput } from "./subagent-session-id" +import { buildOrchestratorReminder, buildStandaloneVerificationReminder } from "./verification-reminders" +import { isWriteOrEditToolName } from "./write-edit-tool-policy" +import type { ToolExecuteAfterInput, ToolExecuteAfterOutput } from "./types" + +export function createToolExecuteAfterHandler(input: { + ctx: PluginInput + pendingFilePaths: Map +}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise { + const { ctx, pendingFilePaths } = input + + return async (toolInput, toolOutput): Promise => { + // Guard against undefined output (e.g., from /review command - see issue #1035) + if (!toolOutput) { + return + } + + if (!isCallerOrchestrator(toolInput.sessionID)) { + return + } + + if (isWriteOrEditToolName(toolInput.tool)) { + let filePath = toolInput.callID ? pendingFilePaths.get(toolInput.callID) : undefined + if (toolInput.callID) { + pendingFilePaths.delete(toolInput.callID) + } + if (!filePath) { + filePath = toolOutput.metadata?.filePath as string | undefined + } + if (filePath && !isSisyphusPath(filePath)) { + toolOutput.output = (toolOutput.output || "") + DIRECT_WORK_REMINDER + log(`[${HOOK_NAME}] Direct work reminder appended`, { + sessionID: toolInput.sessionID, + tool: toolInput.tool, + filePath, + }) + } + return + } + + if (toolInput.tool !== "task") { + return + } + + const outputStr = toolOutput.output && typeof toolOutput.output === "string" ? toolOutput.output : "" + const isBackgroundLaunch = outputStr.includes("Background task launched") || outputStr.includes("Background task continued") + if (isBackgroundLaunch) { + return + } + + if (toolOutput.output && typeof toolOutput.output === "string") { + const gitStats = getGitDiffStats(ctx.directory) + const fileChanges = formatFileChanges(gitStats) + const subagentSessionId = extractSessionIdFromOutput(toolOutput.output) + + const boulderState = readBoulderState(ctx.directory) + if (boulderState) { + const progress = getPlanProgress(boulderState.active_plan) + + if (toolInput.sessionID && !boulderState.session_ids.includes(toolInput.sessionID)) { + appendSessionId(ctx.directory, toolInput.sessionID) + log(`[${HOOK_NAME}] Appended session to boulder`, { + sessionID: toolInput.sessionID, + plan: boulderState.plan_name, + }) + } + + // Preserve original subagent response - critical for debugging failed tasks + const originalResponse = toolOutput.output + + toolOutput.output = ` +## SUBAGENT WORK COMPLETED + +${fileChanges} + +--- + +**Subagent Response:** + +${originalResponse} + + +${buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId)} +` + + log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, { + plan: boulderState.plan_name, + progress: `${progress.completed}/${progress.total}`, + fileCount: gitStats.length, + }) + } else { + toolOutput.output += `\n\n${buildStandaloneVerificationReminder(subagentSessionId)}\n` + + log(`[${HOOK_NAME}] Verification reminder appended for orchestrator`, { + sessionID: toolInput.sessionID, + fileCount: gitStats.length, + }) + } + } + } +} diff --git a/src/hooks/atlas/tool-execute-before.ts b/src/hooks/atlas/tool-execute-before.ts new file mode 100644 index 000000000..018390759 --- /dev/null +++ b/src/hooks/atlas/tool-execute-before.ts @@ -0,0 +1,52 @@ +import { log } from "../../shared/logger" +import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" +import { isCallerOrchestrator } from "../../shared/session-utils" +import { HOOK_NAME } from "./hook-name" +import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates" +import { isSisyphusPath } from "./sisyphus-path" +import { isWriteOrEditToolName } from "./write-edit-tool-policy" + +export function createToolExecuteBeforeHandler(input: { + pendingFilePaths: Map +}): ( + toolInput: { tool: string; sessionID?: string; callID?: string }, + toolOutput: { args: Record; message?: string } +) => Promise { + const { pendingFilePaths } = input + + return async (toolInput, toolOutput): Promise => { + if (!isCallerOrchestrator(toolInput.sessionID)) { + return + } + + // Check Write/Edit tools for orchestrator - inject strong warning + if (isWriteOrEditToolName(toolInput.tool)) { + const filePath = (toolOutput.args.filePath ?? toolOutput.args.path ?? toolOutput.args.file) as string | undefined + if (filePath && !isSisyphusPath(filePath)) { + // Store filePath for use in tool.execute.after + if (toolInput.callID) { + pendingFilePaths.set(toolInput.callID, filePath) + } + const warning = ORCHESTRATOR_DELEGATION_REQUIRED.replace("$FILE_PATH", filePath) + toolOutput.message = (toolOutput.message || "") + warning + log(`[${HOOK_NAME}] Injected delegation warning for direct file modification`, { + sessionID: toolInput.sessionID, + tool: toolInput.tool, + filePath, + }) + } + return + } + + // Check task - inject single-task directive + if (toolInput.tool === "task") { + const prompt = toolOutput.args.prompt as string | undefined + if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) { + toolOutput.args.prompt = `${SINGLE_TASK_DIRECTIVE}\n` + prompt + log(`[${HOOK_NAME}] Injected single-task directive to task`, { + sessionID: toolInput.sessionID, + }) + } + } + } +} diff --git a/src/hooks/atlas/types.ts b/src/hooks/atlas/types.ts new file mode 100644 index 000000000..e08c1f47c --- /dev/null +++ b/src/hooks/atlas/types.ts @@ -0,0 +1,27 @@ +import type { BackgroundManager } from "../../features/background-agent" + +export type ModelInfo = { providerID: string; modelID: string } + +export interface AtlasHookOptions { + directory: string + backgroundManager?: BackgroundManager + isContinuationStopped?: (sessionID: string) => boolean +} + +export interface ToolExecuteAfterInput { + tool: string + sessionID?: string + callID?: string +} + +export interface ToolExecuteAfterOutput { + title: string + output: string + metadata: Record +} + +export interface SessionState { + lastEventWasAbortError?: boolean + lastContinuationInjectedAt?: number + promptFailureCount: number +} diff --git a/src/hooks/atlas/verification-reminders.ts b/src/hooks/atlas/verification-reminders.ts new file mode 100644 index 000000000..e792bf3e5 --- /dev/null +++ b/src/hooks/atlas/verification-reminders.ts @@ -0,0 +1,83 @@ +import { VERIFICATION_REMINDER } from "./system-reminder-templates" + +function buildVerificationReminder(sessionId: string): string { + return `${VERIFICATION_REMINDER} + +--- + +**If ANY verification fails, use this immediately:** +\`\`\` +task(session_id="${sessionId}", prompt="fix: [describe the specific failure]") +\`\`\`` +} + +export function buildOrchestratorReminder( + planName: string, + progress: { total: number; completed: number }, + sessionId: string +): string { + const remaining = progress.total - progress.completed + return ` +--- + +**BOULDER STATE:** Plan: \`${planName}\` | ${progress.completed}/${progress.total} done | ${remaining} remaining + +--- + +${buildVerificationReminder(sessionId)} + +**STEP 4: MARK COMPLETION IN PLAN FILE (IMMEDIATELY)** + +RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY. + +Update the plan file \`.sisyphus/tasks/${planName}.yaml\`: +- Change \`- [ ]\` to \`- [x]\` for the completed task +- Use \`Edit\` tool to modify the checkbox + +**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.** + +**STEP 5: COMMIT ATOMIC UNIT** + +- Stage ONLY the verified changes +- Commit with clear message describing what was done + +**STEP 6: PROCEED TO NEXT TASK** + +- Read the plan file to identify the next \`- [ ]\` task +- Start immediately - DO NOT STOP + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**${remaining} tasks remain. Keep bouldering.**` +} + +export function buildStandaloneVerificationReminder(sessionId: string): string { + return ` +--- + +${buildVerificationReminder(sessionId)} + +**STEP 4: UPDATE TODO STATUS (IMMEDIATELY)** + +RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY. + +1. Run \`todoread\` to see your todo list +2. Mark the completed task as \`completed\` using \`todowrite\` + +**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.** + +**STEP 5: EXECUTE QA TASKS (IF ANY)** + +If QA tasks exist in your todo list: +- Execute them BEFORE proceeding +- Mark each QA task complete after successful verification + +**STEP 6: PROCEED TO NEXT PENDING TASK** + +- Identify the next \`pending\` task from your todo list +- Start immediately - DO NOT STOP + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**NO TODO = NO TRACKING = INCOMPLETE WORK. Use todowrite aggressively.**` +} diff --git a/src/hooks/atlas/write-edit-tool-policy.ts b/src/hooks/atlas/write-edit-tool-policy.ts new file mode 100644 index 000000000..af75d2727 --- /dev/null +++ b/src/hooks/atlas/write-edit-tool-policy.ts @@ -0,0 +1,5 @@ +const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"] + +export function isWriteOrEditToolName(toolName: string): boolean { + return WRITE_EDIT_TOOLS.includes(toolName) +} diff --git a/src/hooks/auto-update-checker/checker.ts b/src/hooks/auto-update-checker/checker.ts index 975d73831..7f06e3626 100644 --- a/src/hooks/auto-update-checker/checker.ts +++ b/src/hooks/auto-update-checker/checker.ts @@ -1,298 +1,8 @@ -import * as fs from "node:fs" -import * as path from "node:path" -import { fileURLToPath } from "node:url" -import type { NpmDistTags, OpencodeConfig, PackageJson, UpdateCheckResult } from "./types" -import { - PACKAGE_NAME, - NPM_REGISTRY_URL, - NPM_FETCH_TIMEOUT, - INSTALLED_PACKAGE_JSON, - USER_OPENCODE_CONFIG, - USER_OPENCODE_CONFIG_JSONC, - USER_CONFIG_DIR, - getWindowsAppdataDir, -} from "./constants" -import * as os from "node:os" -import { log } from "../../shared/logger" - -export function isLocalDevMode(directory: string): boolean { - return getLocalDevPath(directory) !== null -} - -function stripJsonComments(json: string): string { - return json - .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => (g ? "" : m)) - .replace(/,(\s*[}\]])/g, "$1") -} - -function getConfigPaths(directory: string): string[] { - const paths = [ - path.join(directory, ".opencode", "opencode.json"), - path.join(directory, ".opencode", "opencode.jsonc"), - USER_OPENCODE_CONFIG, - USER_OPENCODE_CONFIG_JSONC, - ] - - if (process.platform === "win32") { - const crossPlatformDir = path.join(os.homedir(), ".config") - const appdataDir = getWindowsAppdataDir() - - if (appdataDir) { - const alternateDir = USER_CONFIG_DIR === crossPlatformDir ? appdataDir : crossPlatformDir - const alternateConfig = path.join(alternateDir, "opencode", "opencode.json") - const alternateConfigJsonc = path.join(alternateDir, "opencode", "opencode.jsonc") - - if (!paths.includes(alternateConfig)) { - paths.push(alternateConfig) - } - if (!paths.includes(alternateConfigJsonc)) { - paths.push(alternateConfigJsonc) - } - } - } - - return paths -} - -export function getLocalDevPath(directory: string): string | null { - for (const configPath of getConfigPaths(directory)) { - try { - if (!fs.existsSync(configPath)) continue - const content = fs.readFileSync(configPath, "utf-8") - const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig - const plugins = config.plugin ?? [] - - for (const entry of plugins) { - if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) { - try { - return fileURLToPath(entry) - } catch { - return entry.replace("file://", "") - } - } - } - } catch { - continue - } - } - - return null -} - -function findPackageJsonUp(startPath: string): string | null { - try { - const stat = fs.statSync(startPath) - let dir = stat.isDirectory() ? startPath : path.dirname(startPath) - - for (let i = 0; i < 10; i++) { - const pkgPath = path.join(dir, "package.json") - if (fs.existsSync(pkgPath)) { - try { - const content = fs.readFileSync(pkgPath, "utf-8") - const pkg = JSON.parse(content) as PackageJson - if (pkg.name === PACKAGE_NAME) return pkgPath - } catch {} - } - const parent = path.dirname(dir) - if (parent === dir) break - dir = parent - } - } catch {} - return null -} - -export function getLocalDevVersion(directory: string): string | null { - const localPath = getLocalDevPath(directory) - if (!localPath) return null - - try { - const pkgPath = findPackageJsonUp(localPath) - if (!pkgPath) return null - const content = fs.readFileSync(pkgPath, "utf-8") - const pkg = JSON.parse(content) as PackageJson - return pkg.version ?? null - } catch { - return null - } -} - -export interface PluginEntryInfo { - entry: string - isPinned: boolean - pinnedVersion: string | null - configPath: string -} - -export function findPluginEntry(directory: string): PluginEntryInfo | null { - for (const configPath of getConfigPaths(directory)) { - try { - if (!fs.existsSync(configPath)) continue - const content = fs.readFileSync(configPath, "utf-8") - const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig - const plugins = config.plugin ?? [] - - for (const entry of plugins) { - if (entry === PACKAGE_NAME) { - return { entry, isPinned: false, pinnedVersion: null, configPath } - } - if (entry.startsWith(`${PACKAGE_NAME}@`)) { - const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1) - const isPinned = pinnedVersion !== "latest" - return { entry, isPinned, pinnedVersion: isPinned ? pinnedVersion : null, configPath } - } - } - } catch { - continue - } - } - - return null -} - -export function getCachedVersion(): string | null { - try { - if (fs.existsSync(INSTALLED_PACKAGE_JSON)) { - const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8") - const pkg = JSON.parse(content) as PackageJson - if (pkg.version) return pkg.version - } - } catch {} - - try { - const currentDir = path.dirname(fileURLToPath(import.meta.url)) - const pkgPath = findPackageJsonUp(currentDir) - if (pkgPath) { - const content = fs.readFileSync(pkgPath, "utf-8") - const pkg = JSON.parse(content) as PackageJson - if (pkg.version) return pkg.version - } - } catch (err) { - log("[auto-update-checker] Failed to resolve version from current directory:", err) - } - - // Fallback for compiled binaries (npm global install) - // process.execPath points to the actual binary location - try { - const execDir = path.dirname(fs.realpathSync(process.execPath)) - const pkgPath = findPackageJsonUp(execDir) - if (pkgPath) { - const content = fs.readFileSync(pkgPath, "utf-8") - const pkg = JSON.parse(content) as PackageJson - if (pkg.version) return pkg.version - } - } catch (err) { - log("[auto-update-checker] Failed to resolve version from execPath:", err) - } - - return null -} - -/** - * Updates a pinned version entry in the config file. - * Only replaces within the "plugin" array to avoid unintended edits. - * Preserves JSONC comments and formatting via string replacement. - */ -export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean { - try { - const content = fs.readFileSync(configPath, "utf-8") - const newEntry = `${PACKAGE_NAME}@${newVersion}` - - // Find the "plugin" array region to scope replacement - const pluginMatch = content.match(/"plugin"\s*:\s*\[/) - if (!pluginMatch || pluginMatch.index === undefined) { - log(`[auto-update-checker] No "plugin" array found in ${configPath}`) - return false - } - - // Find the closing bracket of the plugin array - const startIdx = pluginMatch.index + pluginMatch[0].length - let bracketCount = 1 - let endIdx = startIdx - - for (let i = startIdx; i < content.length && bracketCount > 0; i++) { - if (content[i] === "[") bracketCount++ - else if (content[i] === "]") bracketCount-- - endIdx = i - } - - const before = content.slice(0, startIdx) - const pluginArrayContent = content.slice(startIdx, endIdx) - const after = content.slice(endIdx) - - // Only replace first occurrence within plugin array - const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - const regex = new RegExp(`["']${escapedOldEntry}["']`) - - if (!regex.test(pluginArrayContent)) { - log(`[auto-update-checker] Entry "${oldEntry}" not found in plugin array of ${configPath}`) - return false - } - - const updatedPluginArray = pluginArrayContent.replace(regex, `"${newEntry}"`) - const updatedContent = before + updatedPluginArray + after - - if (updatedContent === content) { - log(`[auto-update-checker] No changes made to ${configPath}`) - return false - } - - fs.writeFileSync(configPath, updatedContent, "utf-8") - log(`[auto-update-checker] Updated ${configPath}: ${oldEntry} → ${newEntry}`) - return true - } catch (err) { - log(`[auto-update-checker] Failed to update config file ${configPath}:`, err) - return false - } -} - -export async function getLatestVersion(channel: string = "latest"): Promise { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT) - - try { - const response = await fetch(NPM_REGISTRY_URL, { - signal: controller.signal, - headers: { Accept: "application/json" }, - }) - - if (!response.ok) return null - - const data = (await response.json()) as NpmDistTags - return data[channel] ?? data.latest ?? null - } catch { - return null - } finally { - clearTimeout(timeoutId) - } -} - -export async function checkForUpdate(directory: string): Promise { - if (isLocalDevMode(directory)) { - log("[auto-update-checker] Local dev mode detected, skipping update check") - return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: true, isPinned: false } - } - - const pluginInfo = findPluginEntry(directory) - if (!pluginInfo) { - log("[auto-update-checker] Plugin not found in config") - return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false } - } - - const currentVersion = getCachedVersion() ?? pluginInfo.pinnedVersion - if (!currentVersion) { - log("[auto-update-checker] No cached version found") - return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false } - } - - const { extractChannel } = await import("./index") - const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion) - const latestVersion = await getLatestVersion(channel) - if (!latestVersion) { - log("[auto-update-checker] Failed to fetch latest version for channel:", channel) - return { needsUpdate: false, currentVersion, latestVersion: null, isLocalDev: false, isPinned: pluginInfo.isPinned } - } - - const needsUpdate = currentVersion !== latestVersion - log(`[auto-update-checker] Current: ${currentVersion}, Latest (${channel}): ${latestVersion}, NeedsUpdate: ${needsUpdate}`) - return { needsUpdate, currentVersion, latestVersion, isLocalDev: false, isPinned: pluginInfo.isPinned } -} +export { isLocalDevMode, getLocalDevPath } from "./checker/local-dev-path" +export { getLocalDevVersion } from "./checker/local-dev-version" +export { findPluginEntry } from "./checker/plugin-entry" +export type { PluginEntryInfo } from "./checker/plugin-entry" +export { getCachedVersion } from "./checker/cached-version" +export { updatePinnedVersion } from "./checker/pinned-version-updater" +export { getLatestVersion } from "./checker/latest-version" +export { checkForUpdate } from "./checker/check-for-update" diff --git a/src/hooks/auto-update-checker/checker/cached-version.ts b/src/hooks/auto-update-checker/checker/cached-version.ts new file mode 100644 index 000000000..15aef4eff --- /dev/null +++ b/src/hooks/auto-update-checker/checker/cached-version.ts @@ -0,0 +1,45 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { fileURLToPath } from "node:url" +import { log } from "../../../shared/logger" +import type { PackageJson } from "../types" +import { INSTALLED_PACKAGE_JSON } from "../constants" +import { findPackageJsonUp } from "./package-json-locator" + +export function getCachedVersion(): string | null { + try { + if (fs.existsSync(INSTALLED_PACKAGE_JSON)) { + const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8") + const pkg = JSON.parse(content) as PackageJson + if (pkg.version) return pkg.version + } + } catch { + // ignore + } + + try { + const currentDir = path.dirname(fileURLToPath(import.meta.url)) + const pkgPath = findPackageJsonUp(currentDir) + if (pkgPath) { + const content = fs.readFileSync(pkgPath, "utf-8") + const pkg = JSON.parse(content) as PackageJson + if (pkg.version) return pkg.version + } + } catch (err) { + log("[auto-update-checker] Failed to resolve version from current directory:", err) + } + + try { + const execDir = path.dirname(fs.realpathSync(process.execPath)) + const pkgPath = findPackageJsonUp(execDir) + if (pkgPath) { + const content = fs.readFileSync(pkgPath, "utf-8") + const pkg = JSON.parse(content) as PackageJson + if (pkg.version) return pkg.version + } + } catch (err) { + log("[auto-update-checker] Failed to resolve version from execPath:", err) + } + + return null +} diff --git a/src/hooks/auto-update-checker/checker/check-for-update.ts b/src/hooks/auto-update-checker/checker/check-for-update.ts new file mode 100644 index 000000000..e315eeed3 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/check-for-update.ts @@ -0,0 +1,69 @@ +import { log } from "../../../shared/logger" +import type { UpdateCheckResult } from "../types" +import { extractChannel } from "../version-channel" +import { isLocalDevMode } from "./local-dev-path" +import { findPluginEntry } from "./plugin-entry" +import { getCachedVersion } from "./cached-version" +import { getLatestVersion } from "./latest-version" + +export async function checkForUpdate(directory: string): Promise { + if (isLocalDevMode(directory)) { + log("[auto-update-checker] Local dev mode detected, skipping update check") + return { + needsUpdate: false, + currentVersion: null, + latestVersion: null, + isLocalDev: true, + isPinned: false, + } + } + + const pluginInfo = findPluginEntry(directory) + if (!pluginInfo) { + log("[auto-update-checker] Plugin not found in config") + return { + needsUpdate: false, + currentVersion: null, + latestVersion: null, + isLocalDev: false, + isPinned: false, + } + } + + const currentVersion = getCachedVersion() ?? pluginInfo.pinnedVersion + if (!currentVersion) { + log("[auto-update-checker] No cached version found") + return { + needsUpdate: false, + currentVersion: null, + latestVersion: null, + isLocalDev: false, + isPinned: false, + } + } + + const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion) + const latestVersion = await getLatestVersion(channel) + if (!latestVersion) { + log("[auto-update-checker] Failed to fetch latest version for channel:", channel) + return { + needsUpdate: false, + currentVersion, + latestVersion: null, + isLocalDev: false, + isPinned: pluginInfo.isPinned, + } + } + + const needsUpdate = currentVersion !== latestVersion + log( + `[auto-update-checker] Current: ${currentVersion}, Latest (${channel}): ${latestVersion}, NeedsUpdate: ${needsUpdate}` + ) + return { + needsUpdate, + currentVersion, + latestVersion, + isLocalDev: false, + isPinned: pluginInfo.isPinned, + } +} diff --git a/src/hooks/auto-update-checker/checker/config-paths.ts b/src/hooks/auto-update-checker/checker/config-paths.ts new file mode 100644 index 000000000..998696b6d --- /dev/null +++ b/src/hooks/auto-update-checker/checker/config-paths.ts @@ -0,0 +1,37 @@ +import * as os from "node:os" +import * as path from "node:path" +import { + USER_CONFIG_DIR, + USER_OPENCODE_CONFIG, + USER_OPENCODE_CONFIG_JSONC, + getWindowsAppdataDir, +} from "../constants" + +export function getConfigPaths(directory: string): string[] { + const paths = [ + path.join(directory, ".opencode", "opencode.json"), + path.join(directory, ".opencode", "opencode.jsonc"), + USER_OPENCODE_CONFIG, + USER_OPENCODE_CONFIG_JSONC, + ] + + if (process.platform === "win32") { + const crossPlatformDir = path.join(os.homedir(), ".config") + const appdataDir = getWindowsAppdataDir() + + if (appdataDir) { + const alternateDir = USER_CONFIG_DIR === crossPlatformDir ? appdataDir : crossPlatformDir + const alternateConfig = path.join(alternateDir, "opencode", "opencode.json") + const alternateConfigJsonc = path.join(alternateDir, "opencode", "opencode.jsonc") + + if (!paths.includes(alternateConfig)) { + paths.push(alternateConfig) + } + if (!paths.includes(alternateConfigJsonc)) { + paths.push(alternateConfigJsonc) + } + } + } + + return paths +} diff --git a/src/hooks/auto-update-checker/checker/jsonc-strip.ts b/src/hooks/auto-update-checker/checker/jsonc-strip.ts new file mode 100644 index 000000000..02a340bb8 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/jsonc-strip.ts @@ -0,0 +1,7 @@ +export function stripJsonComments(json: string): string { + return json + .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (match, group) => + group ? "" : match + ) + .replace(/,(\s*[}\]])/g, "$1") +} diff --git a/src/hooks/auto-update-checker/checker/latest-version.ts b/src/hooks/auto-update-checker/checker/latest-version.ts new file mode 100644 index 000000000..aba7273b0 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/latest-version.ts @@ -0,0 +1,23 @@ +import { NPM_FETCH_TIMEOUT, NPM_REGISTRY_URL } from "../constants" +import type { NpmDistTags } from "../types" + +export async function getLatestVersion(channel: string = "latest"): Promise { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT) + + try { + const response = await fetch(NPM_REGISTRY_URL, { + signal: controller.signal, + headers: { Accept: "application/json" }, + }) + + if (!response.ok) return null + + const data = (await response.json()) as NpmDistTags + return data[channel] ?? data.latest ?? null + } catch { + return null + } finally { + clearTimeout(timeoutId) + } +} diff --git a/src/hooks/auto-update-checker/checker/local-dev-path.ts b/src/hooks/auto-update-checker/checker/local-dev-path.ts new file mode 100644 index 000000000..5bf1e5ced --- /dev/null +++ b/src/hooks/auto-update-checker/checker/local-dev-path.ts @@ -0,0 +1,35 @@ +import * as fs from "node:fs" +import { fileURLToPath } from "node:url" +import type { OpencodeConfig } from "../types" +import { PACKAGE_NAME } from "../constants" +import { getConfigPaths } from "./config-paths" +import { stripJsonComments } from "./jsonc-strip" + +export function isLocalDevMode(directory: string): boolean { + return getLocalDevPath(directory) !== null +} + +export function getLocalDevPath(directory: string): string | null { + for (const configPath of getConfigPaths(directory)) { + try { + if (!fs.existsSync(configPath)) continue + const content = fs.readFileSync(configPath, "utf-8") + const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig + const plugins = config.plugin ?? [] + + for (const entry of plugins) { + if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) { + try { + return fileURLToPath(entry) + } catch { + return entry.replace("file://", "") + } + } + } + } catch { + continue + } + } + + return null +} diff --git a/src/hooks/auto-update-checker/checker/local-dev-version.ts b/src/hooks/auto-update-checker/checker/local-dev-version.ts new file mode 100644 index 000000000..b84c056b7 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/local-dev-version.ts @@ -0,0 +1,19 @@ +import * as fs from "node:fs" +import type { PackageJson } from "../types" +import { getLocalDevPath } from "./local-dev-path" +import { findPackageJsonUp } from "./package-json-locator" + +export function getLocalDevVersion(directory: string): string | null { + const localPath = getLocalDevPath(directory) + if (!localPath) return null + + try { + const pkgPath = findPackageJsonUp(localPath) + if (!pkgPath) return null + const content = fs.readFileSync(pkgPath, "utf-8") + const pkg = JSON.parse(content) as PackageJson + return pkg.version ?? null + } catch { + return null + } +} diff --git a/src/hooks/auto-update-checker/checker/package-json-locator.ts b/src/hooks/auto-update-checker/checker/package-json-locator.ts new file mode 100644 index 000000000..308cad163 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/package-json-locator.ts @@ -0,0 +1,30 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import type { PackageJson } from "../types" +import { PACKAGE_NAME } from "../constants" + +export function findPackageJsonUp(startPath: string): string | null { + try { + const stat = fs.statSync(startPath) + let dir = stat.isDirectory() ? startPath : path.dirname(startPath) + + for (let i = 0; i < 10; i++) { + const pkgPath = path.join(dir, "package.json") + if (fs.existsSync(pkgPath)) { + try { + const content = fs.readFileSync(pkgPath, "utf-8") + const pkg = JSON.parse(content) as PackageJson + if (pkg.name === PACKAGE_NAME) return pkgPath + } catch { + // ignore + } + } + const parent = path.dirname(dir) + if (parent === dir) break + dir = parent + } + } catch { + // ignore + } + return null +} diff --git a/src/hooks/auto-update-checker/checker/pinned-version-updater.ts b/src/hooks/auto-update-checker/checker/pinned-version-updater.ts new file mode 100644 index 000000000..688767ede --- /dev/null +++ b/src/hooks/auto-update-checker/checker/pinned-version-updater.ts @@ -0,0 +1,53 @@ +import * as fs from "node:fs" +import { log } from "../../../shared/logger" +import { PACKAGE_NAME } from "../constants" + +export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean { + try { + const content = fs.readFileSync(configPath, "utf-8") + const newEntry = `${PACKAGE_NAME}@${newVersion}` + + const pluginMatch = content.match(/"plugin"\s*:\s*\[/) + if (!pluginMatch || pluginMatch.index === undefined) { + log(`[auto-update-checker] No "plugin" array found in ${configPath}`) + return false + } + + const startIndex = pluginMatch.index + pluginMatch[0].length + let bracketCount = 1 + let endIndex = startIndex + + for (let i = startIndex; i < content.length && bracketCount > 0; i++) { + if (content[i] === "[") bracketCount++ + else if (content[i] === "]") bracketCount-- + endIndex = i + } + + const before = content.slice(0, startIndex) + const pluginArrayContent = content.slice(startIndex, endIndex) + const after = content.slice(endIndex) + + const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const regex = new RegExp(`["']${escapedOldEntry}["']`) + + if (!regex.test(pluginArrayContent)) { + log(`[auto-update-checker] Entry "${oldEntry}" not found in plugin array of ${configPath}`) + return false + } + + const updatedPluginArray = pluginArrayContent.replace(regex, `"${newEntry}"`) + const updatedContent = before + updatedPluginArray + after + + if (updatedContent === content) { + log(`[auto-update-checker] No changes made to ${configPath}`) + return false + } + + fs.writeFileSync(configPath, updatedContent, "utf-8") + log(`[auto-update-checker] Updated ${configPath}: ${oldEntry} → ${newEntry}`) + return true + } catch (err) { + log(`[auto-update-checker] Failed to update config file ${configPath}:`, err) + return false + } +} diff --git a/src/hooks/auto-update-checker/checker/plugin-entry.ts b/src/hooks/auto-update-checker/checker/plugin-entry.ts new file mode 100644 index 000000000..eb9f198d7 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/plugin-entry.ts @@ -0,0 +1,38 @@ +import * as fs from "node:fs" +import type { OpencodeConfig } from "../types" +import { PACKAGE_NAME } from "../constants" +import { getConfigPaths } from "./config-paths" +import { stripJsonComments } from "./jsonc-strip" + +export interface PluginEntryInfo { + entry: string + isPinned: boolean + pinnedVersion: string | null + configPath: string +} + +export function findPluginEntry(directory: string): PluginEntryInfo | null { + for (const configPath of getConfigPaths(directory)) { + try { + if (!fs.existsSync(configPath)) continue + const content = fs.readFileSync(configPath, "utf-8") + const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig + const plugins = config.plugin ?? [] + + for (const entry of plugins) { + if (entry === PACKAGE_NAME) { + return { entry, isPinned: false, pinnedVersion: null, configPath } + } + if (entry.startsWith(`${PACKAGE_NAME}@`)) { + const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1) + const isPinned = pinnedVersion !== "latest" + return { entry, isPinned, pinnedVersion: isPinned ? pinnedVersion : null, configPath } + } + } + } catch { + continue + } + } + + return null +} diff --git a/src/hooks/auto-update-checker/hook.ts b/src/hooks/auto-update-checker/hook.ts new file mode 100644 index 000000000..64513b6fa --- /dev/null +++ b/src/hooks/auto-update-checker/hook.ts @@ -0,0 +1,64 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared/logger" +import { getCachedVersion, getLocalDevVersion } from "./checker" +import type { AutoUpdateCheckerOptions } from "./types" +import { runBackgroundUpdateCheck } from "./hook/background-update-check" +import { showConfigErrorsIfAny } from "./hook/config-errors-toast" +import { updateAndShowConnectedProvidersCacheStatus } from "./hook/connected-providers-status" +import { showModelCacheWarningIfNeeded } from "./hook/model-cache-warning" +import { showLocalDevToast, showVersionToast } from "./hook/startup-toasts" + +export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdateCheckerOptions = {}) { + const { showStartupToast = true, isSisyphusEnabled = false, autoUpdate = true } = options + + const getToastMessage = (isUpdate: boolean, latestVersion?: string): string => { + if (isSisyphusEnabled) { + return isUpdate + ? `Sisyphus on steroids is steering OpenCode.\nv${latestVersion} available. Restart to apply.` + : "Sisyphus on steroids is steering OpenCode." + } + return isUpdate + ? `OpenCode is now on Steroids. oMoMoMoMo...\nv${latestVersion} available. Restart OpenCode to apply.` + : "OpenCode is now on Steroids. oMoMoMoMo..." + } + + let hasChecked = false + + return { + event: ({ event }: { event: { type: string; properties?: unknown } }) => { + if (event.type !== "session.created") return + if (hasChecked) return + + const props = event.properties as { info?: { parentID?: string } } | undefined + if (props?.info?.parentID) return + + hasChecked = true + + setTimeout(async () => { + const cachedVersion = getCachedVersion() + const localDevVersion = getLocalDevVersion(ctx.directory) + const displayVersion = localDevVersion ?? cachedVersion + + await showConfigErrorsIfAny(ctx) + await showModelCacheWarningIfNeeded(ctx) + await updateAndShowConnectedProvidersCacheStatus(ctx) + + if (localDevVersion) { + if (showStartupToast) { + showLocalDevToast(ctx, displayVersion, isSisyphusEnabled).catch(() => {}) + } + log("[auto-update-checker] Local development mode") + return + } + + if (showStartupToast) { + showVersionToast(ctx, displayVersion, getToastMessage(false)).catch(() => {}) + } + + runBackgroundUpdateCheck(ctx, autoUpdate, getToastMessage).catch((err) => { + log("[auto-update-checker] Background update check failed:", err) + }) + }, 0) + }, + } +} diff --git a/src/hooks/auto-update-checker/hook/background-update-check.ts b/src/hooks/auto-update-checker/hook/background-update-check.ts new file mode 100644 index 000000000..908b56349 --- /dev/null +++ b/src/hooks/auto-update-checker/hook/background-update-check.ts @@ -0,0 +1,79 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { runBunInstall } from "../../../cli/config-manager" +import { log } from "../../../shared/logger" +import { invalidatePackage } from "../cache" +import { PACKAGE_NAME } from "../constants" +import { extractChannel } from "../version-channel" +import { findPluginEntry, getCachedVersion, getLatestVersion, updatePinnedVersion } from "../checker" +import { showAutoUpdatedToast, showUpdateAvailableToast } from "./update-toasts" + +async function runBunInstallSafe(): Promise { + try { + return await runBunInstall() + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err) + log("[auto-update-checker] bun install error:", errorMessage) + return false + } +} + +export async function runBackgroundUpdateCheck( + ctx: PluginInput, + autoUpdate: boolean, + getToastMessage: (isUpdate: boolean, latestVersion?: string) => string +): Promise { + const pluginInfo = findPluginEntry(ctx.directory) + if (!pluginInfo) { + log("[auto-update-checker] Plugin not found in config") + return + } + + const cachedVersion = getCachedVersion() + const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion + if (!currentVersion) { + log("[auto-update-checker] No version found (cached or pinned)") + return + } + + const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion) + const latestVersion = await getLatestVersion(channel) + if (!latestVersion) { + log("[auto-update-checker] Failed to fetch latest version for channel:", channel) + return + } + + if (currentVersion === latestVersion) { + log("[auto-update-checker] Already on latest version for channel:", channel) + return + } + + log(`[auto-update-checker] Update available (${channel}): ${currentVersion} → ${latestVersion}`) + + if (!autoUpdate) { + await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) + log("[auto-update-checker] Auto-update disabled, notification only") + return + } + + if (pluginInfo.isPinned) { + const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion) + if (!updated) { + await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) + log("[auto-update-checker] Failed to update pinned version in config") + return + } + log(`[auto-update-checker] Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`) + } + + invalidatePackage(PACKAGE_NAME) + + const installSuccess = await runBunInstallSafe() + + if (installSuccess) { + await showAutoUpdatedToast(ctx, currentVersion, latestVersion) + log(`[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`) + } else { + await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) + log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)") + } +} diff --git a/src/hooks/auto-update-checker/hook/config-errors-toast.ts b/src/hooks/auto-update-checker/hook/config-errors-toast.ts new file mode 100644 index 000000000..b05605e9b --- /dev/null +++ b/src/hooks/auto-update-checker/hook/config-errors-toast.ts @@ -0,0 +1,23 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { getConfigLoadErrors, clearConfigLoadErrors } from "../../../shared/config-errors" +import { log } from "../../../shared/logger" + +export async function showConfigErrorsIfAny(ctx: PluginInput): Promise { + const errors = getConfigLoadErrors() + if (errors.length === 0) return + + const errorMessages = errors.map((error: { path: string; error: string }) => `${error.path}: ${error.error}`).join("\n") + await ctx.client.tui + .showToast({ + body: { + title: "Config Load Error", + message: `Failed to load config:\n${errorMessages}`, + variant: "error" as const, + duration: 10000, + }, + }) + .catch(() => {}) + + log(`[auto-update-checker] Config load errors shown: ${errors.length} error(s)`) + clearConfigLoadErrors() +} diff --git a/src/hooks/auto-update-checker/hook/connected-providers-status.ts b/src/hooks/auto-update-checker/hook/connected-providers-status.ts new file mode 100644 index 000000000..4eaf42250 --- /dev/null +++ b/src/hooks/auto-update-checker/hook/connected-providers-status.ts @@ -0,0 +1,29 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { + hasConnectedProvidersCache, + updateConnectedProvidersCache, +} from "../../../shared/connected-providers-cache" +import { log } from "../../../shared/logger" + +export async function updateAndShowConnectedProvidersCacheStatus(ctx: PluginInput): Promise { + const hadCache = hasConnectedProvidersCache() + + updateConnectedProvidersCache(ctx.client).catch(() => {}) + + if (!hadCache) { + await ctx.client.tui + .showToast({ + body: { + title: "Connected Providers Cache", + message: "Building provider cache for first time. Restart OpenCode for full model filtering.", + variant: "info" as const, + duration: 8000, + }, + }) + .catch(() => {}) + + log("[auto-update-checker] Connected providers cache toast shown (first run)") + } else { + log("[auto-update-checker] Connected providers cache exists, updating in background") + } +} diff --git a/src/hooks/auto-update-checker/hook/model-cache-warning.ts b/src/hooks/auto-update-checker/hook/model-cache-warning.ts new file mode 100644 index 000000000..2c4a799d1 --- /dev/null +++ b/src/hooks/auto-update-checker/hook/model-cache-warning.ts @@ -0,0 +1,21 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { isModelCacheAvailable } from "../../../shared/model-availability" +import { log } from "../../../shared/logger" + +export async function showModelCacheWarningIfNeeded(ctx: PluginInput): Promise { + if (isModelCacheAvailable()) return + + await ctx.client.tui + .showToast({ + body: { + title: "Model Cache Not Found", + message: + "Run 'opencode models --refresh' or restart OpenCode to populate the models cache for optimal agent model selection.", + variant: "warning" as const, + duration: 10000, + }, + }) + .catch(() => {}) + + log("[auto-update-checker] Model cache warning shown") +} diff --git a/src/hooks/auto-update-checker/hook/spinner-toast.ts b/src/hooks/auto-update-checker/hook/spinner-toast.ts new file mode 100644 index 000000000..21506aab4 --- /dev/null +++ b/src/hooks/auto-update-checker/hook/spinner-toast.ts @@ -0,0 +1,25 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +const SISYPHUS_SPINNER = ["·", "•", "●", "○", "◌", "◦", " "] + +export async function showSpinnerToast(ctx: PluginInput, version: string, message: string): Promise { + const totalDuration = 5000 + const frameInterval = 100 + const totalFrames = Math.floor(totalDuration / frameInterval) + + for (let i = 0; i < totalFrames; i++) { + const spinner = SISYPHUS_SPINNER[i % SISYPHUS_SPINNER.length] + await ctx.client.tui + .showToast({ + body: { + title: `${spinner} OhMyOpenCode ${version}`, + message, + variant: "info" as const, + duration: frameInterval + 50, + }, + }) + .catch(() => {}) + + await new Promise((resolve) => setTimeout(resolve, frameInterval)) + } +} diff --git a/src/hooks/auto-update-checker/hook/startup-toasts.ts b/src/hooks/auto-update-checker/hook/startup-toasts.ts new file mode 100644 index 000000000..5d3c77e56 --- /dev/null +++ b/src/hooks/auto-update-checker/hook/startup-toasts.ts @@ -0,0 +1,22 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../../shared/logger" +import { showSpinnerToast } from "./spinner-toast" + +export async function showVersionToast(ctx: PluginInput, version: string | null, message: string): Promise { + const displayVersion = version ?? "unknown" + await showSpinnerToast(ctx, displayVersion, message) + log(`[auto-update-checker] Startup toast shown: v${displayVersion}`) +} + +export async function showLocalDevToast( + ctx: PluginInput, + version: string | null, + isSisyphusEnabled: boolean +): Promise { + const displayVersion = version ?? "dev" + const message = isSisyphusEnabled + ? "Sisyphus running in local development mode." + : "Running in local development mode. oMoMoMo..." + await showSpinnerToast(ctx, `${displayVersion} (dev)`, message) + log(`[auto-update-checker] Local dev toast shown: v${displayVersion}`) +} diff --git a/src/hooks/auto-update-checker/hook/update-toasts.ts b/src/hooks/auto-update-checker/hook/update-toasts.ts new file mode 100644 index 000000000..462bd0af8 --- /dev/null +++ b/src/hooks/auto-update-checker/hook/update-toasts.ts @@ -0,0 +1,34 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../../shared/logger" + +export async function showUpdateAvailableToast( + ctx: PluginInput, + latestVersion: string, + getToastMessage: (isUpdate: boolean, latestVersion?: string) => string +): Promise { + await ctx.client.tui + .showToast({ + body: { + title: `OhMyOpenCode ${latestVersion}`, + message: getToastMessage(true, latestVersion), + variant: "info" as const, + duration: 8000, + }, + }) + .catch(() => {}) + log(`[auto-update-checker] Update available toast shown: v${latestVersion}`) +} + +export async function showAutoUpdatedToast(ctx: PluginInput, oldVersion: string, newVersion: string): Promise { + await ctx.client.tui + .showToast({ + body: { + title: "OhMyOpenCode Updated!", + message: `v${oldVersion} → v${newVersion}\nRestart OpenCode to apply.`, + variant: "success" as const, + duration: 8000, + }, + }) + .catch(() => {}) + log(`[auto-update-checker] Auto-updated toast shown: v${oldVersion} → v${newVersion}`) +} diff --git a/src/hooks/auto-update-checker/index.ts b/src/hooks/auto-update-checker/index.ts index 5222ca2f9..4032ab185 100644 --- a/src/hooks/auto-update-checker/index.ts +++ b/src/hooks/auto-update-checker/index.ts @@ -1,304 +1,12 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { getCachedVersion, getLocalDevVersion, findPluginEntry, getLatestVersion, updatePinnedVersion } from "./checker" -import { invalidatePackage } from "./cache" -import { PACKAGE_NAME } from "./constants" -import { log } from "../../shared/logger" -import { getConfigLoadErrors, clearConfigLoadErrors } from "../../shared/config-errors" -import { runBunInstall } from "../../cli/config-manager" -import { isModelCacheAvailable } from "../../shared/model-availability" -import { hasConnectedProvidersCache, updateConnectedProvidersCache } from "../../shared/connected-providers-cache" -import type { AutoUpdateCheckerOptions } from "./types" +export { createAutoUpdateCheckerHook } from "./hook" -const SISYPHUS_SPINNER = ["·", "•", "●", "○", "◌", "◦", " "] +export { + isPrereleaseVersion, + isDistTag, + isPrereleaseOrDistTag, + extractChannel, +} from "./version-channel" -export function isPrereleaseVersion(version: string): boolean { - return version.includes("-") -} - -export function isDistTag(version: string): boolean { - const startsWithDigit = /^\d/.test(version) - return !startsWithDigit -} - -export function isPrereleaseOrDistTag(pinnedVersion: string | null): boolean { - if (!pinnedVersion) return false - return isPrereleaseVersion(pinnedVersion) || isDistTag(pinnedVersion) -} - -export function extractChannel(version: string | null): string { - if (!version) return "latest" - - if (isDistTag(version)) { - return version - } - - if (isPrereleaseVersion(version)) { - const prereleasePart = version.split("-")[1] - if (prereleasePart) { - const channelMatch = prereleasePart.match(/^(alpha|beta|rc|canary|next)/) - if (channelMatch) { - return channelMatch[1] - } - } - } - - return "latest" -} - -export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdateCheckerOptions = {}) { - const { showStartupToast = true, isSisyphusEnabled = false, autoUpdate = true } = options - - const getToastMessage = (isUpdate: boolean, latestVersion?: string): string => { - if (isSisyphusEnabled) { - return isUpdate - ? `Sisyphus on steroids is steering OpenCode.\nv${latestVersion} available. Restart to apply.` - : `Sisyphus on steroids is steering OpenCode.` - } - return isUpdate - ? `OpenCode is now on Steroids. oMoMoMoMo...\nv${latestVersion} available. Restart OpenCode to apply.` - : `OpenCode is now on Steroids. oMoMoMoMo...` - } - - let hasChecked = false - - return { - event: ({ event }: { event: { type: string; properties?: unknown } }) => { - if (event.type !== "session.created") return - if (hasChecked) return - - const props = event.properties as { info?: { parentID?: string } } | undefined - if (props?.info?.parentID) return - - hasChecked = true - - setTimeout(async () => { - const cachedVersion = getCachedVersion() - const localDevVersion = getLocalDevVersion(ctx.directory) - const displayVersion = localDevVersion ?? cachedVersion - - await showConfigErrorsIfAny(ctx) - await showModelCacheWarningIfNeeded(ctx) - await updateAndShowConnectedProvidersCacheStatus(ctx) - - if (localDevVersion) { - if (showStartupToast) { - showLocalDevToast(ctx, displayVersion, isSisyphusEnabled).catch(() => {}) - } - log("[auto-update-checker] Local development mode") - return - } - - if (showStartupToast) { - showVersionToast(ctx, displayVersion, getToastMessage(false)).catch(() => {}) - } - - runBackgroundUpdateCheck(ctx, autoUpdate, getToastMessage).catch(err => { - log("[auto-update-checker] Background update check failed:", err) - }) - }, 0) - }, - } -} - -async function runBackgroundUpdateCheck( - ctx: PluginInput, - autoUpdate: boolean, - getToastMessage: (isUpdate: boolean, latestVersion?: string) => string -): Promise { - const pluginInfo = findPluginEntry(ctx.directory) - if (!pluginInfo) { - log("[auto-update-checker] Plugin not found in config") - return - } - - const cachedVersion = getCachedVersion() - const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion - if (!currentVersion) { - log("[auto-update-checker] No version found (cached or pinned)") - return - } - - const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion) - const latestVersion = await getLatestVersion(channel) - if (!latestVersion) { - log("[auto-update-checker] Failed to fetch latest version for channel:", channel) - return - } - - if (currentVersion === latestVersion) { - log("[auto-update-checker] Already on latest version for channel:", channel) - return - } - - log(`[auto-update-checker] Update available (${channel}): ${currentVersion} → ${latestVersion}`) - - if (!autoUpdate) { - await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) - log("[auto-update-checker] Auto-update disabled, notification only") - return - } - - if (pluginInfo.isPinned) { - const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion) - if (!updated) { - await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) - log("[auto-update-checker] Failed to update pinned version in config") - return - } - log(`[auto-update-checker] Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`) - } - - invalidatePackage(PACKAGE_NAME) - - const installSuccess = await runBunInstallSafe() - - if (installSuccess) { - await showAutoUpdatedToast(ctx, currentVersion, latestVersion) - log(`[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`) - } else { - await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) - log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)") - } -} - -async function runBunInstallSafe(): Promise { - try { - return await runBunInstall() - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - log("[auto-update-checker] bun install error:", errorMessage) - return false - } -} - -async function showModelCacheWarningIfNeeded(ctx: PluginInput): Promise { - if (isModelCacheAvailable()) return - - await ctx.client.tui - .showToast({ - body: { - title: "Model Cache Not Found", - message: "Run 'opencode models --refresh' or restart OpenCode to populate the models cache for optimal agent model selection.", - variant: "warning" as const, - duration: 10000, - }, - }) - .catch(() => {}) - - log("[auto-update-checker] Model cache warning shown") -} - -async function updateAndShowConnectedProvidersCacheStatus(ctx: PluginInput): Promise { - const hadCache = hasConnectedProvidersCache() - - updateConnectedProvidersCache(ctx.client).catch(() => {}) - - if (!hadCache) { - await ctx.client.tui - .showToast({ - body: { - title: "Connected Providers Cache", - message: "Building provider cache for first time. Restart OpenCode for full model filtering.", - variant: "info" as const, - duration: 8000, - }, - }) - .catch(() => {}) - - log("[auto-update-checker] Connected providers cache toast shown (first run)") - } else { - log("[auto-update-checker] Connected providers cache exists, updating in background") - } -} - -async function showConfigErrorsIfAny(ctx: PluginInput): Promise { - const errors = getConfigLoadErrors() - if (errors.length === 0) return - - const errorMessages = errors.map(e => `${e.path}: ${e.error}`).join("\n") - await ctx.client.tui - .showToast({ - body: { - title: "Config Load Error", - message: `Failed to load config:\n${errorMessages}`, - variant: "error" as const, - duration: 10000, - }, - }) - .catch(() => {}) - - log(`[auto-update-checker] Config load errors shown: ${errors.length} error(s)`) - clearConfigLoadErrors() -} - -async function showVersionToast(ctx: PluginInput, version: string | null, message: string): Promise { - const displayVersion = version ?? "unknown" - await showSpinnerToast(ctx, displayVersion, message) - log(`[auto-update-checker] Startup toast shown: v${displayVersion}`) -} - -async function showSpinnerToast(ctx: PluginInput, version: string, message: string): Promise { - const totalDuration = 5000 - const frameInterval = 100 - const totalFrames = Math.floor(totalDuration / frameInterval) - - for (let i = 0; i < totalFrames; i++) { - const spinner = SISYPHUS_SPINNER[i % SISYPHUS_SPINNER.length] - await ctx.client.tui - .showToast({ - body: { - title: `${spinner} OhMyOpenCode ${version}`, - message, - variant: "info" as const, - duration: frameInterval + 50, - }, - }) - .catch(() => { }) - await new Promise(resolve => setTimeout(resolve, frameInterval)) - } -} - -async function showUpdateAvailableToast( - ctx: PluginInput, - latestVersion: string, - getToastMessage: (isUpdate: boolean, latestVersion?: string) => string -): Promise { - await ctx.client.tui - .showToast({ - body: { - title: `OhMyOpenCode ${latestVersion}`, - message: getToastMessage(true, latestVersion), - variant: "info" as const, - duration: 8000, - }, - }) - .catch(() => {}) - log(`[auto-update-checker] Update available toast shown: v${latestVersion}`) -} - -async function showAutoUpdatedToast(ctx: PluginInput, oldVersion: string, newVersion: string): Promise { - await ctx.client.tui - .showToast({ - body: { - title: `OhMyOpenCode Updated!`, - message: `v${oldVersion} → v${newVersion}\nRestart OpenCode to apply.`, - variant: "success" as const, - duration: 8000, - }, - }) - .catch(() => {}) - log(`[auto-update-checker] Auto-updated toast shown: v${oldVersion} → v${newVersion}`) -} - -async function showLocalDevToast(ctx: PluginInput, version: string | null, isSisyphusEnabled: boolean): Promise { - const displayVersion = version ?? "dev" - const message = isSisyphusEnabled - ? "Sisyphus running in local development mode." - : "Running in local development mode. oMoMoMo..." - await showSpinnerToast(ctx, `${displayVersion} (dev)`, message) - log(`[auto-update-checker] Local dev toast shown: v${displayVersion}`) -} - -export type { UpdateCheckResult, AutoUpdateCheckerOptions } from "./types" export { checkForUpdate } from "./checker" export { invalidatePackage, invalidateCache } from "./cache" +export type { UpdateCheckResult, AutoUpdateCheckerOptions } from "./types" diff --git a/src/hooks/auto-update-checker/version-channel.ts b/src/hooks/auto-update-checker/version-channel.ts new file mode 100644 index 000000000..80b3c8eb9 --- /dev/null +++ b/src/hooks/auto-update-checker/version-channel.ts @@ -0,0 +1,33 @@ +export function isPrereleaseVersion(version: string): boolean { + return version.includes("-") +} + +export function isDistTag(version: string): boolean { + const startsWithDigit = /^\d/.test(version) + return !startsWithDigit +} + +export function isPrereleaseOrDistTag(pinnedVersion: string | null): boolean { + if (!pinnedVersion) return false + return isPrereleaseVersion(pinnedVersion) || isDistTag(pinnedVersion) +} + +export function extractChannel(version: string | null): string { + if (!version) return "latest" + + if (isDistTag(version)) { + return version + } + + if (isPrereleaseVersion(version)) { + const prereleasePart = version.split("-")[1] + if (prereleasePart) { + const channelMatch = prereleasePart.match(/^(alpha|beta|rc|canary|next)/) + if (channelMatch) { + return channelMatch[1] + } + } + } + + return "latest" +} diff --git a/src/hooks/claude-code-hooks/claude-code-hooks-hook.ts b/src/hooks/claude-code-hooks/claude-code-hooks-hook.ts new file mode 100644 index 000000000..de8bc9c47 --- /dev/null +++ b/src/hooks/claude-code-hooks/claude-code-hooks-hook.ts @@ -0,0 +1,421 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { loadClaudeHooksConfig } from "./config" +import { loadPluginExtendedConfig } from "./config-loader" +import { + executePreToolUseHooks, + type PreToolUseContext, +} from "./pre-tool-use" +import { + executePostToolUseHooks, + type PostToolUseContext, + type PostToolUseClient, +} from "./post-tool-use" +import { + executeUserPromptSubmitHooks, + type UserPromptSubmitContext, + type MessagePart, +} from "./user-prompt-submit" +import { + executeStopHooks, + type StopContext, +} from "./stop" +import { + executePreCompactHooks, + type PreCompactContext, +} from "./pre-compact" +import { cacheToolInput, getToolInput } from "./tool-input-cache" +import { appendTranscriptEntry, getTranscriptPath } from "./transcript" +import type { PluginConfig } from "./types" +import { log, isHookDisabled } from "../../shared" +import type { ContextCollector } from "../../features/context-injector" + +const sessionFirstMessageProcessed = new Set() +const sessionErrorState = new Map() +const sessionInterruptState = new Map() + +export function createClaudeCodeHooksHook( + ctx: PluginInput, + config: PluginConfig = {}, + contextCollector?: ContextCollector +) { + return { + "experimental.session.compacting": async ( + input: { sessionID: string }, + output: { context: string[] } + ): Promise => { + if (isHookDisabled(config, "PreCompact")) { + return + } + + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const preCompactCtx: PreCompactContext = { + sessionId: input.sessionID, + cwd: ctx.directory, + } + + const result = await executePreCompactHooks(preCompactCtx, claudeConfig, extendedConfig) + + if (result.context.length > 0) { + log("PreCompact hooks injecting context", { + sessionID: input.sessionID, + contextCount: result.context.length, + hookName: result.hookName, + elapsedMs: result.elapsedMs, + }) + output.context.push(...result.context) + } + }, + + "chat.message": async ( + input: { + sessionID: string + agent?: string + model?: { providerID: string; modelID: string } + messageID?: string + }, + output: { + message: Record + parts: Array<{ type: string; text?: string; [key: string]: unknown }> + } + ): Promise => { + const interruptState = sessionInterruptState.get(input.sessionID) + if (interruptState?.interrupted) { + log("chat.message hook skipped - session interrupted", { sessionID: input.sessionID }) + return + } + + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const textParts = output.parts.filter((p) => p.type === "text" && p.text) + const prompt = textParts.map((p) => p.text ?? "").join("\n") + + appendTranscriptEntry(input.sessionID, { + type: "user", + timestamp: new Date().toISOString(), + content: prompt, + }) + + const messageParts: MessagePart[] = textParts.map((p) => ({ + type: p.type as "text", + text: p.text, + })) + + const interruptStateBeforeHooks = sessionInterruptState.get(input.sessionID) + if (interruptStateBeforeHooks?.interrupted) { + log("chat.message hooks skipped - interrupted during preparation", { sessionID: input.sessionID }) + return + } + + let parentSessionId: string | undefined + try { + const sessionInfo = await ctx.client.session.get({ + path: { id: input.sessionID }, + }) + parentSessionId = sessionInfo.data?.parentID + } catch {} + + const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID) + sessionFirstMessageProcessed.add(input.sessionID) + + if (!isHookDisabled(config, "UserPromptSubmit")) { + const userPromptCtx: UserPromptSubmitContext = { + sessionId: input.sessionID, + parentSessionId, + prompt, + parts: messageParts, + cwd: ctx.directory, + } + + const result = await executeUserPromptSubmitHooks( + userPromptCtx, + claudeConfig, + extendedConfig + ) + + if (result.block) { + throw new Error(result.reason ?? "Hook blocked the prompt") + } + + const interruptStateAfterHooks = sessionInterruptState.get(input.sessionID) + if (interruptStateAfterHooks?.interrupted) { + log("chat.message injection skipped - interrupted during hooks", { sessionID: input.sessionID }) + return + } + + if (result.messages.length > 0) { + const hookContent = result.messages.join("\n\n") + log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage }) + + if (contextCollector) { + log("[DEBUG] Registering hook content to contextCollector", { + sessionID: input.sessionID, + contentLength: hookContent.length, + contentPreview: hookContent.slice(0, 100), + }) + contextCollector.register(input.sessionID, { + id: "hook-context", + source: "custom", + content: hookContent, + priority: "high", + }) + + log("Hook content registered for synthetic message injection", { + sessionID: input.sessionID, + contentLength: hookContent.length, + }) + } + } + } + }, + + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record } + ): Promise => { + if (input.tool.trim() === "todowrite" && typeof output.args.todos === "string") { + let parsed: unknown + try { + parsed = JSON.parse(output.args.todos) + } catch (e) { + throw new Error( + `[todowrite ERROR] Failed to parse todos string as JSON. ` + + `Received: ${output.args.todos.length > 100 ? output.args.todos.slice(0, 100) + '...' : output.args.todos} ` + + `Expected: Valid JSON array. Pass todos as an array, not a string.` + ) + } + + if (!Array.isArray(parsed)) { + throw new Error( + `[todowrite ERROR] Parsed JSON is not an array. ` + + `Received type: ${typeof parsed}. ` + + `Expected: Array of todo objects. Pass todos as [{id, content, status, priority}, ...].` + ) + } + + output.args.todos = parsed + log("todowrite: parsed todos string to array", { sessionID: input.sessionID }) + } + + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + appendTranscriptEntry(input.sessionID, { + type: "tool_use", + timestamp: new Date().toISOString(), + tool_name: input.tool, + tool_input: output.args as Record, + }) + + cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record) + + if (!isHookDisabled(config, "PreToolUse")) { + const preCtx: PreToolUseContext = { + sessionId: input.sessionID, + toolName: input.tool, + toolInput: output.args as Record, + cwd: ctx.directory, + toolUseId: input.callID, + } + + const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig) + + if (result.decision === "deny") { + ctx.client.tui + .showToast({ + body: { + title: "PreToolUse Hook Executed", + message: `[BLOCKED] ${result.toolName ?? input.tool} ${result.hookName ?? "hook"}: ${result.elapsedMs ?? 0}ms\n${result.inputLines ?? ""}`, + variant: "error" as const, + duration: 4000, + }, + }) + .catch(() => {}) + throw new Error(result.reason ?? "Hook blocked the operation") + } + + if (result.modifiedInput) { + Object.assign(output.args as Record, result.modifiedInput) + } + } + }, + + "tool.execute.after": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: unknown } + ): Promise => { + // Guard against undefined output (e.g., from /review command - see issue #1035) + if (!output) { + return + } + + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {} + + // Use metadata if available and non-empty, otherwise wrap output.output in a structured object + // This ensures plugin tools (call_omo_agent, task) that return strings + // get their results properly recorded in transcripts instead of empty {} + const metadata = output.metadata as Record | undefined + const hasMetadata = metadata && typeof metadata === "object" && Object.keys(metadata).length > 0 + const toolOutput = hasMetadata ? metadata : { output: output.output } + appendTranscriptEntry(input.sessionID, { + type: "tool_result", + timestamp: new Date().toISOString(), + tool_name: input.tool, + tool_input: cachedInput, + tool_output: toolOutput, + }) + + if (!isHookDisabled(config, "PostToolUse")) { + const postClient: PostToolUseClient = { + session: { + messages: (opts) => ctx.client.session.messages(opts), + }, + } + + const postCtx: PostToolUseContext = { + sessionId: input.sessionID, + toolName: input.tool, + toolInput: cachedInput, + toolOutput: { + title: input.tool, + output: output.output, + metadata: output.metadata as Record, + }, + cwd: ctx.directory, + transcriptPath: getTranscriptPath(input.sessionID), + toolUseId: input.callID, + client: postClient, + permissionMode: "bypassPermissions", + } + + const result = await executePostToolUseHooks(postCtx, claudeConfig, extendedConfig) + + if (result.block) { + ctx.client.tui + .showToast({ + body: { + title: "PostToolUse Hook Warning", + message: result.reason ?? "Hook returned warning", + variant: "warning", + duration: 4000, + }, + }) + .catch(() => {}) + } + + if (result.warnings && result.warnings.length > 0) { + output.output = `${output.output}\n\n${result.warnings.join("\n")}` + } + + if (result.message) { + output.output = `${output.output}\n\n${result.message}` + } + + if (result.hookName) { + ctx.client.tui + .showToast({ + body: { + title: "PostToolUse Hook Executed", + message: `▶ ${result.toolName ?? input.tool} ${result.hookName}: ${result.elapsedMs ?? 0}ms`, + variant: "success", + duration: 2000, + }, + }) + .catch(() => {}) + } + } + }, + + event: async (input: { event: { type: string; properties?: unknown } }) => { + const { event } = input + + if (event.type === "session.error") { + const props = event.properties as Record | undefined + const sessionID = props?.sessionID as string | undefined + if (sessionID) { + sessionErrorState.set(sessionID, { + hasError: true, + errorMessage: String(props?.error ?? "Unknown error"), + }) + } + return + } + + if (event.type === "session.deleted") { + const props = event.properties as Record | undefined + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id) { + sessionErrorState.delete(sessionInfo.id) + sessionInterruptState.delete(sessionInfo.id) + sessionFirstMessageProcessed.delete(sessionInfo.id) + } + return + } + + if (event.type === "session.idle") { + const props = event.properties as Record | undefined + const sessionID = props?.sessionID as string | undefined + + if (!sessionID) return + + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const errorStateBefore = sessionErrorState.get(sessionID) + const endedWithErrorBefore = errorStateBefore?.hasError === true + const interruptStateBefore = sessionInterruptState.get(sessionID) + const interruptedBefore = interruptStateBefore?.interrupted === true + + let parentSessionId: string | undefined + try { + const sessionInfo = await ctx.client.session.get({ + path: { id: sessionID }, + }) + parentSessionId = sessionInfo.data?.parentID + } catch {} + + if (!isHookDisabled(config, "Stop")) { + const stopCtx: StopContext = { + sessionId: sessionID, + parentSessionId, + cwd: ctx.directory, + } + + const stopResult = await executeStopHooks(stopCtx, claudeConfig, extendedConfig) + + const errorStateAfter = sessionErrorState.get(sessionID) + const endedWithErrorAfter = errorStateAfter?.hasError === true + const interruptStateAfter = sessionInterruptState.get(sessionID) + const interruptedAfter = interruptStateAfter?.interrupted === true + + const shouldBypass = endedWithErrorBefore || endedWithErrorAfter || interruptedBefore || interruptedAfter + + if (shouldBypass && stopResult.block) { + const interrupted = interruptedBefore || interruptedAfter + const endedWithError = endedWithErrorBefore || endedWithErrorAfter + log("Stop hook block ignored", { sessionID, block: stopResult.block, interrupted, endedWithError }) + } else if (stopResult.block && stopResult.injectPrompt) { + log("Stop hook returned block with inject_prompt", { sessionID }) + ctx.client.session + .prompt({ + path: { id: sessionID }, + body: { parts: [{ type: "text", text: stopResult.injectPrompt }] }, + query: { directory: ctx.directory }, + }) + .catch((err: unknown) => log("Failed to inject prompt from Stop hook", err)) + } else if (stopResult.block) { + log("Stop hook returned block", { sessionID, reason: stopResult.reason }) + } + } + + sessionErrorState.delete(sessionID) + sessionInterruptState.delete(sessionID) + } + }, + } +} diff --git a/src/hooks/claude-code-hooks/index.ts b/src/hooks/claude-code-hooks/index.ts index 9555ea797..a532c8ee1 100644 --- a/src/hooks/claude-code-hooks/index.ts +++ b/src/hooks/claude-code-hooks/index.ts @@ -1,421 +1 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { loadClaudeHooksConfig } from "./config" -import { loadPluginExtendedConfig } from "./config-loader" -import { - executePreToolUseHooks, - type PreToolUseContext, -} from "./pre-tool-use" -import { - executePostToolUseHooks, - type PostToolUseContext, - type PostToolUseClient, -} from "./post-tool-use" -import { - executeUserPromptSubmitHooks, - type UserPromptSubmitContext, - type MessagePart, -} from "./user-prompt-submit" -import { - executeStopHooks, - type StopContext, -} from "./stop" -import { - executePreCompactHooks, - type PreCompactContext, -} from "./pre-compact" -import { cacheToolInput, getToolInput } from "./tool-input-cache" -import { appendTranscriptEntry, getTranscriptPath } from "./transcript" -import type { PluginConfig } from "./types" -import { log, isHookDisabled } from "../../shared" -import type { ContextCollector } from "../../features/context-injector" - -const sessionFirstMessageProcessed = new Set() -const sessionErrorState = new Map() -const sessionInterruptState = new Map() - -export function createClaudeCodeHooksHook( - ctx: PluginInput, - config: PluginConfig = {}, - contextCollector?: ContextCollector -) { - return { - "experimental.session.compacting": async ( - input: { sessionID: string }, - output: { context: string[] } - ): Promise => { - if (isHookDisabled(config, "PreCompact")) { - return - } - - const claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() - - const preCompactCtx: PreCompactContext = { - sessionId: input.sessionID, - cwd: ctx.directory, - } - - const result = await executePreCompactHooks(preCompactCtx, claudeConfig, extendedConfig) - - if (result.context.length > 0) { - log("PreCompact hooks injecting context", { - sessionID: input.sessionID, - contextCount: result.context.length, - hookName: result.hookName, - elapsedMs: result.elapsedMs, - }) - output.context.push(...result.context) - } - }, - - "chat.message": async ( - input: { - sessionID: string - agent?: string - model?: { providerID: string; modelID: string } - messageID?: string - }, - output: { - message: Record - parts: Array<{ type: string; text?: string; [key: string]: unknown }> - } - ): Promise => { - const interruptState = sessionInterruptState.get(input.sessionID) - if (interruptState?.interrupted) { - log("chat.message hook skipped - session interrupted", { sessionID: input.sessionID }) - return - } - - const claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() - - const textParts = output.parts.filter((p) => p.type === "text" && p.text) - const prompt = textParts.map((p) => p.text ?? "").join("\n") - - appendTranscriptEntry(input.sessionID, { - type: "user", - timestamp: new Date().toISOString(), - content: prompt, - }) - - const messageParts: MessagePart[] = textParts.map((p) => ({ - type: p.type as "text", - text: p.text, - })) - - const interruptStateBeforeHooks = sessionInterruptState.get(input.sessionID) - if (interruptStateBeforeHooks?.interrupted) { - log("chat.message hooks skipped - interrupted during preparation", { sessionID: input.sessionID }) - return - } - - let parentSessionId: string | undefined - try { - const sessionInfo = await ctx.client.session.get({ - path: { id: input.sessionID }, - }) - parentSessionId = sessionInfo.data?.parentID - } catch {} - - const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID) - sessionFirstMessageProcessed.add(input.sessionID) - - if (!isHookDisabled(config, "UserPromptSubmit")) { - const userPromptCtx: UserPromptSubmitContext = { - sessionId: input.sessionID, - parentSessionId, - prompt, - parts: messageParts, - cwd: ctx.directory, - } - - const result = await executeUserPromptSubmitHooks( - userPromptCtx, - claudeConfig, - extendedConfig - ) - - if (result.block) { - throw new Error(result.reason ?? "Hook blocked the prompt") - } - - const interruptStateAfterHooks = sessionInterruptState.get(input.sessionID) - if (interruptStateAfterHooks?.interrupted) { - log("chat.message injection skipped - interrupted during hooks", { sessionID: input.sessionID }) - return - } - - if (result.messages.length > 0) { - const hookContent = result.messages.join("\n\n") - log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage }) - - if (contextCollector) { - log("[DEBUG] Registering hook content to contextCollector", { - sessionID: input.sessionID, - contentLength: hookContent.length, - contentPreview: hookContent.slice(0, 100), - }) - contextCollector.register(input.sessionID, { - id: "hook-context", - source: "custom", - content: hookContent, - priority: "high", - }) - - log("Hook content registered for synthetic message injection", { - sessionID: input.sessionID, - contentLength: hookContent.length, - }) - } - } - } - }, - - "tool.execute.before": async ( - input: { tool: string; sessionID: string; callID: string }, - output: { args: Record } - ): Promise => { - if (input.tool.trim() === "todowrite" && typeof output.args.todos === "string") { - let parsed: unknown - try { - parsed = JSON.parse(output.args.todos) - } catch (e) { - throw new Error( - `[todowrite ERROR] Failed to parse todos string as JSON. ` + - `Received: ${output.args.todos.length > 100 ? output.args.todos.slice(0, 100) + '...' : output.args.todos} ` + - `Expected: Valid JSON array. Pass todos as an array, not a string.` - ) - } - - if (!Array.isArray(parsed)) { - throw new Error( - `[todowrite ERROR] Parsed JSON is not an array. ` + - `Received type: ${typeof parsed}. ` + - `Expected: Array of todo objects. Pass todos as [{id, content, status, priority}, ...].` - ) - } - - output.args.todos = parsed - log("todowrite: parsed todos string to array", { sessionID: input.sessionID }) - } - - const claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() - - appendTranscriptEntry(input.sessionID, { - type: "tool_use", - timestamp: new Date().toISOString(), - tool_name: input.tool, - tool_input: output.args as Record, - }) - - cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record) - - if (!isHookDisabled(config, "PreToolUse")) { - const preCtx: PreToolUseContext = { - sessionId: input.sessionID, - toolName: input.tool, - toolInput: output.args as Record, - cwd: ctx.directory, - toolUseId: input.callID, - } - - const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig) - - if (result.decision === "deny") { - ctx.client.tui - .showToast({ - body: { - title: "PreToolUse Hook Executed", - message: `[BLOCKED] ${result.toolName ?? input.tool} ${result.hookName ?? "hook"}: ${result.elapsedMs ?? 0}ms\n${result.inputLines ?? ""}`, - variant: "error", - duration: 4000, - }, - }) - .catch(() => {}) - throw new Error(result.reason ?? "Hook blocked the operation") - } - - if (result.modifiedInput) { - Object.assign(output.args as Record, result.modifiedInput) - } - } - }, - - "tool.execute.after": async ( - input: { tool: string; sessionID: string; callID: string }, - output: { title: string; output: string; metadata: unknown } - ): Promise => { - // Guard against undefined output (e.g., from /review command - see issue #1035) - if (!output) { - return - } - - const claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() - - const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {} - - // Use metadata if available and non-empty, otherwise wrap output.output in a structured object - // This ensures plugin tools (call_omo_agent, task) that return strings - // get their results properly recorded in transcripts instead of empty {} - const metadata = output.metadata as Record | undefined - const hasMetadata = metadata && typeof metadata === "object" && Object.keys(metadata).length > 0 - const toolOutput = hasMetadata ? metadata : { output: output.output } - appendTranscriptEntry(input.sessionID, { - type: "tool_result", - timestamp: new Date().toISOString(), - tool_name: input.tool, - tool_input: cachedInput, - tool_output: toolOutput, - }) - - if (!isHookDisabled(config, "PostToolUse")) { - const postClient: PostToolUseClient = { - session: { - messages: (opts) => ctx.client.session.messages(opts), - }, - } - - const postCtx: PostToolUseContext = { - sessionId: input.sessionID, - toolName: input.tool, - toolInput: cachedInput, - toolOutput: { - title: input.tool, - output: output.output, - metadata: output.metadata as Record, - }, - cwd: ctx.directory, - transcriptPath: getTranscriptPath(input.sessionID), - toolUseId: input.callID, - client: postClient, - permissionMode: "bypassPermissions", - } - - const result = await executePostToolUseHooks(postCtx, claudeConfig, extendedConfig) - - if (result.block) { - ctx.client.tui - .showToast({ - body: { - title: "PostToolUse Hook Warning", - message: result.reason ?? "Hook returned warning", - variant: "warning", - duration: 4000, - }, - }) - .catch(() => {}) - } - - if (result.warnings && result.warnings.length > 0) { - output.output = `${output.output}\n\n${result.warnings.join("\n")}` - } - - if (result.message) { - output.output = `${output.output}\n\n${result.message}` - } - - if (result.hookName) { - ctx.client.tui - .showToast({ - body: { - title: "PostToolUse Hook Executed", - message: `▶ ${result.toolName ?? input.tool} ${result.hookName}: ${result.elapsedMs ?? 0}ms`, - variant: "success", - duration: 2000, - }, - }) - .catch(() => {}) - } - } - }, - - event: async (input: { event: { type: string; properties?: unknown } }) => { - const { event } = input - - if (event.type === "session.error") { - const props = event.properties as Record | undefined - const sessionID = props?.sessionID as string | undefined - if (sessionID) { - sessionErrorState.set(sessionID, { - hasError: true, - errorMessage: String(props?.error ?? "Unknown error"), - }) - } - return - } - - if (event.type === "session.deleted") { - const props = event.properties as Record | undefined - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - sessionErrorState.delete(sessionInfo.id) - sessionInterruptState.delete(sessionInfo.id) - sessionFirstMessageProcessed.delete(sessionInfo.id) - } - return - } - - if (event.type === "session.idle") { - const props = event.properties as Record | undefined - const sessionID = props?.sessionID as string | undefined - - if (!sessionID) return - - const claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() - - const errorStateBefore = sessionErrorState.get(sessionID) - const endedWithErrorBefore = errorStateBefore?.hasError === true - const interruptStateBefore = sessionInterruptState.get(sessionID) - const interruptedBefore = interruptStateBefore?.interrupted === true - - let parentSessionId: string | undefined - try { - const sessionInfo = await ctx.client.session.get({ - path: { id: sessionID }, - }) - parentSessionId = sessionInfo.data?.parentID - } catch {} - - if (!isHookDisabled(config, "Stop")) { - const stopCtx: StopContext = { - sessionId: sessionID, - parentSessionId, - cwd: ctx.directory, - } - - const stopResult = await executeStopHooks(stopCtx, claudeConfig, extendedConfig) - - const errorStateAfter = sessionErrorState.get(sessionID) - const endedWithErrorAfter = errorStateAfter?.hasError === true - const interruptStateAfter = sessionInterruptState.get(sessionID) - const interruptedAfter = interruptStateAfter?.interrupted === true - - const shouldBypass = endedWithErrorBefore || endedWithErrorAfter || interruptedBefore || interruptedAfter - - if (shouldBypass && stopResult.block) { - const interrupted = interruptedBefore || interruptedAfter - const endedWithError = endedWithErrorBefore || endedWithErrorAfter - log("Stop hook block ignored", { sessionID, block: stopResult.block, interrupted, endedWithError }) - } else if (stopResult.block && stopResult.injectPrompt) { - log("Stop hook returned block with inject_prompt", { sessionID }) - ctx.client.session - .prompt({ - path: { id: sessionID }, - body: { parts: [{ type: "text", text: stopResult.injectPrompt }] }, - query: { directory: ctx.directory }, - }) - .catch((err: unknown) => log("Failed to inject prompt from Stop hook", err)) - } else if (stopResult.block) { - log("Stop hook returned block", { sessionID, reason: stopResult.reason }) - } - } - - sessionErrorState.delete(sessionID) - sessionInterruptState.delete(sessionID) - } - }, - } -} +export { createClaudeCodeHooksHook } from "./claude-code-hooks-hook" diff --git a/src/hooks/interactive-bash-session/index.ts b/src/hooks/interactive-bash-session/index.ts index 307441629..b9be8e12b 100644 --- a/src/hooks/interactive-bash-session/index.ts +++ b/src/hooks/interactive-bash-session/index.ts @@ -1,267 +1 @@ -import type { PluginInput } from "@opencode-ai/plugin"; -import { - loadInteractiveBashSessionState, - saveInteractiveBashSessionState, - clearInteractiveBashSessionState, -} from "./storage"; -import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants"; -import type { InteractiveBashSessionState } from "./types"; -import { subagentSessions } from "../../features/claude-code-session-state"; - -interface ToolExecuteInput { - tool: string; - sessionID: string; - callID: string; - args?: Record; -} - -interface ToolExecuteOutput { - title: string; - output: string; - metadata: unknown; -} - -interface EventInput { - event: { - type: string; - properties?: unknown; - }; -} - -/** - * Quote-aware command tokenizer with escape handling - * Handles single/double quotes and backslash escapes - */ -function tokenizeCommand(cmd: string): string[] { - const tokens: string[] = [] - let current = "" - let inQuote = false - let quoteChar = "" - let escaped = false - - for (let i = 0; i < cmd.length; i++) { - const char = cmd[i] - - if (escaped) { - current += char - escaped = false - continue - } - - if (char === "\\") { - escaped = true - continue - } - - if ((char === "'" || char === '"') && !inQuote) { - inQuote = true - quoteChar = char - } else if (char === quoteChar && inQuote) { - inQuote = false - quoteChar = "" - } else if (char === " " && !inQuote) { - if (current) { - tokens.push(current) - current = "" - } - } else { - current += char - } - } - - if (current) tokens.push(current) - return tokens -} - -/** - * Normalize session name by stripping :window and .pane suffixes - * e.g., "omo-x:1" -> "omo-x", "omo-x:1.2" -> "omo-x" - */ -function normalizeSessionName(name: string): string { - return name.split(":")[0].split(".")[0] -} - -function findFlagValue(tokens: string[], flag: string): string | null { - for (let i = 0; i < tokens.length - 1; i++) { - if (tokens[i] === flag) return tokens[i + 1] - } - return null -} - -/** - * Extract session name from tokens, considering the subCommand - * For new-session: prioritize -s over -t - * For other commands: use -t - */ -function extractSessionNameFromTokens(tokens: string[], subCommand: string): string | null { - if (subCommand === "new-session") { - const sFlag = findFlagValue(tokens, "-s") - if (sFlag) return normalizeSessionName(sFlag) - const tFlag = findFlagValue(tokens, "-t") - if (tFlag) return normalizeSessionName(tFlag) - } else { - const tFlag = findFlagValue(tokens, "-t") - if (tFlag) return normalizeSessionName(tFlag) - } - return null -} - -/** - * Find the tmux subcommand from tokens, skipping global options. - * tmux allows global options before the subcommand: - * e.g., `tmux -L socket-name new-session -s omo-x` - * Global options with args: -L, -S, -f, -c, -T - * Standalone flags: -C, -v, -V, etc. - * Special: -- (end of options marker) - */ -function findSubcommand(tokens: string[]): string { - // Options that require an argument: -L, -S, -f, -c, -T - const globalOptionsWithArgs = new Set(["-L", "-S", "-f", "-c", "-T"]) - - let i = 0 - while (i < tokens.length) { - const token = tokens[i] - - // Handle end of options marker - if (token === "--") { - // Next token is the subcommand - return tokens[i + 1] ?? "" - } - - if (globalOptionsWithArgs.has(token)) { - // Skip the option and its argument - i += 2 - continue - } - - if (token.startsWith("-")) { - // Skip standalone flags like -C, -v, -V - i++ - continue - } - - // Found the subcommand - return token - } - - return "" -} - -export function createInteractiveBashSessionHook(ctx: PluginInput) { - const sessionStates = new Map(); - - function getOrCreateState(sessionID: string): InteractiveBashSessionState { - if (!sessionStates.has(sessionID)) { - const persisted = loadInteractiveBashSessionState(sessionID); - const state: InteractiveBashSessionState = persisted ?? { - sessionID, - tmuxSessions: new Set(), - updatedAt: Date.now(), - }; - sessionStates.set(sessionID, state); - } - return sessionStates.get(sessionID)!; - } - - function isOmoSession(sessionName: string | null): boolean { - return sessionName !== null && sessionName.startsWith(OMO_SESSION_PREFIX); - } - - async function killAllTrackedSessions( - state: InteractiveBashSessionState, - ): Promise { - for (const sessionName of state.tmuxSessions) { - try { - const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], { - stdout: "ignore", - stderr: "ignore", - }); - await proc.exited; - } catch {} - } - - for (const sessionId of subagentSessions) { - ctx.client.session.abort({ path: { id: sessionId } }).catch(() => {}) - } - } - - const toolExecuteAfter = async ( - input: ToolExecuteInput, - output: ToolExecuteOutput, - ) => { - const { tool, sessionID, args } = input; - const toolLower = tool.toLowerCase(); - - if (toolLower !== "interactive_bash") { - return; - } - - if (typeof args?.tmux_command !== "string") { - return; - } - - const tmuxCommand = args.tmux_command; - const tokens = tokenizeCommand(tmuxCommand); - const subCommand = findSubcommand(tokens); - const state = getOrCreateState(sessionID); - let stateChanged = false; - - const toolOutput = output?.output ?? "" - if (toolOutput.startsWith("Error:")) { - return - } - - const isNewSession = subCommand === "new-session"; - const isKillSession = subCommand === "kill-session"; - const isKillServer = subCommand === "kill-server"; - - const sessionName = extractSessionNameFromTokens(tokens, subCommand); - - if (isNewSession && isOmoSession(sessionName)) { - state.tmuxSessions.add(sessionName!); - stateChanged = true; - } else if (isKillSession && isOmoSession(sessionName)) { - state.tmuxSessions.delete(sessionName!); - stateChanged = true; - } else if (isKillServer) { - state.tmuxSessions.clear(); - stateChanged = true; - } - - if (stateChanged) { - state.updatedAt = Date.now(); - saveInteractiveBashSessionState(state); - } - - const isSessionOperation = isNewSession || isKillSession || isKillServer; - if (isSessionOperation) { - const reminder = buildSessionReminderMessage( - Array.from(state.tmuxSessions), - ); - if (reminder) { - output.output += reminder; - } - } - }; - - const eventHandler = async ({ event }: EventInput) => { - const props = event.properties as Record | undefined; - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - const sessionID = sessionInfo?.id; - - if (sessionID) { - const state = getOrCreateState(sessionID); - await killAllTrackedSessions(state); - sessionStates.delete(sessionID); - clearInteractiveBashSessionState(sessionID); - } - } - }; - - return { - "tool.execute.after": toolExecuteAfter, - event: eventHandler, - }; -} +export { createInteractiveBashSessionHook } from "./interactive-bash-session-hook" diff --git a/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts b/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts new file mode 100644 index 000000000..dd0a87002 --- /dev/null +++ b/src/hooks/interactive-bash-session/interactive-bash-session-hook.ts @@ -0,0 +1,267 @@ +import type { PluginInput } from "@opencode-ai/plugin"; +import { + loadInteractiveBashSessionState, + saveInteractiveBashSessionState, + clearInteractiveBashSessionState, +} from "./storage"; +import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants"; +import type { InteractiveBashSessionState } from "./types"; +import { subagentSessions } from "../../features/claude-code-session-state"; + +interface ToolExecuteInput { + tool: string; + sessionID: string; + callID: string; + args?: Record; +} + +interface ToolExecuteOutput { + title: string; + output: string; + metadata: unknown; +} + +interface EventInput { + event: { + type: string; + properties?: unknown; + }; +} + +/** + * Quote-aware command tokenizer with escape handling + * Handles single/double quotes and backslash escapes + */ +function tokenizeCommand(cmd: string): string[] { + const tokens: string[] = [] + let current = "" + let inQuote = false + let quoteChar = "" + let escaped = false + + for (let i = 0; i < cmd.length; i++) { + const char = cmd[i] + + if (escaped) { + current += char + escaped = false + continue + } + + if (char === "\\") { + escaped = true + continue + } + + if ((char === "'" || char === '"') && !inQuote) { + inQuote = true + quoteChar = char + } else if (char === quoteChar && inQuote) { + inQuote = false + quoteChar = "" + } else if (char === " " && !inQuote) { + if (current) { + tokens.push(current) + current = "" + } + } else { + current += char + } + } + + if (current) tokens.push(current) + return tokens +} + +/** + * Normalize session name by stripping :window and .pane suffixes + * e.g., "omo-x:1" -> "omo-x", "omo-x:1.2" -> "omo-x" + */ +function normalizeSessionName(name: string): string { + return name.split(":")[0].split(".")[0] +} + +function findFlagValue(tokens: string[], flag: string): string | null { + for (let i = 0; i < tokens.length - 1; i++) { + if (tokens[i] === flag) return tokens[i + 1] + } + return null +} + +/** + * Extract session name from tokens, considering the subCommand + * For new-session: prioritize -s over -t + * For other commands: use -t + */ +function extractSessionNameFromTokens(tokens: string[], subCommand: string): string | null { + if (subCommand === "new-session") { + const sFlag = findFlagValue(tokens, "-s") + if (sFlag) return normalizeSessionName(sFlag) + const tFlag = findFlagValue(tokens, "-t") + if (tFlag) return normalizeSessionName(tFlag) + } else { + const tFlag = findFlagValue(tokens, "-t") + if (tFlag) return normalizeSessionName(tFlag) + } + return null +} + +/** + * Find the tmux subcommand from tokens, skipping global options. + * tmux allows global options before the subcommand: + * e.g., `tmux -L socket-name new-session -s omo-x` + * Global options with args: -L, -S, -f, -c, -T + * Standalone flags: -C, -v, -V, etc. + * Special: -- (end of options marker) + */ +function findSubcommand(tokens: string[]): string { + // Options that require an argument: -L, -S, -f, -c, -T + const globalOptionsWithArgs = new Set(["-L", "-S", "-f", "-c", "-T"]) + + let i = 0 + while (i < tokens.length) { + const token = tokens[i] + + // Handle end of options marker + if (token === "--") { + // Next token is the subcommand + return tokens[i + 1] ?? "" + } + + if (globalOptionsWithArgs.has(token)) { + // Skip the option and its argument + i += 2 + continue + } + + if (token.startsWith("-")) { + // Skip standalone flags like -C, -v, -V + i++ + continue + } + + // Found the subcommand + return token + } + + return "" +} + +export function createInteractiveBashSessionHook(ctx: PluginInput) { + const sessionStates = new Map(); + + function getOrCreateState(sessionID: string): InteractiveBashSessionState { + if (!sessionStates.has(sessionID)) { + const persisted = loadInteractiveBashSessionState(sessionID); + const state: InteractiveBashSessionState = persisted ?? { + sessionID, + tmuxSessions: new Set(), + updatedAt: Date.now(), + }; + sessionStates.set(sessionID, state); + } + return sessionStates.get(sessionID)!; + } + + function isOmoSession(sessionName: string | null): boolean { + return sessionName !== null && sessionName.startsWith(OMO_SESSION_PREFIX); + } + + async function killAllTrackedSessions( + state: InteractiveBashSessionState, + ): Promise { + for (const sessionName of state.tmuxSessions) { + try { + const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], { + stdout: "ignore", + stderr: "ignore", + }); + await proc.exited; + } catch {} + } + + for (const sessionId of subagentSessions) { + ctx.client.session.abort({ path: { id: sessionId } }).catch(() => {}) + } + } + + const toolExecuteAfter = async ( + input: ToolExecuteInput, + output: ToolExecuteOutput, + ) => { + const { tool, sessionID, args } = input; + const toolLower = tool.toLowerCase(); + + if (toolLower !== "interactive_bash") { + return; + } + + if (typeof args?.tmux_command !== "string") { + return; + } + + const tmuxCommand = args.tmux_command; + const tokens = tokenizeCommand(tmuxCommand); + const subCommand = findSubcommand(tokens); + const state = getOrCreateState(sessionID); + let stateChanged = false; + + const toolOutput = output?.output ?? "" + if (toolOutput.startsWith("Error:")) { + return + } + + const isNewSession = subCommand === "new-session"; + const isKillSession = subCommand === "kill-session"; + const isKillServer = subCommand === "kill-server"; + + const sessionName = extractSessionNameFromTokens(tokens, subCommand); + + if (isNewSession && isOmoSession(sessionName)) { + state.tmuxSessions.add(sessionName!); + stateChanged = true; + } else if (isKillSession && isOmoSession(sessionName)) { + state.tmuxSessions.delete(sessionName!); + stateChanged = true; + } else if (isKillServer) { + state.tmuxSessions.clear(); + stateChanged = true; + } + + if (stateChanged) { + state.updatedAt = Date.now(); + saveInteractiveBashSessionState(state); + } + + const isSessionOperation = isNewSession || isKillSession || isKillServer; + if (isSessionOperation) { + const reminder = buildSessionReminderMessage( + Array.from(state.tmuxSessions), + ); + if (reminder) { + output.output += reminder; + } + } + }; + + const eventHandler = async ({ event }: EventInput) => { + const props = event.properties as Record | undefined; + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined; + const sessionID = sessionInfo?.id; + + if (sessionID) { + const state = getOrCreateState(sessionID); + await killAllTrackedSessions(state); + sessionStates.delete(sessionID); + clearInteractiveBashSessionState(sessionID); + } + } + }; + + return { + "tool.execute.after": toolExecuteAfter, + event: eventHandler, + }; +} diff --git a/src/hooks/non-interactive-env/index.ts b/src/hooks/non-interactive-env/index.ts index 0acfaadcc..a5411ad55 100644 --- a/src/hooks/non-interactive-env/index.ts +++ b/src/hooks/non-interactive-env/index.ts @@ -1,66 +1,5 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./constants" -import { log, buildEnvPrefix } from "../../shared" - export * from "./constants" export * from "./detector" export * from "./types" -const BANNED_COMMAND_PATTERNS = SHELL_COMMAND_PATTERNS.banned - .filter((cmd) => !cmd.includes("(")) - .map((cmd) => new RegExp(`\\b${cmd}\\b`)) - -function detectBannedCommand(command: string): string | undefined { - for (let i = 0; i < BANNED_COMMAND_PATTERNS.length; i++) { - if (BANNED_COMMAND_PATTERNS[i].test(command)) { - return SHELL_COMMAND_PATTERNS.banned[i] - } - } - return undefined -} - -export function createNonInteractiveEnvHook(_ctx: PluginInput) { - return { - "tool.execute.before": async ( - input: { tool: string; sessionID: string; callID: string }, - output: { args: Record; message?: string } - ): Promise => { - if (input.tool.toLowerCase() !== "bash") { - return - } - - const command = output.args.command as string | undefined - if (!command) { - return - } - - const bannedCmd = detectBannedCommand(command) - if (bannedCmd) { - output.message = `Warning: '${bannedCmd}' is an interactive command that may hang in non-interactive environments.` - } - - // Only prepend env vars for git commands (editor blocking, pager, etc.) - const isGitCommand = /\bgit\b/.test(command) - if (!isGitCommand) { - return - } - - // NOTE: We intentionally removed the isNonInteractive() check here. - // Even when OpenCode runs in a TTY, the agent cannot interact with - // spawned bash processes. Git commands like `git rebase --continue` - // would open editors (vim/nvim) that hang forever. - // The env vars (GIT_EDITOR=:, EDITOR=:, etc.) must ALWAYS be injected - // for git commands to prevent interactive prompts. - - // The bash tool always runs in a Unix-like shell (bash/sh), even on Windows - // (via Git Bash, WSL, etc.), so always use unix export syntax. - const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, "unix") - output.args.command = `${envPrefix} ${command}` - - log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, { - sessionID: input.sessionID, - envPrefix, - }) - }, - } -} +export { createNonInteractiveEnvHook } from "./non-interactive-env-hook" diff --git a/src/hooks/non-interactive-env/non-interactive-env-hook.ts b/src/hooks/non-interactive-env/non-interactive-env-hook.ts new file mode 100644 index 000000000..90686f64d --- /dev/null +++ b/src/hooks/non-interactive-env/non-interactive-env-hook.ts @@ -0,0 +1,66 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./constants" +import { log, buildEnvPrefix } from "../../shared" + +export * from "./constants" +export * from "./detector" +export * from "./types" + +const BANNED_COMMAND_PATTERNS = SHELL_COMMAND_PATTERNS.banned + .filter((command) => !command.includes("(")) + .map((cmd) => new RegExp(`\\b${cmd}\\b`)) + +function detectBannedCommand(command: string): string | undefined { + for (let i = 0; i < BANNED_COMMAND_PATTERNS.length; i++) { + if (BANNED_COMMAND_PATTERNS[i].test(command)) { + return SHELL_COMMAND_PATTERNS.banned[i] + } + } + return undefined +} + +export function createNonInteractiveEnvHook(_ctx: PluginInput) { + return { + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record; message?: string } + ): Promise => { + if (input.tool.toLowerCase() !== "bash") { + return + } + + const command = output.args.command as string | undefined + if (!command) { + return + } + + const bannedCmd = detectBannedCommand(command) + if (bannedCmd) { + output.message = `Warning: '${bannedCmd}' is an interactive command that may hang in non-interactive environments.` + } + + // Only prepend env vars for git commands (editor blocking, pager, etc.) + const isGitCommand = /\bgit\b/.test(command) + if (!isGitCommand) { + return + } + + // NOTE: We intentionally removed the isNonInteractive() check here. + // Even when OpenCode runs in a TTY, the agent cannot interact with + // spawned bash processes. Git commands like `git rebase --continue` + // would open editors (vim/nvim) that hang forever. + // The env vars (GIT_EDITOR=:, EDITOR=:, etc.) must ALWAYS be injected + // for git commands to prevent interactive prompts. + + // The bash tool always runs in a Unix-like shell (bash/sh), even on Windows + // (via Git Bash, WSL, etc.), so always use unix export syntax. + const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, "unix") + output.args.command = `${envPrefix} ${command}` + + log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, { + sessionID: input.sessionID, + envPrefix, + }) + }, + } +} diff --git a/src/hooks/ralph-loop/index.ts b/src/hooks/ralph-loop/index.ts index 3cc77edd2..f85290677 100644 --- a/src/hooks/ralph-loop/index.ts +++ b/src/hooks/ralph-loop/index.ts @@ -1,428 +1,6 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { existsSync, readFileSync, readdirSync } from "node:fs" -import { join } from "node:path" -import { log } from "../../shared/logger" -import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" -import { readState, writeState, clearState, incrementIteration } from "./storage" -import { - HOOK_NAME, - DEFAULT_MAX_ITERATIONS, - DEFAULT_COMPLETION_PROMISE, -} from "./constants" -import type { RalphLoopState, RalphLoopOptions } from "./types" -import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript" -import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" - -function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - return null -} - export * from "./types" export * from "./constants" export { readState, writeState, clearState, incrementIteration } from "./storage" -interface SessionState { - isRecovering?: boolean -} - -interface OpenCodeSessionMessage { - info?: { - role?: string - } - parts?: Array<{ - type: string - text?: string - [key: string]: unknown - }> -} - -const CONTINUATION_PROMPT = `${SYSTEM_DIRECTIVE_PREFIX} - RALPH LOOP {{ITERATION}}/{{MAX}}] - -Your previous attempt did not output the completion promise. Continue working on the task. - -IMPORTANT: -- Review your progress so far -- Continue from where you left off -- When FULLY complete, output: {{PROMISE}} -- Do not stop until the task is truly done - -Original task: -{{PROMPT}}` - -export interface RalphLoopHook { - event: (input: { event: { type: string; properties?: unknown } }) => Promise - startLoop: ( - sessionID: string, - prompt: string, - options?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean } - ) => boolean - cancelLoop: (sessionID: string) => boolean - getState: () => RalphLoopState | null -} - -const DEFAULT_API_TIMEOUT = 3000 - -export function createRalphLoopHook( - ctx: PluginInput, - options?: RalphLoopOptions -): RalphLoopHook { - const sessions = new Map() - const config = options?.config - const stateDir = config?.state_dir - const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath - const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT - const checkSessionExists = options?.checkSessionExists - - function getSessionState(sessionID: string): SessionState { - let state = sessions.get(sessionID) - if (!state) { - state = {} - sessions.set(sessionID, state) - } - return state - } - - function detectCompletionPromise( - transcriptPath: string | undefined, - promise: string - ): boolean { - if (!transcriptPath) return false - - try { - if (!existsSync(transcriptPath)) return false - - const content = readFileSync(transcriptPath, "utf-8") - const pattern = new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is") - const lines = content.split("\n").filter(l => l.trim()) - - for (const line of lines) { - try { - const entry = JSON.parse(line) - if (entry.type === "user") continue - if (pattern.test(line)) return true - } catch { - continue - } - } - return false - } catch { - return false - } - } - - function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - } - - async function detectCompletionInSessionMessages( - sessionID: string, - promise: string - ): Promise { - try { - const response = await Promise.race([ - ctx.client.session.messages({ - path: { id: sessionID }, - query: { directory: ctx.directory }, - }), - new Promise((_, reject) => - setTimeout(() => reject(new Error("API timeout")), apiTimeout) - ), - ]) - - const messages = (response as { data?: unknown[] }).data ?? [] - if (!Array.isArray(messages)) return false - - const assistantMessages = (messages as OpenCodeSessionMessage[]).filter( - (msg) => msg.info?.role === "assistant" - ) - const lastAssistant = assistantMessages[assistantMessages.length - 1] - if (!lastAssistant?.parts) return false - - const pattern = new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is") - const responseText = lastAssistant.parts - .filter((p) => p.type === "text") - .map((p) => p.text ?? "") - .join("\n") - - return pattern.test(responseText) - } catch (err) { - log(`[${HOOK_NAME}] Session messages check failed`, { sessionID, error: String(err) }) - return false - } - } - - const startLoop = ( - sessionID: string, - prompt: string, - loopOptions?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean } - ): boolean => { - const state: RalphLoopState = { - active: true, - iteration: 1, - max_iterations: - loopOptions?.maxIterations ?? config?.default_max_iterations ?? DEFAULT_MAX_ITERATIONS, - completion_promise: loopOptions?.completionPromise ?? DEFAULT_COMPLETION_PROMISE, - ultrawork: loopOptions?.ultrawork, - started_at: new Date().toISOString(), - prompt, - session_id: sessionID, - } - - const success = writeState(ctx.directory, state, stateDir) - if (success) { - log(`[${HOOK_NAME}] Loop started`, { - sessionID, - maxIterations: state.max_iterations, - completionPromise: state.completion_promise, - }) - } - return success - } - - const cancelLoop = (sessionID: string): boolean => { - const state = readState(ctx.directory, stateDir) - if (!state || state.session_id !== sessionID) { - return false - } - - const success = clearState(ctx.directory, stateDir) - if (success) { - log(`[${HOOK_NAME}] Loop cancelled`, { sessionID, iteration: state.iteration }) - } - return success - } - - const getState = (): RalphLoopState | null => { - return readState(ctx.directory, stateDir) - } - - const event = async ({ - event, - }: { - event: { type: string; properties?: unknown } - }): Promise => { - const props = event.properties as Record | undefined - - if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - const sessionState = getSessionState(sessionID) - if (sessionState.isRecovering) { - log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID }) - return - } - - const state = readState(ctx.directory, stateDir) - if (!state || !state.active) { - return - } - - if (state.session_id && state.session_id !== sessionID) { - if (checkSessionExists) { - try { - const originalSessionExists = await checkSessionExists(state.session_id) - if (!originalSessionExists) { - clearState(ctx.directory, stateDir) - log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, { - orphanedSessionId: state.session_id, - currentSessionId: sessionID, - }) - return - } - } catch (err) { - log(`[${HOOK_NAME}] Failed to check session existence`, { - sessionId: state.session_id, - error: String(err), - }) - } - } - return - } - - const transcriptPath = getTranscriptPath(sessionID) - const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise) - - const completionDetectedViaApi = completionDetectedViaTranscript - ? false - : await detectCompletionInSessionMessages(sessionID, state.completion_promise) - - if (completionDetectedViaTranscript || completionDetectedViaApi) { - log(`[${HOOK_NAME}] Completion detected!`, { - sessionID, - iteration: state.iteration, - promise: state.completion_promise, - detectedVia: completionDetectedViaTranscript ? "transcript_file" : "session_messages_api", - }) - clearState(ctx.directory, stateDir) - - const title = state.ultrawork - ? "ULTRAWORK LOOP COMPLETE!" - : "Ralph Loop Complete!" - const message = state.ultrawork - ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` - : `Task completed after ${state.iteration} iteration(s)` - - await ctx.client.tui - .showToast({ - body: { - title, - message, - variant: "success", - duration: 5000, - }, - }) - .catch(() => {}) - - return - } - - if (state.iteration >= state.max_iterations) { - log(`[${HOOK_NAME}] Max iterations reached`, { - sessionID, - iteration: state.iteration, - max: state.max_iterations, - }) - clearState(ctx.directory, stateDir) - - await ctx.client.tui - .showToast({ - body: { - title: "Ralph Loop Stopped", - message: `Max iterations (${state.max_iterations}) reached without completion`, - variant: "warning", - duration: 5000, - }, - }) - .catch(() => {}) - - return - } - - const newState = incrementIteration(ctx.directory, stateDir) - if (!newState) { - log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID }) - return - } - - log(`[${HOOK_NAME}] Continuing loop`, { - sessionID, - iteration: newState.iteration, - max: newState.max_iterations, - }) - - const continuationPrompt = CONTINUATION_PROMPT.replace("{{ITERATION}}", String(newState.iteration)) - .replace("{{MAX}}", String(newState.max_iterations)) - .replace("{{PROMISE}}", newState.completion_promise) - .replace("{{PROMPT}}", newState.prompt) - - const finalPrompt = newState.ultrawork - ? `ultrawork ${continuationPrompt}` - : continuationPrompt - - await ctx.client.tui - .showToast({ - body: { - title: "Ralph Loop", - message: `Iteration ${newState.iteration}/${newState.max_iterations}`, - variant: "info", - duration: 2000, - }, - }) - .catch(() => {}) - - try { - let agent: string | undefined - let model: { providerID: string; modelID: string } | undefined - - try { - const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } }) - const messages = (messagesResp.data ?? []) as Array<{ - info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string } - }> - for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { - agent = info.agent - model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined) - break - } - } - } catch { - const messageDir = getMessageDir(sessionID) - const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - agent = currentMessage?.agent - model = currentMessage?.model?.providerID && currentMessage?.model?.modelID - ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } - : undefined - } - - await ctx.client.session.promptAsync({ - path: { id: sessionID }, - body: { - ...(agent !== undefined ? { agent } : {}), - ...(model !== undefined ? { model } : {}), - parts: [{ type: "text", text: finalPrompt }], - }, - query: { directory: ctx.directory }, - }) - } catch (err) { - log(`[${HOOK_NAME}] Failed to inject continuation`, { - sessionID, - error: String(err), - }) - } - } - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - const state = readState(ctx.directory, stateDir) - if (state?.session_id === sessionInfo.id) { - clearState(ctx.directory, stateDir) - log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id }) - } - sessions.delete(sessionInfo.id) - } - } - - if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined - const error = props?.error as { name?: string } | undefined - - if (error?.name === "MessageAbortedError") { - if (sessionID) { - const state = readState(ctx.directory, stateDir) - if (state?.session_id === sessionID) { - clearState(ctx.directory, stateDir) - log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID }) - } - sessions.delete(sessionID) - } - return - } - - if (sessionID) { - const sessionState = getSessionState(sessionID) - sessionState.isRecovering = true - setTimeout(() => { - sessionState.isRecovering = false - }, 5000) - } - } - } - - return { - event, - startLoop, - cancelLoop, - getState, - } -} +export { createRalphLoopHook } from "./ralph-loop-hook" +export type { RalphLoopHook } from "./ralph-loop-hook" diff --git a/src/hooks/ralph-loop/ralph-loop-hook.ts b/src/hooks/ralph-loop/ralph-loop-hook.ts new file mode 100644 index 000000000..6be3a5e8e --- /dev/null +++ b/src/hooks/ralph-loop/ralph-loop-hook.ts @@ -0,0 +1,428 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { existsSync, readFileSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { log } from "../../shared/logger" +import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" +import { readState, writeState, clearState, incrementIteration } from "./storage" +import { + HOOK_NAME, + DEFAULT_MAX_ITERATIONS, + DEFAULT_COMPLETION_PROMISE, +} from "./constants" +import type { RalphLoopState, RalphLoopOptions } from "./types" +import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript" +import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" + +function getMessageDir(sessionID: string): string | null { + if (!existsSync(MESSAGE_STORAGE)) return null + const directPath = join(MESSAGE_STORAGE, sessionID) + if (existsSync(directPath)) return directPath + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) + if (existsSync(sessionPath)) return sessionPath + } + return null +} + +export * from "./types" +export * from "./constants" +export { readState, writeState, clearState, incrementIteration } from "./storage" + +interface SessionState { + isRecovering?: boolean +} + +interface OpenCodeSessionMessage { + info?: { + role?: string + } + parts?: Array<{ + type: string + text?: string + [key: string]: unknown + }> +} + +const CONTINUATION_PROMPT = `${SYSTEM_DIRECTIVE_PREFIX} - RALPH LOOP {{ITERATION}}/{{MAX}}] + +Your previous attempt did not output the completion promise. Continue working on the task. + +IMPORTANT: +- Review your progress so far +- Continue from where you left off +- When FULLY complete, output: {{PROMISE}} +- Do not stop until the task is truly done + +Original task: +{{PROMPT}}` + +export interface RalphLoopHook { + event: (input: { event: { type: string; properties?: unknown } }) => Promise + startLoop: ( + sessionID: string, + prompt: string, + options?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean } + ) => boolean + cancelLoop: (sessionID: string) => boolean + getState: () => RalphLoopState | null +} + +const DEFAULT_API_TIMEOUT = 3000 as const + +export function createRalphLoopHook( + ctx: PluginInput, + options?: RalphLoopOptions +): RalphLoopHook { + const sessions = new Map() + const config = options?.config + const stateDir = config?.state_dir + const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath + const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT + const checkSessionExists = options?.checkSessionExists + + function getSessionState(sessionID: string): SessionState { + let state = sessions.get(sessionID) + if (!state) { + state = {} + sessions.set(sessionID, state) + } + return state + } + + function detectCompletionPromise( + transcriptPath: string | undefined, + promise: string + ): boolean { + if (!transcriptPath) return false + + try { + if (!existsSync(transcriptPath)) return false + + const content = readFileSync(transcriptPath, "utf-8") + const pattern = new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is") + const lines = content.split("\n").filter(l => l.trim()) + + for (const line of lines) { + try { + const entry = JSON.parse(line) + if (entry.type === "user") continue + if (pattern.test(line)) return true + } catch { + continue + } + } + return false + } catch { + return false + } + } + + function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + } + + async function detectCompletionInSessionMessages( + sessionID: string, + promise: string + ): Promise { + try { + const response = await Promise.race([ + ctx.client.session.messages({ + path: { id: sessionID }, + query: { directory: ctx.directory }, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("API timeout")), apiTimeout) + ), + ]) + + const messages = (response as { data?: unknown[] }).data ?? [] + if (!Array.isArray(messages)) return false + + const assistantMessages = (messages as OpenCodeSessionMessage[]).filter( + (msg) => msg.info?.role === "assistant" + ) + const lastAssistant = assistantMessages[assistantMessages.length - 1] + if (!lastAssistant?.parts) return false + + const pattern = new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is") + const responseText = lastAssistant.parts + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join("\n") + + return pattern.test(responseText) + } catch (err) { + log(`[${HOOK_NAME}] Session messages check failed`, { sessionID, error: String(err) }) + return false + } + } + + const startLoop = ( + sessionID: string, + prompt: string, + loopOptions?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean } + ): boolean => { + const state: RalphLoopState = { + active: true, + iteration: 1, + max_iterations: + loopOptions?.maxIterations ?? config?.default_max_iterations ?? DEFAULT_MAX_ITERATIONS, + completion_promise: loopOptions?.completionPromise ?? DEFAULT_COMPLETION_PROMISE, + ultrawork: loopOptions?.ultrawork, + started_at: new Date().toISOString(), + prompt, + session_id: sessionID, + } + + const success = writeState(ctx.directory, state, stateDir) + if (success) { + log(`[${HOOK_NAME}] Loop started`, { + sessionID, + maxIterations: state.max_iterations, + completionPromise: state.completion_promise, + }) + } + return success + } + + const cancelLoop = (sessionID: string): boolean => { + const state = readState(ctx.directory, stateDir) + if (!state || state.session_id !== sessionID) { + return false + } + + const success = clearState(ctx.directory, stateDir) + if (success) { + log(`[${HOOK_NAME}] Loop cancelled`, { sessionID, iteration: state.iteration }) + } + return success + } + + const getState = (): RalphLoopState | null => { + return readState(ctx.directory, stateDir) + } + + const event = async ({ + event, + }: { + event: { type: string; properties?: unknown } + }): Promise => { + const props = event.properties as Record | undefined + + if (event.type === "session.idle") { + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + + const sessionState = getSessionState(sessionID) + if (sessionState.isRecovering) { + log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID }) + return + } + + const state = readState(ctx.directory, stateDir) + if (!state || !state.active) { + return + } + + if (state.session_id && state.session_id !== sessionID) { + if (checkSessionExists) { + try { + const originalSessionExists = await checkSessionExists(state.session_id) + if (!originalSessionExists) { + clearState(ctx.directory, stateDir) + log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, { + orphanedSessionId: state.session_id, + currentSessionId: sessionID, + }) + return + } + } catch (err) { + log(`[${HOOK_NAME}] Failed to check session existence`, { + sessionId: state.session_id, + error: String(err), + }) + } + } + return + } + + const transcriptPath = getTranscriptPath(sessionID) + const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise) + + const completionDetectedViaApi = completionDetectedViaTranscript + ? false + : await detectCompletionInSessionMessages(sessionID, state.completion_promise) + + if (completionDetectedViaTranscript || completionDetectedViaApi) { + log(`[${HOOK_NAME}] Completion detected!`, { + sessionID, + iteration: state.iteration, + promise: state.completion_promise, + detectedVia: completionDetectedViaTranscript ? "transcript_file" : "session_messages_api", + }) + clearState(ctx.directory, stateDir) + + const title = state.ultrawork + ? "ULTRAWORK LOOP COMPLETE!" + : "Ralph Loop Complete!" + const message = state.ultrawork + ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` + : `Task completed after ${state.iteration} iteration(s)` + + await ctx.client.tui + .showToast({ + body: { + title, + message, + variant: "success", + duration: 5000, + }, + }) + .catch(() => {}) + + return + } + + if (state.iteration >= state.max_iterations) { + log(`[${HOOK_NAME}] Max iterations reached`, { + sessionID, + iteration: state.iteration, + max: state.max_iterations, + }) + clearState(ctx.directory, stateDir) + + await ctx.client.tui + .showToast({ + body: { + title: "Ralph Loop Stopped", + message: `Max iterations (${state.max_iterations}) reached without completion`, + variant: "warning", + duration: 5000, + }, + }) + .catch(() => {}) + + return + } + + const newState = incrementIteration(ctx.directory, stateDir) + if (!newState) { + log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID }) + return + } + + log(`[${HOOK_NAME}] Continuing loop`, { + sessionID, + iteration: newState.iteration, + max: newState.max_iterations, + }) + + const continuationPrompt = CONTINUATION_PROMPT.replace("{{ITERATION}}", String(newState.iteration)) + .replace("{{MAX}}", String(newState.max_iterations)) + .replace("{{PROMISE}}", newState.completion_promise) + .replace("{{PROMPT}}", newState.prompt) + + const finalPrompt = newState.ultrawork + ? `ultrawork ${continuationPrompt}` + : continuationPrompt + + await ctx.client.tui + .showToast({ + body: { + title: "Ralph Loop", + message: `Iteration ${newState.iteration}/${newState.max_iterations}`, + variant: "info", + duration: 2000, + }, + }) + .catch(() => {}) + + try { + let agent: string | undefined + let model: { providerID: string; modelID: string } | undefined + + try { + const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } }) + const messages = (messagesResp.data ?? []) as Array<{ + info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string } + }> + for (let i = messages.length - 1; i >= 0; i--) { + const info = messages[i].info + if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { + agent = info.agent + model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined) + break + } + } + } catch { + const messageDir = getMessageDir(sessionID) + const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + agent = currentMessage?.agent + model = currentMessage?.model?.providerID && currentMessage?.model?.modelID + ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } + : undefined + } + + await ctx.client.session.promptAsync({ + path: { id: sessionID }, + body: { + ...(agent !== undefined ? { agent } : {}), + ...(model !== undefined ? { model } : {}), + parts: [{ type: "text", text: finalPrompt }], + }, + query: { directory: ctx.directory }, + }) + } catch (err) { + log(`[${HOOK_NAME}] Failed to inject continuation`, { + sessionID, + error: String(err), + }) + } + } + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id) { + const state = readState(ctx.directory, stateDir) + if (state?.session_id === sessionInfo.id) { + clearState(ctx.directory, stateDir) + log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id }) + } + sessions.delete(sessionInfo.id) + } + } + + if (event.type === "session.error") { + const sessionID = props?.sessionID as string | undefined + const error = props?.error as { name?: string } | undefined + + if (error?.name === "MessageAbortedError") { + if (sessionID) { + const state = readState(ctx.directory, stateDir) + if (state?.session_id === sessionID) { + clearState(ctx.directory, stateDir) + log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID }) + } + sessions.delete(sessionID) + } + return + } + + if (sessionID) { + const sessionState = getSessionState(sessionID) + sessionState.isRecovering = true + setTimeout(() => { + sessionState.isRecovering = false + }, 5000) + } + } + } + + return { + event, + startLoop, + cancelLoop, + getState, + } +} diff --git a/src/hooks/session-recovery/detect-error-type.ts b/src/hooks/session-recovery/detect-error-type.ts new file mode 100644 index 000000000..763370d16 --- /dev/null +++ b/src/hooks/session-recovery/detect-error-type.ts @@ -0,0 +1,65 @@ +export type RecoveryErrorType = + | "tool_result_missing" + | "thinking_block_order" + | "thinking_disabled_violation" + | null + +function getErrorMessage(error: unknown): string { + if (!error) return "" + if (typeof error === "string") return error.toLowerCase() + + const errorObj = error as Record + const paths = [ + errorObj.data, + errorObj.error, + errorObj, + (errorObj.data as Record)?.error, + ] + + for (const obj of paths) { + if (obj && typeof obj === "object") { + const msg = (obj as Record).message + if (typeof msg === "string" && msg.length > 0) { + return msg.toLowerCase() + } + } + } + + try { + return JSON.stringify(error).toLowerCase() + } catch { + return "" + } +} + +export function extractMessageIndex(error: unknown): number | null { + const message = getErrorMessage(error) + const match = message.match(/messages\.(\d+)/) + return match ? parseInt(match[1], 10) : null +} + +export function detectErrorType(error: unknown): RecoveryErrorType { + const message = getErrorMessage(error) + + if ( + message.includes("thinking") && + (message.includes("first block") || + message.includes("must start with") || + message.includes("preceeding") || + message.includes("final block") || + message.includes("cannot be thinking") || + (message.includes("expected") && message.includes("found"))) + ) { + return "thinking_block_order" + } + + if (message.includes("thinking is disabled") && message.includes("cannot contain")) { + return "thinking_disabled_violation" + } + + if (message.includes("tool_use") && message.includes("tool_result")) { + return "tool_result_missing" + } + + return null +} diff --git a/src/hooks/session-recovery/hook.ts b/src/hooks/session-recovery/hook.ts new file mode 100644 index 000000000..55a16662c --- /dev/null +++ b/src/hooks/session-recovery/hook.ts @@ -0,0 +1,141 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import type { ExperimentalConfig } from "../../config" +import { log } from "../../shared/logger" +import { detectErrorType } from "./detect-error-type" +import type { RecoveryErrorType } from "./detect-error-type" +import type { MessageData } from "./types" +import { recoverToolResultMissing } from "./recover-tool-result-missing" +import { recoverThinkingBlockOrder } from "./recover-thinking-block-order" +import { recoverThinkingDisabledViolation } from "./recover-thinking-disabled-violation" +import { extractResumeConfig, findLastUserMessage, resumeSession } from "./resume" + +interface MessageInfo { + id?: string + role?: string + sessionID?: string + parentID?: string + error?: unknown +} + +export interface SessionRecoveryOptions { + experimental?: ExperimentalConfig +} + +export interface SessionRecoveryHook { + handleSessionRecovery: (info: MessageInfo) => Promise + isRecoverableError: (error: unknown) => boolean + setOnAbortCallback: (callback: (sessionID: string) => void) => void + setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void +} + +export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRecoveryOptions): SessionRecoveryHook { + const processingErrors = new Set() + const experimental = options?.experimental + let onAbortCallback: ((sessionID: string) => void) | null = null + let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null + + const setOnAbortCallback = (callback: (sessionID: string) => void): void => { + onAbortCallback = callback + } + + const setOnRecoveryCompleteCallback = (callback: (sessionID: string) => void): void => { + onRecoveryCompleteCallback = callback + } + + const isRecoverableError = (error: unknown): boolean => { + return detectErrorType(error) !== null + } + + const handleSessionRecovery = async (info: MessageInfo): Promise => { + if (!info || info.role !== "assistant" || !info.error) return false + + const errorType = detectErrorType(info.error) + if (!errorType) return false + + const sessionID = info.sessionID + const assistantMsgID = info.id + + if (!sessionID || !assistantMsgID) return false + if (processingErrors.has(assistantMsgID)) return false + processingErrors.add(assistantMsgID) + + try { + if (onAbortCallback) { + onAbortCallback(sessionID) + } + + await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {}) + + const messagesResp = await ctx.client.session.messages({ + path: { id: sessionID }, + query: { directory: ctx.directory }, + }) + const msgs = (messagesResp as { data?: MessageData[] }).data + + const failedMsg = msgs?.find((m) => m.info?.id === assistantMsgID) + if (!failedMsg) { + return false + } + + const toastTitles: Record = { + tool_result_missing: "Tool Crash Recovery", + thinking_block_order: "Thinking Block Recovery", + thinking_disabled_violation: "Thinking Strip Recovery", + } + const toastMessages: Record = { + tool_result_missing: "Injecting cancelled tool results...", + thinking_block_order: "Fixing message structure...", + thinking_disabled_violation: "Stripping thinking blocks...", + } + + await ctx.client.tui + .showToast({ + body: { + title: toastTitles[errorType], + message: toastMessages[errorType], + variant: "warning", + duration: 3000, + }, + }) + .catch(() => {}) + + let success = false + + if (errorType === "tool_result_missing") { + success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg) + } else if (errorType === "thinking_block_order") { + success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error) + if (success && experimental?.auto_resume) { + const lastUser = findLastUserMessage(msgs ?? []) + const resumeConfig = extractResumeConfig(lastUser, sessionID) + await resumeSession(ctx.client, resumeConfig) + } + } else if (errorType === "thinking_disabled_violation") { + success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg) + if (success && experimental?.auto_resume) { + const lastUser = findLastUserMessage(msgs ?? []) + const resumeConfig = extractResumeConfig(lastUser, sessionID) + await resumeSession(ctx.client, resumeConfig) + } + } + + return success + } catch (err) { + log("[session-recovery] Recovery failed:", err) + return false + } finally { + processingErrors.delete(assistantMsgID) + + if (sessionID && onRecoveryCompleteCallback) { + onRecoveryCompleteCallback(sessionID) + } + } + } + + return { + handleSessionRecovery, + isRecoverableError, + setOnAbortCallback, + setOnRecoveryCompleteCallback, + } +} diff --git a/src/hooks/session-recovery/index.ts b/src/hooks/session-recovery/index.ts index 2aecee157..f1ecc4366 100644 --- a/src/hooks/session-recovery/index.ts +++ b/src/hooks/session-recovery/index.ts @@ -1,436 +1,7 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import type { createOpencodeClient } from "@opencode-ai/sdk" -import type { ExperimentalConfig } from "../../config" -import { - findEmptyMessages, - findEmptyMessageByIndex, - findMessageByIndexNeedingThinking, - findMessagesWithEmptyTextParts, - findMessagesWithOrphanThinking, - findMessagesWithThinkingBlocks, - findMessagesWithThinkingOnly, - injectTextPart, - prependThinkingPart, - readParts, - replaceEmptyTextParts, - stripThinkingParts, -} from "./storage" -import type { MessageData, ResumeConfig } from "./types" -import { log } from "../../shared/logger" +export { createSessionRecoveryHook } from "./hook" +export type { SessionRecoveryHook, SessionRecoveryOptions } from "./hook" -export interface SessionRecoveryOptions { - experimental?: ExperimentalConfig -} +export { detectErrorType } from "./detect-error-type" +export type { RecoveryErrorType } from "./detect-error-type" -type Client = ReturnType - -type RecoveryErrorType = - | "tool_result_missing" - | "thinking_block_order" - | "thinking_disabled_violation" - | null - -interface MessageInfo { - id?: string - role?: string - sessionID?: string - parentID?: string - error?: unknown -} - -interface ToolUsePart { - type: "tool_use" - id: string - name: string - input: Record -} - -interface MessagePart { - type: string - id?: string - text?: string - thinking?: string - name?: string - input?: Record -} - -const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]" - -function findLastUserMessage(messages: MessageData[]): MessageData | undefined { - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].info?.role === "user") { - return messages[i] - } - } - return undefined -} - -function extractResumeConfig(userMessage: MessageData | undefined, sessionID: string): ResumeConfig { - return { - sessionID, - agent: userMessage?.info?.agent, - model: userMessage?.info?.model, - } -} - -async function resumeSession(client: Client, config: ResumeConfig): Promise { - try { - await client.session.promptAsync({ - path: { id: config.sessionID }, - body: { - parts: [{ type: "text", text: RECOVERY_RESUME_TEXT }], - agent: config.agent, - model: config.model, - }, - }) - return true - } catch { - return false - } -} - -function getErrorMessage(error: unknown): string { - if (!error) return "" - if (typeof error === "string") return error.toLowerCase() - - const errorObj = error as Record - const paths = [ - errorObj.data, - errorObj.error, - errorObj, - (errorObj.data as Record)?.error, - ] - - for (const obj of paths) { - if (obj && typeof obj === "object") { - const msg = (obj as Record).message - if (typeof msg === "string" && msg.length > 0) { - return msg.toLowerCase() - } - } - } - - try { - return JSON.stringify(error).toLowerCase() - } catch { - return "" - } -} - -function extractMessageIndex(error: unknown): number | null { - const message = getErrorMessage(error) - const match = message.match(/messages\.(\d+)/) - return match ? parseInt(match[1], 10) : null -} - -export function detectErrorType(error: unknown): RecoveryErrorType { - const message = getErrorMessage(error) - - // IMPORTANT: Check thinking_block_order BEFORE tool_result_missing - // because Anthropic's extended thinking error messages contain "tool_use" and "tool_result" - // in the documentation URL, which would incorrectly match tool_result_missing - if ( - message.includes("thinking") && - (message.includes("first block") || - message.includes("must start with") || - message.includes("preceeding") || - message.includes("final block") || - message.includes("cannot be thinking") || - (message.includes("expected") && message.includes("found"))) - ) { - return "thinking_block_order" - } - - if (message.includes("thinking is disabled") && message.includes("cannot contain")) { - return "thinking_disabled_violation" - } - - if (message.includes("tool_use") && message.includes("tool_result")) { - return "tool_result_missing" - } - - return null -} - -function extractToolUseIds(parts: MessagePart[]): string[] { - return parts.filter((p): p is ToolUsePart => p.type === "tool_use" && !!p.id).map((p) => p.id) -} - -async function recoverToolResultMissing( - client: Client, - sessionID: string, - failedAssistantMsg: MessageData -): Promise { - // Try API parts first, fallback to filesystem if empty - let parts = failedAssistantMsg.parts || [] - if (parts.length === 0 && failedAssistantMsg.info?.id) { - const storedParts = readParts(failedAssistantMsg.info.id) - parts = storedParts.map((p) => ({ - type: p.type === "tool" ? "tool_use" : p.type, - id: "callID" in p ? (p as { callID?: string }).callID : p.id, - name: "tool" in p ? (p as { tool?: string }).tool : undefined, - input: "state" in p ? (p as { state?: { input?: Record } }).state?.input : undefined, - })) - } - const toolUseIds = extractToolUseIds(parts) - - if (toolUseIds.length === 0) { - return false - } - - const toolResultParts = toolUseIds.map((id) => ({ - type: "tool_result" as const, - tool_use_id: id, - content: "Operation cancelled by user (ESC pressed)", - })) - - try { - await client.session.promptAsync({ - path: { id: sessionID }, - // @ts-expect-error - SDK types may not include tool_result parts - body: { parts: toolResultParts }, - }) - - return true - } catch { - return false - } -} - -async function recoverThinkingBlockOrder( - _client: Client, - sessionID: string, - _failedAssistantMsg: MessageData, - _directory: string, - error: unknown -): Promise { - const targetIndex = extractMessageIndex(error) - if (targetIndex !== null) { - const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex) - if (targetMessageID) { - return prependThinkingPart(sessionID, targetMessageID) - } - } - - const orphanMessages = findMessagesWithOrphanThinking(sessionID) - - if (orphanMessages.length === 0) { - return false - } - - let anySuccess = false - for (const messageID of orphanMessages) { - if (prependThinkingPart(sessionID, messageID)) { - anySuccess = true - } - } - - return anySuccess -} - -async function recoverThinkingDisabledViolation( - _client: Client, - sessionID: string, - _failedAssistantMsg: MessageData -): Promise { - const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID) - - if (messagesWithThinking.length === 0) { - return false - } - - let anySuccess = false - for (const messageID of messagesWithThinking) { - if (stripThinkingParts(messageID)) { - anySuccess = true - } - } - - return anySuccess -} - -const PLACEHOLDER_TEXT = "[user interrupted]" - -async function recoverEmptyContentMessage( - _client: Client, - sessionID: string, - failedAssistantMsg: MessageData, - _directory: string, - error: unknown -): Promise { - const targetIndex = extractMessageIndex(error) - const failedID = failedAssistantMsg.info?.id - let anySuccess = false - - const messagesWithEmptyText = findMessagesWithEmptyTextParts(sessionID) - for (const messageID of messagesWithEmptyText) { - if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) { - anySuccess = true - } - } - - const thinkingOnlyIDs = findMessagesWithThinkingOnly(sessionID) - for (const messageID of thinkingOnlyIDs) { - if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) { - anySuccess = true - } - } - - if (targetIndex !== null) { - const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex) - if (targetMessageID) { - if (replaceEmptyTextParts(targetMessageID, PLACEHOLDER_TEXT)) { - return true - } - if (injectTextPart(sessionID, targetMessageID, PLACEHOLDER_TEXT)) { - return true - } - } - } - - if (failedID) { - if (replaceEmptyTextParts(failedID, PLACEHOLDER_TEXT)) { - return true - } - if (injectTextPart(sessionID, failedID, PLACEHOLDER_TEXT)) { - return true - } - } - - const emptyMessageIDs = findEmptyMessages(sessionID) - for (const messageID of emptyMessageIDs) { - if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) { - anySuccess = true - } - if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) { - anySuccess = true - } - } - - return anySuccess -} - -// NOTE: fallbackRevertStrategy was removed (2025-12-08) -// Reason: Function was defined but never called - no error recovery paths used it. -// All error types have dedicated recovery functions (recoverToolResultMissing, -// recoverThinkingBlockOrder, recoverThinkingDisabledViolation, recoverEmptyContentMessage). - -export interface SessionRecoveryHook { - handleSessionRecovery: (info: MessageInfo) => Promise - isRecoverableError: (error: unknown) => boolean - setOnAbortCallback: (callback: (sessionID: string) => void) => void - setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void -} - -export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRecoveryOptions): SessionRecoveryHook { - const processingErrors = new Set() - const experimental = options?.experimental - let onAbortCallback: ((sessionID: string) => void) | null = null - let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null - - const setOnAbortCallback = (callback: (sessionID: string) => void): void => { - onAbortCallback = callback - } - - const setOnRecoveryCompleteCallback = (callback: (sessionID: string) => void): void => { - onRecoveryCompleteCallback = callback - } - - const isRecoverableError = (error: unknown): boolean => { - return detectErrorType(error) !== null - } - - const handleSessionRecovery = async (info: MessageInfo): Promise => { - if (!info || info.role !== "assistant" || !info.error) return false - - const errorType = detectErrorType(info.error) - if (!errorType) return false - - const sessionID = info.sessionID - const assistantMsgID = info.id - - if (!sessionID || !assistantMsgID) return false - if (processingErrors.has(assistantMsgID)) return false - processingErrors.add(assistantMsgID) - - try { - if (onAbortCallback) { - onAbortCallback(sessionID) // Mark recovering BEFORE abort - } - - await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {}) - - const messagesResp = await ctx.client.session.messages({ - path: { id: sessionID }, - query: { directory: ctx.directory }, - }) - const msgs = (messagesResp as { data?: MessageData[] }).data - - const failedMsg = msgs?.find((m) => m.info?.id === assistantMsgID) - if (!failedMsg) { - return false - } - - const toastTitles: Record = { - tool_result_missing: "Tool Crash Recovery", - thinking_block_order: "Thinking Block Recovery", - thinking_disabled_violation: "Thinking Strip Recovery", - } - const toastMessages: Record = { - tool_result_missing: "Injecting cancelled tool results...", - thinking_block_order: "Fixing message structure...", - thinking_disabled_violation: "Stripping thinking blocks...", - } - - await ctx.client.tui - .showToast({ - body: { - title: toastTitles[errorType], - message: toastMessages[errorType], - variant: "warning", - duration: 3000, - }, - }) - .catch(() => {}) - - let success = false - - if (errorType === "tool_result_missing") { - success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg) - } else if (errorType === "thinking_block_order") { - success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error) - if (success && experimental?.auto_resume) { - const lastUser = findLastUserMessage(msgs ?? []) - const resumeConfig = extractResumeConfig(lastUser, sessionID) - await resumeSession(ctx.client, resumeConfig) - } - } else if (errorType === "thinking_disabled_violation") { - success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg) - if (success && experimental?.auto_resume) { - const lastUser = findLastUserMessage(msgs ?? []) - const resumeConfig = extractResumeConfig(lastUser, sessionID) - await resumeSession(ctx.client, resumeConfig) - } - } - - return success - } catch (err) { - log("[session-recovery] Recovery failed:", err) - return false - } finally { - processingErrors.delete(assistantMsgID) - - // Always notify recovery complete, regardless of success or failure - if (sessionID && onRecoveryCompleteCallback) { - onRecoveryCompleteCallback(sessionID) - } - } - } - - return { - handleSessionRecovery, - isRecoverableError, - setOnAbortCallback, - setOnRecoveryCompleteCallback, - } -} +export type { MessageData, ResumeConfig } from "./types" diff --git a/src/hooks/session-recovery/recover-empty-content-message.ts b/src/hooks/session-recovery/recover-empty-content-message.ts new file mode 100644 index 000000000..f095eb2e8 --- /dev/null +++ b/src/hooks/session-recovery/recover-empty-content-message.ts @@ -0,0 +1,74 @@ +import type { createOpencodeClient } from "@opencode-ai/sdk" +import type { MessageData } from "./types" +import { extractMessageIndex } from "./detect-error-type" +import { + findEmptyMessageByIndex, + findEmptyMessages, + findMessagesWithEmptyTextParts, + findMessagesWithThinkingOnly, + injectTextPart, + replaceEmptyTextParts, +} from "./storage" + +type Client = ReturnType + +const PLACEHOLDER_TEXT = "[user interrupted]" + +export async function recoverEmptyContentMessage( + _client: Client, + sessionID: string, + failedAssistantMsg: MessageData, + _directory: string, + error: unknown +): Promise { + const targetIndex = extractMessageIndex(error) + const failedID = failedAssistantMsg.info?.id + let anySuccess = false + + const messagesWithEmptyText = findMessagesWithEmptyTextParts(sessionID) + for (const messageID of messagesWithEmptyText) { + if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) { + anySuccess = true + } + } + + const thinkingOnlyIDs = findMessagesWithThinkingOnly(sessionID) + for (const messageID of thinkingOnlyIDs) { + if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) { + anySuccess = true + } + } + + if (targetIndex !== null) { + const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex) + if (targetMessageID) { + if (replaceEmptyTextParts(targetMessageID, PLACEHOLDER_TEXT)) { + return true + } + if (injectTextPart(sessionID, targetMessageID, PLACEHOLDER_TEXT)) { + return true + } + } + } + + if (failedID) { + if (replaceEmptyTextParts(failedID, PLACEHOLDER_TEXT)) { + return true + } + if (injectTextPart(sessionID, failedID, PLACEHOLDER_TEXT)) { + return true + } + } + + const emptyMessageIDs = findEmptyMessages(sessionID) + for (const messageID of emptyMessageIDs) { + if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) { + anySuccess = true + } + if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) { + anySuccess = true + } + } + + return anySuccess +} diff --git a/src/hooks/session-recovery/recover-thinking-block-order.ts b/src/hooks/session-recovery/recover-thinking-block-order.ts new file mode 100644 index 000000000..f26bf4f11 --- /dev/null +++ b/src/hooks/session-recovery/recover-thinking-block-order.ts @@ -0,0 +1,36 @@ +import type { createOpencodeClient } from "@opencode-ai/sdk" +import type { MessageData } from "./types" +import { extractMessageIndex } from "./detect-error-type" +import { findMessageByIndexNeedingThinking, findMessagesWithOrphanThinking, prependThinkingPart } from "./storage" + +type Client = ReturnType + +export async function recoverThinkingBlockOrder( + _client: Client, + sessionID: string, + _failedAssistantMsg: MessageData, + _directory: string, + error: unknown +): Promise { + const targetIndex = extractMessageIndex(error) + if (targetIndex !== null) { + const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex) + if (targetMessageID) { + return prependThinkingPart(sessionID, targetMessageID) + } + } + + const orphanMessages = findMessagesWithOrphanThinking(sessionID) + if (orphanMessages.length === 0) { + return false + } + + let anySuccess = false + for (const messageID of orphanMessages) { + if (prependThinkingPart(sessionID, messageID)) { + anySuccess = true + } + } + + return anySuccess +} diff --git a/src/hooks/session-recovery/recover-thinking-disabled-violation.ts b/src/hooks/session-recovery/recover-thinking-disabled-violation.ts new file mode 100644 index 000000000..6eeded936 --- /dev/null +++ b/src/hooks/session-recovery/recover-thinking-disabled-violation.ts @@ -0,0 +1,25 @@ +import type { createOpencodeClient } from "@opencode-ai/sdk" +import type { MessageData } from "./types" +import { findMessagesWithThinkingBlocks, stripThinkingParts } from "./storage" + +type Client = ReturnType + +export async function recoverThinkingDisabledViolation( + _client: Client, + sessionID: string, + _failedAssistantMsg: MessageData +): Promise { + const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID) + if (messagesWithThinking.length === 0) { + return false + } + + let anySuccess = false + for (const messageID of messagesWithThinking) { + if (stripThinkingParts(messageID)) { + anySuccess = true + } + } + + return anySuccess +} diff --git a/src/hooks/session-recovery/recover-tool-result-missing.ts b/src/hooks/session-recovery/recover-tool-result-missing.ts new file mode 100644 index 000000000..1f114fe33 --- /dev/null +++ b/src/hooks/session-recovery/recover-tool-result-missing.ts @@ -0,0 +1,61 @@ +import type { createOpencodeClient } from "@opencode-ai/sdk" +import type { MessageData } from "./types" +import { readParts } from "./storage" + +type Client = ReturnType + +interface ToolUsePart { + type: "tool_use" + id: string + name: string + input: Record +} + +interface MessagePart { + type: string + id?: string +} + +function extractToolUseIds(parts: MessagePart[]): string[] { + return parts.filter((part): part is ToolUsePart => part.type === "tool_use" && !!part.id).map((part) => part.id) +} + +export async function recoverToolResultMissing( + client: Client, + sessionID: string, + failedAssistantMsg: MessageData +): Promise { + let parts = failedAssistantMsg.parts || [] + if (parts.length === 0 && failedAssistantMsg.info?.id) { + const storedParts = readParts(failedAssistantMsg.info.id) + parts = storedParts.map((part) => ({ + type: part.type === "tool" ? "tool_use" : part.type, + id: "callID" in part ? (part as { callID?: string }).callID : part.id, + })) + } + + const toolUseIds = extractToolUseIds(parts) + if (toolUseIds.length === 0) { + return false + } + + const toolResultParts = toolUseIds.map((id) => ({ + type: "tool_result" as const, + tool_use_id: id, + content: "Operation cancelled by user (ESC pressed)", + })) + + const promptInput = { + path: { id: sessionID }, + body: { parts: toolResultParts }, + } + + try { + // @ts-expect-error - SDK types may not include tool_result parts + await client.session.promptAsync(promptInput) + + return true + } catch { + return false + } +} diff --git a/src/hooks/session-recovery/resume.ts b/src/hooks/session-recovery/resume.ts new file mode 100644 index 000000000..48e6bfff0 --- /dev/null +++ b/src/hooks/session-recovery/resume.ts @@ -0,0 +1,39 @@ +import type { createOpencodeClient } from "@opencode-ai/sdk" +import type { MessageData, ResumeConfig } from "./types" + +const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]" + +type Client = ReturnType + +export function findLastUserMessage(messages: MessageData[]): MessageData | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].info?.role === "user") { + return messages[i] + } + } + return undefined +} + +export function extractResumeConfig(userMessage: MessageData | undefined, sessionID: string): ResumeConfig { + return { + sessionID, + agent: userMessage?.info?.agent, + model: userMessage?.info?.model, + } +} + +export async function resumeSession(client: Client, config: ResumeConfig): Promise { + try { + await client.session.promptAsync({ + path: { id: config.sessionID }, + body: { + parts: [{ type: "text", text: RECOVERY_RESUME_TEXT }], + agent: config.agent, + model: config.model, + }, + }) + return true + } catch { + return false + } +} diff --git a/src/hooks/session-recovery/storage.ts b/src/hooks/session-recovery/storage.ts index 7b00ffcdd..b9dbccb94 100644 --- a/src/hooks/session-recovery/storage.ts +++ b/src/hooks/session-recovery/storage.ts @@ -1,390 +1,26 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs" -import { join } from "node:path" -import { MESSAGE_STORAGE, PART_STORAGE, THINKING_TYPES, META_TYPES } from "./constants" -import type { StoredMessageMeta, StoredPart, StoredTextPart } from "./types" - -export function generatePartId(): string { - const timestamp = Date.now().toString(16) - const random = Math.random().toString(36).substring(2, 10) - return `prt_${timestamp}${random}` -} - -export function getMessageDir(sessionID: string): string { - if (!existsSync(MESSAGE_STORAGE)) return "" - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) { - return directPath - } - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) { - return sessionPath - } - } - - return "" -} - -export function readMessages(sessionID: string): StoredMessageMeta[] { - const messageDir = getMessageDir(sessionID) - if (!messageDir || !existsSync(messageDir)) return [] - - const messages: StoredMessageMeta[] = [] - for (const file of readdirSync(messageDir)) { - if (!file.endsWith(".json")) continue - try { - const content = readFileSync(join(messageDir, file), "utf-8") - messages.push(JSON.parse(content)) - } catch { - continue - } - } - - return messages.sort((a, b) => { - const aTime = a.time?.created ?? 0 - const bTime = b.time?.created ?? 0 - if (aTime !== bTime) return aTime - bTime - return a.id.localeCompare(b.id) - }) -} - -export function readParts(messageID: string): StoredPart[] { - const partDir = join(PART_STORAGE, messageID) - if (!existsSync(partDir)) return [] - - const parts: StoredPart[] = [] - for (const file of readdirSync(partDir)) { - if (!file.endsWith(".json")) continue - try { - const content = readFileSync(join(partDir, file), "utf-8") - parts.push(JSON.parse(content)) - } catch { - continue - } - } - - return parts -} - -export function hasContent(part: StoredPart): boolean { - if (THINKING_TYPES.has(part.type)) return false - if (META_TYPES.has(part.type)) return false - - if (part.type === "text") { - const textPart = part as StoredTextPart - return !!(textPart.text?.trim()) - } - - if (part.type === "tool" || part.type === "tool_use") { - return true - } - - if (part.type === "tool_result") { - return true - } - - return false -} - -export function messageHasContent(messageID: string): boolean { - const parts = readParts(messageID) - return parts.some(hasContent) -} - -export function injectTextPart(sessionID: string, messageID: string, text: string): boolean { - const partDir = join(PART_STORAGE, messageID) - - if (!existsSync(partDir)) { - mkdirSync(partDir, { recursive: true }) - } - - const partId = generatePartId() - const part: StoredTextPart = { - id: partId, - sessionID, - messageID, - type: "text", - text, - synthetic: true, - } - - try { - writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2)) - return true - } catch { - return false - } -} - -export function findEmptyMessages(sessionID: string): string[] { - const messages = readMessages(sessionID) - const emptyIds: string[] = [] - - for (const msg of messages) { - if (!messageHasContent(msg.id)) { - emptyIds.push(msg.id) - } - } - - return emptyIds -} - -export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null { - const messages = readMessages(sessionID) - - // API index may differ from storage index due to system messages - const indicesToTry = [ - targetIndex, - targetIndex - 1, - targetIndex + 1, - targetIndex - 2, - targetIndex + 2, - targetIndex - 3, - targetIndex - 4, - targetIndex - 5, - ] - - for (const idx of indicesToTry) { - if (idx < 0 || idx >= messages.length) continue - - const targetMsg = messages[idx] - - if (!messageHasContent(targetMsg.id)) { - return targetMsg.id - } - } - - return null -} - -export function findFirstEmptyMessage(sessionID: string): string | null { - const emptyIds = findEmptyMessages(sessionID) - return emptyIds.length > 0 ? emptyIds[0] : null -} - -export function findMessagesWithThinkingBlocks(sessionID: string): string[] { - const messages = readMessages(sessionID) - const result: string[] = [] - - for (const msg of messages) { - if (msg.role !== "assistant") continue - - const parts = readParts(msg.id) - const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type)) - if (hasThinking) { - result.push(msg.id) - } - } - - return result -} - -export function findMessagesWithThinkingOnly(sessionID: string): string[] { - const messages = readMessages(sessionID) - const result: string[] = [] - - for (const msg of messages) { - if (msg.role !== "assistant") continue - - const parts = readParts(msg.id) - if (parts.length === 0) continue - - const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type)) - const hasTextContent = parts.some(hasContent) - - // Has thinking but no text content = orphan thinking - if (hasThinking && !hasTextContent) { - result.push(msg.id) - } - } - - return result -} - -export function findMessagesWithOrphanThinking(sessionID: string): string[] { - const messages = readMessages(sessionID) - const result: string[] = [] - - for (let i = 0; i < messages.length; i++) { - const msg = messages[i] - if (msg.role !== "assistant") continue - - // NOTE: Removed isLastMessage skip - recovery needs to fix last message too - // when "thinking must start with" errors occur on final assistant message - - const parts = readParts(msg.id) - if (parts.length === 0) continue - - const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id)) - const firstPart = sortedParts[0] - - const firstIsThinking = THINKING_TYPES.has(firstPart.type) - - // NOTE: Changed condition - if first part is not thinking, it's orphan - // regardless of whether thinking blocks exist elsewhere in the message - if (!firstIsThinking) { - result.push(msg.id) - } - } - - return result -} - -/** - * Find the most recent thinking content from previous assistant messages - * Following Anthropic's recommendation to include thinking blocks from previous turns - */ -function findLastThinkingContent(sessionID: string, beforeMessageID: string): string { - const messages = readMessages(sessionID) - - // Find the index of the current message - const currentIndex = messages.findIndex(m => m.id === beforeMessageID) - if (currentIndex === -1) return "" - - // Search backwards through previous assistant messages - for (let i = currentIndex - 1; i >= 0; i--) { - const msg = messages[i] - if (msg.role !== "assistant") continue - - // Look for thinking parts in this message - const parts = readParts(msg.id) - for (const part of parts) { - if (THINKING_TYPES.has(part.type)) { - // Found thinking content - return it - // Note: 'thinking' type uses 'thinking' property, 'reasoning' type uses 'text' property - const thinking = (part as { thinking?: string; text?: string }).thinking - const reasoning = (part as { thinking?: string; text?: string }).text - const content = thinking || reasoning - if (content && content.trim().length > 0) { - return content - } - } - } - } - - return "" -} - -export function prependThinkingPart(sessionID: string, messageID: string): boolean { - const partDir = join(PART_STORAGE, messageID) - - if (!existsSync(partDir)) { - mkdirSync(partDir, { recursive: true }) - } - - // Try to get thinking content from previous turns (Anthropic's recommendation) - const previousThinking = findLastThinkingContent(sessionID, messageID) - - const partId = `prt_0000000000_thinking` - const part = { - id: partId, - sessionID, - messageID, - type: "thinking", - thinking: previousThinking || "[Continuing from previous reasoning]", - synthetic: true, - } - - try { - writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2)) - return true - } catch { - return false - } -} - -export function stripThinkingParts(messageID: string): boolean { - const partDir = join(PART_STORAGE, messageID) - if (!existsSync(partDir)) return false - - let anyRemoved = false - for (const file of readdirSync(partDir)) { - if (!file.endsWith(".json")) continue - try { - const filePath = join(partDir, file) - const content = readFileSync(filePath, "utf-8") - const part = JSON.parse(content) as StoredPart - if (THINKING_TYPES.has(part.type)) { - unlinkSync(filePath) - anyRemoved = true - } - } catch { - continue - } - } - - return anyRemoved -} - -export function replaceEmptyTextParts(messageID: string, replacementText: string): boolean { - const partDir = join(PART_STORAGE, messageID) - if (!existsSync(partDir)) return false - - let anyReplaced = false - for (const file of readdirSync(partDir)) { - if (!file.endsWith(".json")) continue - try { - const filePath = join(partDir, file) - const content = readFileSync(filePath, "utf-8") - const part = JSON.parse(content) as StoredPart - - if (part.type === "text") { - const textPart = part as StoredTextPart - if (!textPart.text?.trim()) { - textPart.text = replacementText - textPart.synthetic = true - writeFileSync(filePath, JSON.stringify(textPart, null, 2)) - anyReplaced = true - } - } - } catch { - continue - } - } - - return anyReplaced -} - -export function findMessagesWithEmptyTextParts(sessionID: string): string[] { - const messages = readMessages(sessionID) - const result: string[] = [] - - for (const msg of messages) { - const parts = readParts(msg.id) - const hasEmptyTextPart = parts.some((p) => { - if (p.type !== "text") return false - const textPart = p as StoredTextPart - return !textPart.text?.trim() - }) - - if (hasEmptyTextPart) { - result.push(msg.id) - } - } - - return result -} - -export function findMessageByIndexNeedingThinking(sessionID: string, targetIndex: number): string | null { - const messages = readMessages(sessionID) - - if (targetIndex < 0 || targetIndex >= messages.length) return null - - const targetMsg = messages[targetIndex] - if (targetMsg.role !== "assistant") return null - - const parts = readParts(targetMsg.id) - if (parts.length === 0) return null - - const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id)) - const firstPart = sortedParts[0] - const firstIsThinking = THINKING_TYPES.has(firstPart.type) - - if (!firstIsThinking) { - return targetMsg.id - } - - return null -} +export { generatePartId } from "./storage/part-id" +export { getMessageDir } from "./storage/message-dir" +export { readMessages } from "./storage/messages-reader" +export { readParts } from "./storage/parts-reader" +export { hasContent, messageHasContent } from "./storage/part-content" +export { injectTextPart } from "./storage/text-part-injector" + +export { + findEmptyMessages, + findEmptyMessageByIndex, + findFirstEmptyMessage, +} from "./storage/empty-messages" +export { findMessagesWithEmptyTextParts } from "./storage/empty-text" + +export { + findMessagesWithThinkingBlocks, + findMessagesWithThinkingOnly, +} from "./storage/thinking-block-search" +export { + findMessagesWithOrphanThinking, + findMessageByIndexNeedingThinking, +} from "./storage/orphan-thinking-search" + +export { prependThinkingPart } from "./storage/thinking-prepend" +export { stripThinkingParts } from "./storage/thinking-strip" +export { replaceEmptyTextParts } from "./storage/empty-text" diff --git a/src/hooks/session-recovery/storage/empty-messages.ts b/src/hooks/session-recovery/storage/empty-messages.ts new file mode 100644 index 000000000..1d6211177 --- /dev/null +++ b/src/hooks/session-recovery/storage/empty-messages.ts @@ -0,0 +1,47 @@ +import { messageHasContent } from "./part-content" +import { readMessages } from "./messages-reader" + +export function findEmptyMessages(sessionID: string): string[] { + const messages = readMessages(sessionID) + const emptyIds: string[] = [] + + for (const msg of messages) { + if (!messageHasContent(msg.id)) { + emptyIds.push(msg.id) + } + } + + return emptyIds +} + +export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null { + const messages = readMessages(sessionID) + + const indicesToTry = [ + targetIndex, + targetIndex - 1, + targetIndex + 1, + targetIndex - 2, + targetIndex + 2, + targetIndex - 3, + targetIndex - 4, + targetIndex - 5, + ] + + for (const index of indicesToTry) { + if (index < 0 || index >= messages.length) continue + + const targetMessage = messages[index] + + if (!messageHasContent(targetMessage.id)) { + return targetMessage.id + } + } + + return null +} + +export function findFirstEmptyMessage(sessionID: string): string | null { + const emptyIds = findEmptyMessages(sessionID) + return emptyIds.length > 0 ? emptyIds[0] : null +} diff --git a/src/hooks/session-recovery/storage/empty-text.ts b/src/hooks/session-recovery/storage/empty-text.ts new file mode 100644 index 000000000..aa6ff2eb0 --- /dev/null +++ b/src/hooks/session-recovery/storage/empty-text.ts @@ -0,0 +1,55 @@ +import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { PART_STORAGE } from "../constants" +import type { StoredPart, StoredTextPart } from "../types" +import { readMessages } from "./messages-reader" +import { readParts } from "./parts-reader" + +export function replaceEmptyTextParts(messageID: string, replacementText: string): boolean { + const partDir = join(PART_STORAGE, messageID) + if (!existsSync(partDir)) return false + + let anyReplaced = false + for (const file of readdirSync(partDir)) { + if (!file.endsWith(".json")) continue + try { + const filePath = join(partDir, file) + const content = readFileSync(filePath, "utf-8") + const part = JSON.parse(content) as StoredPart + + if (part.type === "text") { + const textPart = part as StoredTextPart + if (!textPart.text?.trim()) { + textPart.text = replacementText + textPart.synthetic = true + writeFileSync(filePath, JSON.stringify(textPart, null, 2)) + anyReplaced = true + } + } + } catch { + continue + } + } + + return anyReplaced +} + +export function findMessagesWithEmptyTextParts(sessionID: string): string[] { + const messages = readMessages(sessionID) + const result: string[] = [] + + for (const msg of messages) { + const parts = readParts(msg.id) + const hasEmptyTextPart = parts.some((part) => { + if (part.type !== "text") return false + const textPart = part as StoredTextPart + return !textPart.text?.trim() + }) + + if (hasEmptyTextPart) { + result.push(msg.id) + } + } + + return result +} diff --git a/src/hooks/session-recovery/storage/message-dir.ts b/src/hooks/session-recovery/storage/message-dir.ts new file mode 100644 index 000000000..96f03a279 --- /dev/null +++ b/src/hooks/session-recovery/storage/message-dir.ts @@ -0,0 +1,21 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { MESSAGE_STORAGE } from "../constants" + +export function getMessageDir(sessionID: string): string { + if (!existsSync(MESSAGE_STORAGE)) return "" + + const directPath = join(MESSAGE_STORAGE, sessionID) + if (existsSync(directPath)) { + return directPath + } + + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) + if (existsSync(sessionPath)) { + return sessionPath + } + } + + return "" +} diff --git a/src/hooks/session-recovery/storage/messages-reader.ts b/src/hooks/session-recovery/storage/messages-reader.ts new file mode 100644 index 000000000..ad6c77833 --- /dev/null +++ b/src/hooks/session-recovery/storage/messages-reader.ts @@ -0,0 +1,27 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs" +import { join } from "node:path" +import type { StoredMessageMeta } from "../types" +import { getMessageDir } from "./message-dir" + +export function readMessages(sessionID: string): StoredMessageMeta[] { + const messageDir = getMessageDir(sessionID) + if (!messageDir || !existsSync(messageDir)) return [] + + const messages: StoredMessageMeta[] = [] + for (const file of readdirSync(messageDir)) { + if (!file.endsWith(".json")) continue + try { + const content = readFileSync(join(messageDir, file), "utf-8") + messages.push(JSON.parse(content)) + } catch { + continue + } + } + + return messages.sort((a, b) => { + const aTime = a.time?.created ?? 0 + const bTime = b.time?.created ?? 0 + if (aTime !== bTime) return aTime - bTime + return a.id.localeCompare(b.id) + }) +} diff --git a/src/hooks/session-recovery/storage/orphan-thinking-search.ts b/src/hooks/session-recovery/storage/orphan-thinking-search.ts new file mode 100644 index 000000000..4bb00083f --- /dev/null +++ b/src/hooks/session-recovery/storage/orphan-thinking-search.ts @@ -0,0 +1,43 @@ +import { THINKING_TYPES } from "../constants" +import { readMessages } from "./messages-reader" +import { readParts } from "./parts-reader" + +export function findMessagesWithOrphanThinking(sessionID: string): string[] { + const messages = readMessages(sessionID) + const result: string[] = [] + + for (const msg of messages) { + if (msg.role !== "assistant") continue + + const parts = readParts(msg.id) + if (parts.length === 0) continue + + const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id)) + const firstPart = sortedParts[0] + const firstIsThinking = THINKING_TYPES.has(firstPart.type) + + if (!firstIsThinking) { + result.push(msg.id) + } + } + + return result +} + +export function findMessageByIndexNeedingThinking(sessionID: string, targetIndex: number): string | null { + const messages = readMessages(sessionID) + + if (targetIndex < 0 || targetIndex >= messages.length) return null + + const targetMessage = messages[targetIndex] + if (targetMessage.role !== "assistant") return null + + const parts = readParts(targetMessage.id) + if (parts.length === 0) return null + + const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id)) + const firstPart = sortedParts[0] + const firstIsThinking = THINKING_TYPES.has(firstPart.type) + + return firstIsThinking ? null : targetMessage.id +} diff --git a/src/hooks/session-recovery/storage/part-content.ts b/src/hooks/session-recovery/storage/part-content.ts new file mode 100644 index 000000000..064e2df4b --- /dev/null +++ b/src/hooks/session-recovery/storage/part-content.ts @@ -0,0 +1,28 @@ +import { THINKING_TYPES, META_TYPES } from "../constants" +import type { StoredPart, StoredTextPart } from "../types" +import { readParts } from "./parts-reader" + +export function hasContent(part: StoredPart): boolean { + if (THINKING_TYPES.has(part.type)) return false + if (META_TYPES.has(part.type)) return false + + if (part.type === "text") { + const textPart = part as StoredTextPart + return !!textPart.text?.trim() + } + + if (part.type === "tool" || part.type === "tool_use") { + return true + } + + if (part.type === "tool_result") { + return true + } + + return false +} + +export function messageHasContent(messageID: string): boolean { + const parts = readParts(messageID) + return parts.some(hasContent) +} diff --git a/src/hooks/session-recovery/storage/part-id.ts b/src/hooks/session-recovery/storage/part-id.ts new file mode 100644 index 000000000..1500b4bc1 --- /dev/null +++ b/src/hooks/session-recovery/storage/part-id.ts @@ -0,0 +1,5 @@ +export function generatePartId(): string { + const timestamp = Date.now().toString(16) + const random = Math.random().toString(36).substring(2, 10) + return `prt_${timestamp}${random}` +} diff --git a/src/hooks/session-recovery/storage/parts-reader.ts b/src/hooks/session-recovery/storage/parts-reader.ts new file mode 100644 index 000000000..c4110a59d --- /dev/null +++ b/src/hooks/session-recovery/storage/parts-reader.ts @@ -0,0 +1,22 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs" +import { join } from "node:path" +import { PART_STORAGE } from "../constants" +import type { StoredPart } from "../types" + +export function readParts(messageID: string): StoredPart[] { + const partDir = join(PART_STORAGE, messageID) + if (!existsSync(partDir)) return [] + + const parts: StoredPart[] = [] + for (const file of readdirSync(partDir)) { + if (!file.endsWith(".json")) continue + try { + const content = readFileSync(join(partDir, file), "utf-8") + parts.push(JSON.parse(content)) + } catch { + continue + } + } + + return parts +} diff --git a/src/hooks/session-recovery/storage/text-part-injector.ts b/src/hooks/session-recovery/storage/text-part-injector.ts new file mode 100644 index 000000000..f729ca0fc --- /dev/null +++ b/src/hooks/session-recovery/storage/text-part-injector.ts @@ -0,0 +1,30 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { PART_STORAGE } from "../constants" +import type { StoredTextPart } from "../types" +import { generatePartId } from "./part-id" + +export function injectTextPart(sessionID: string, messageID: string, text: string): boolean { + const partDir = join(PART_STORAGE, messageID) + + if (!existsSync(partDir)) { + mkdirSync(partDir, { recursive: true }) + } + + const partId = generatePartId() + const part: StoredTextPart = { + id: partId, + sessionID, + messageID, + type: "text", + text, + synthetic: true, + } + + try { + writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2)) + return true + } catch { + return false + } +} diff --git a/src/hooks/session-recovery/storage/thinking-block-search.ts b/src/hooks/session-recovery/storage/thinking-block-search.ts new file mode 100644 index 000000000..08b7394bb --- /dev/null +++ b/src/hooks/session-recovery/storage/thinking-block-search.ts @@ -0,0 +1,42 @@ +import { THINKING_TYPES } from "../constants" +import { hasContent } from "./part-content" +import { readMessages } from "./messages-reader" +import { readParts } from "./parts-reader" + +export function findMessagesWithThinkingBlocks(sessionID: string): string[] { + const messages = readMessages(sessionID) + const result: string[] = [] + + for (const msg of messages) { + if (msg.role !== "assistant") continue + + const parts = readParts(msg.id) + const hasThinking = parts.some((part) => THINKING_TYPES.has(part.type)) + if (hasThinking) { + result.push(msg.id) + } + } + + return result +} + +export function findMessagesWithThinkingOnly(sessionID: string): string[] { + const messages = readMessages(sessionID) + const result: string[] = [] + + for (const msg of messages) { + if (msg.role !== "assistant") continue + + const parts = readParts(msg.id) + if (parts.length === 0) continue + + const hasThinking = parts.some((part) => THINKING_TYPES.has(part.type)) + const hasTextContent = parts.some(hasContent) + + if (hasThinking && !hasTextContent) { + result.push(msg.id) + } + } + + return result +} diff --git a/src/hooks/session-recovery/storage/thinking-prepend.ts b/src/hooks/session-recovery/storage/thinking-prepend.ts new file mode 100644 index 000000000..b8c1bd861 --- /dev/null +++ b/src/hooks/session-recovery/storage/thinking-prepend.ts @@ -0,0 +1,58 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { PART_STORAGE, THINKING_TYPES } from "../constants" +import { readMessages } from "./messages-reader" +import { readParts } from "./parts-reader" + +function findLastThinkingContent(sessionID: string, beforeMessageID: string): string { + const messages = readMessages(sessionID) + + const currentIndex = messages.findIndex((message) => message.id === beforeMessageID) + if (currentIndex === -1) return "" + + for (let i = currentIndex - 1; i >= 0; i--) { + const message = messages[i] + if (message.role !== "assistant") continue + + const parts = readParts(message.id) + for (const part of parts) { + if (THINKING_TYPES.has(part.type)) { + const thinking = (part as { thinking?: string; text?: string }).thinking + const reasoning = (part as { thinking?: string; text?: string }).text + const content = thinking || reasoning + if (content && content.trim().length > 0) { + return content + } + } + } + } + + return "" +} + +export function prependThinkingPart(sessionID: string, messageID: string): boolean { + const partDir = join(PART_STORAGE, messageID) + + if (!existsSync(partDir)) { + mkdirSync(partDir, { recursive: true }) + } + + const previousThinking = findLastThinkingContent(sessionID, messageID) + + const partId = "prt_0000000000_thinking" + const part = { + id: partId, + sessionID, + messageID, + type: "thinking", + thinking: previousThinking || "[Continuing from previous reasoning]", + synthetic: true, + } + + try { + writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2)) + return true + } catch { + return false + } +} diff --git a/src/hooks/session-recovery/storage/thinking-strip.ts b/src/hooks/session-recovery/storage/thinking-strip.ts new file mode 100644 index 000000000..8731508a0 --- /dev/null +++ b/src/hooks/session-recovery/storage/thinking-strip.ts @@ -0,0 +1,27 @@ +import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs" +import { join } from "node:path" +import { PART_STORAGE, THINKING_TYPES } from "../constants" +import type { StoredPart } from "../types" + +export function stripThinkingParts(messageID: string): boolean { + const partDir = join(PART_STORAGE, messageID) + if (!existsSync(partDir)) return false + + let anyRemoved = false + for (const file of readdirSync(partDir)) { + if (!file.endsWith(".json")) continue + try { + const filePath = join(partDir, file) + const content = readFileSync(filePath, "utf-8") + const part = JSON.parse(content) as StoredPart + if (THINKING_TYPES.has(part.type)) { + unlinkSync(filePath) + anyRemoved = true + } + } catch { + continue + } + } + + return anyRemoved +} diff --git a/src/hooks/start-work/index.ts b/src/hooks/start-work/index.ts index 600814bd5..41cb0b1a4 100644 --- a/src/hooks/start-work/index.ts +++ b/src/hooks/start-work/index.ts @@ -1,242 +1 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { - readBoulderState, - writeBoulderState, - appendSessionId, - findPrometheusPlans, - getPlanProgress, - createBoulderState, - getPlanName, - clearBoulderState, -} from "../../features/boulder-state" -import { log } from "../../shared/logger" -import { getSessionAgent, updateSessionAgent } from "../../features/claude-code-session-state" - -export const HOOK_NAME = "start-work" - -const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi - -interface StartWorkHookInput { - sessionID: string - messageID?: string -} - -interface StartWorkHookOutput { - parts: Array<{ type: string; text?: string }> -} - -function extractUserRequestPlanName(promptText: string): string | null { - const userRequestMatch = promptText.match(/\s*([\s\S]*?)\s*<\/user-request>/i) - if (!userRequestMatch) return null - - const rawArg = userRequestMatch[1].trim() - if (!rawArg) return null - - const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim() - return cleanedArg || null -} - -function findPlanByName(plans: string[], requestedName: string): string | null { - const lowerName = requestedName.toLowerCase() - - const exactMatch = plans.find(p => getPlanName(p).toLowerCase() === lowerName) - if (exactMatch) return exactMatch - - const partialMatch = plans.find(p => getPlanName(p).toLowerCase().includes(lowerName)) - return partialMatch || null -} - -export function createStartWorkHook(ctx: PluginInput) { - return { - "chat.message": async ( - input: StartWorkHookInput, - output: StartWorkHookOutput - ): Promise => { - const parts = output.parts - const promptText = parts - ?.filter((p) => p.type === "text" && p.text) - .map((p) => p.text) - .join("\n") - .trim() || "" - - // Only trigger on actual command execution (contains tag) - // NOT on description text like "Start Sisyphus work session from Prometheus plan" - const isStartWorkCommand = promptText.includes("") - - if (!isStartWorkCommand) { - return - } - - log(`[${HOOK_NAME}] Processing start-work command`, { - sessionID: input.sessionID, - }) - - updateSessionAgent(input.sessionID, "atlas") // Always switch: fixes #1298 - - const existingState = readBoulderState(ctx.directory) - const sessionId = input.sessionID - const timestamp = new Date().toISOString() - - let contextInfo = "" - - const explicitPlanName = extractUserRequestPlanName(promptText) - - if (explicitPlanName) { - log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { - sessionID: input.sessionID, - }) - - const allPlans = findPrometheusPlans(ctx.directory) - const matchedPlan = findPlanByName(allPlans, explicitPlanName) - - if (matchedPlan) { - const progress = getPlanProgress(matchedPlan) - - if (progress.isComplete) { - contextInfo = ` -## Plan Already Complete - -The requested plan "${getPlanName(matchedPlan)}" has been completed. -All ${progress.total} tasks are done. Create a new plan with: /plan "your task"` - } else { - if (existingState) { - clearBoulderState(ctx.directory) - } - const newState = createBoulderState(matchedPlan, sessionId, "atlas") - writeBoulderState(ctx.directory, newState) - - contextInfo = ` -## Auto-Selected Plan - -**Plan**: ${getPlanName(matchedPlan)} -**Path**: ${matchedPlan} -**Progress**: ${progress.completed}/${progress.total} tasks -**Session ID**: ${sessionId} -**Started**: ${timestamp} - -boulder.json has been created. Read the plan and begin execution.` - } - } else { - const incompletePlans = allPlans.filter(p => !getPlanProgress(p).isComplete) - if (incompletePlans.length > 0) { - const planList = incompletePlans.map((p, i) => { - const prog = getPlanProgress(p) - return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}` - }).join("\n") - - contextInfo = ` -## Plan Not Found - -Could not find a plan matching "${explicitPlanName}". - -Available incomplete plans: -${planList} - -Ask the user which plan to work on.` - } else { - contextInfo = ` -## Plan Not Found - -Could not find a plan matching "${explicitPlanName}". -No incomplete plans available. Create a new plan with: /plan "your task"` - } - } - } else if (existingState) { - const progress = getPlanProgress(existingState.active_plan) - - if (!progress.isComplete) { - appendSessionId(ctx.directory, sessionId) - contextInfo = ` -## Active Work Session Found - -**Status**: RESUMING existing work -**Plan**: ${existingState.plan_name} -**Path**: ${existingState.active_plan} -**Progress**: ${progress.completed}/${progress.total} tasks completed -**Sessions**: ${existingState.session_ids.length + 1} (current session appended) -**Started**: ${existingState.started_at} - -The current session (${sessionId}) has been added to session_ids. -Read the plan file and continue from the first unchecked task.` - } else { - contextInfo = ` -## Previous Work Complete - -The previous plan (${existingState.plan_name}) has been completed. -Looking for new plans...` - } - } - - if ((!existingState && !explicitPlanName) || (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)) { - const plans = findPrometheusPlans(ctx.directory) - const incompletePlans = plans.filter(p => !getPlanProgress(p).isComplete) - - if (plans.length === 0) { - contextInfo += ` - -## No Plans Found - -No Prometheus plan files found at .sisyphus/plans/ -Use Prometheus to create a work plan first: /plan "your task"` - } else if (incompletePlans.length === 0) { - contextInfo += ` - -## All Plans Complete - -All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your task"` - } else if (incompletePlans.length === 1) { - const planPath = incompletePlans[0] - const progress = getPlanProgress(planPath) - const newState = createBoulderState(planPath, sessionId, "atlas") - writeBoulderState(ctx.directory, newState) - - contextInfo += ` - -## Auto-Selected Plan - -**Plan**: ${getPlanName(planPath)} -**Path**: ${planPath} -**Progress**: ${progress.completed}/${progress.total} tasks -**Session ID**: ${sessionId} -**Started**: ${timestamp} - -boulder.json has been created. Read the plan and begin execution.` - } else { - const planList = incompletePlans.map((p, i) => { - const progress = getPlanProgress(p) - const stat = require("node:fs").statSync(p) - const modified = new Date(stat.mtimeMs).toISOString() - return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}` - }).join("\n") - - contextInfo += ` - - -## Multiple Plans Found - -Current Time: ${timestamp} -Session ID: ${sessionId} - -${planList} - -Ask the user which plan to work on. Present the options above and wait for their response. -` - } - } - - const idx = output.parts.findIndex((p) => p.type === "text" && p.text) - if (idx >= 0 && output.parts[idx].text) { - output.parts[idx].text = output.parts[idx].text - .replace(/\$SESSION_ID/g, sessionId) - .replace(/\$TIMESTAMP/g, timestamp) - - output.parts[idx].text += `\n\n---\n${contextInfo}` - } - - log(`[${HOOK_NAME}] Context injected`, { - sessionID: input.sessionID, - hasExistingState: !!existingState, - }) - }, - } -} +export { HOOK_NAME, createStartWorkHook } from "./start-work-hook" diff --git a/src/hooks/start-work/start-work-hook.ts b/src/hooks/start-work/start-work-hook.ts new file mode 100644 index 000000000..bd7e52d96 --- /dev/null +++ b/src/hooks/start-work/start-work-hook.ts @@ -0,0 +1,242 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { + readBoulderState, + writeBoulderState, + appendSessionId, + findPrometheusPlans, + getPlanProgress, + createBoulderState, + getPlanName, + clearBoulderState, +} from "../../features/boulder-state" +import { log } from "../../shared/logger" +import { getSessionAgent, updateSessionAgent } from "../../features/claude-code-session-state" + +export const HOOK_NAME = "start-work" as const + +const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi + +interface StartWorkHookInput { + sessionID: string + messageID?: string +} + +interface StartWorkHookOutput { + parts: Array<{ type: string; text?: string }> +} + +function extractUserRequestPlanName(promptText: string): string | null { + const userRequestMatch = promptText.match(/\s*([\s\S]*?)\s*<\/user-request>/i) + if (!userRequestMatch) return null + + const rawArg = userRequestMatch[1].trim() + if (!rawArg) return null + + const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim() + return cleanedArg || null +} + +function findPlanByName(plans: string[], requestedName: string): string | null { + const lowerName = requestedName.toLowerCase() + + const exactMatch = plans.find(p => getPlanName(p).toLowerCase() === lowerName) + if (exactMatch) return exactMatch + + const partialMatch = plans.find(p => getPlanName(p).toLowerCase().includes(lowerName)) + return partialMatch || null +} + +export function createStartWorkHook(ctx: PluginInput) { + return { + "chat.message": async ( + input: StartWorkHookInput, + output: StartWorkHookOutput + ): Promise => { + const parts = output.parts + const promptText = parts + ?.filter((p) => p.type === "text" && p.text) + .map((p) => p.text) + .join("\n") + .trim() || "" + + // Only trigger on actual command execution (contains tag) + // NOT on description text like "Start Sisyphus work session from Prometheus plan" + const isStartWorkCommand = promptText.includes("") + + if (!isStartWorkCommand) { + return + } + + log(`[${HOOK_NAME}] Processing start-work command`, { + sessionID: input.sessionID, + }) + + updateSessionAgent(input.sessionID, "atlas") // Always switch: fixes #1298 + + const existingState = readBoulderState(ctx.directory) + const sessionId = input.sessionID + const timestamp = new Date().toISOString() + + let contextInfo = "" + + const explicitPlanName = extractUserRequestPlanName(promptText) + + if (explicitPlanName) { + log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { + sessionID: input.sessionID, + }) + + const allPlans = findPrometheusPlans(ctx.directory) + const matchedPlan = findPlanByName(allPlans, explicitPlanName) + + if (matchedPlan) { + const progress = getPlanProgress(matchedPlan) + + if (progress.isComplete) { + contextInfo = ` +## Plan Already Complete + +The requested plan "${getPlanName(matchedPlan)}" has been completed. +All ${progress.total} tasks are done. Create a new plan with: /plan "your task"` + } else { + if (existingState) { + clearBoulderState(ctx.directory) + } + const newState = createBoulderState(matchedPlan, sessionId, "atlas") + writeBoulderState(ctx.directory, newState) + + contextInfo = ` +## Auto-Selected Plan + +**Plan**: ${getPlanName(matchedPlan)} +**Path**: ${matchedPlan} +**Progress**: ${progress.completed}/${progress.total} tasks +**Session ID**: ${sessionId} +**Started**: ${timestamp} + +boulder.json has been created. Read the plan and begin execution.` + } + } else { + const incompletePlans = allPlans.filter(p => !getPlanProgress(p).isComplete) + if (incompletePlans.length > 0) { + const planList = incompletePlans.map((p, i) => { + const prog = getPlanProgress(p) + return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}` + }).join("\n") + + contextInfo = ` +## Plan Not Found + +Could not find a plan matching "${explicitPlanName}". + +Available incomplete plans: +${planList} + +Ask the user which plan to work on.` + } else { + contextInfo = ` +## Plan Not Found + +Could not find a plan matching "${explicitPlanName}". +No incomplete plans available. Create a new plan with: /plan "your task"` + } + } + } else if (existingState) { + const progress = getPlanProgress(existingState.active_plan) + + if (!progress.isComplete) { + appendSessionId(ctx.directory, sessionId) + contextInfo = ` +## Active Work Session Found + +**Status**: RESUMING existing work +**Plan**: ${existingState.plan_name} +**Path**: ${existingState.active_plan} +**Progress**: ${progress.completed}/${progress.total} tasks completed +**Sessions**: ${existingState.session_ids.length + 1} (current session appended) +**Started**: ${existingState.started_at} + +The current session (${sessionId}) has been added to session_ids. +Read the plan file and continue from the first unchecked task.` + } else { + contextInfo = ` +## Previous Work Complete + +The previous plan (${existingState.plan_name}) has been completed. +Looking for new plans...` + } + } + + if ((!existingState && !explicitPlanName) || (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)) { + const plans = findPrometheusPlans(ctx.directory) + const incompletePlans = plans.filter(p => !getPlanProgress(p).isComplete) + + if (plans.length === 0) { + contextInfo += ` + +## No Plans Found + +No Prometheus plan files found at .sisyphus/plans/ +Use Prometheus to create a work plan first: /plan "your task"` + } else if (incompletePlans.length === 0) { + contextInfo += ` + +## All Plans Complete + +All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your task"` + } else if (incompletePlans.length === 1) { + const planPath = incompletePlans[0] + const progress = getPlanProgress(planPath) + const newState = createBoulderState(planPath, sessionId, "atlas") + writeBoulderState(ctx.directory, newState) + + contextInfo += ` + +## Auto-Selected Plan + +**Plan**: ${getPlanName(planPath)} +**Path**: ${planPath} +**Progress**: ${progress.completed}/${progress.total} tasks +**Session ID**: ${sessionId} +**Started**: ${timestamp} + +boulder.json has been created. Read the plan and begin execution.` + } else { + const planList = incompletePlans.map((p, i) => { + const progress = getPlanProgress(p) + const stat = require("node:fs").statSync(p) + const modified = new Date(stat.mtimeMs).toISOString() + return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}` + }).join("\n") + + contextInfo += ` + + +## Multiple Plans Found + +Current Time: ${timestamp} +Session ID: ${sessionId} + +${planList} + +Ask the user which plan to work on. Present the options above and wait for their response. +` + } + } + + const idx = output.parts.findIndex((p) => p.type === "text" && p.text) + if (idx >= 0 && output.parts[idx].text) { + output.parts[idx].text = output.parts[idx].text + .replace(/\$SESSION_ID/g, sessionId) + .replace(/\$TIMESTAMP/g, timestamp) + + output.parts[idx].text += `\n\n---\n${contextInfo}` + } + + log(`[${HOOK_NAME}] Context injected`, { + sessionID: input.sessionID, + hasExistingState: !!existingState, + }) + }, + } +} diff --git a/src/hooks/todo-continuation-enforcer.ts b/src/hooks/todo-continuation-enforcer.ts deleted file mode 100644 index 3e3736beb..000000000 --- a/src/hooks/todo-continuation-enforcer.ts +++ /dev/null @@ -1,517 +0,0 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import type { BackgroundManager } from "../features/background-agent" -import { getMainSessionID, subagentSessions } from "../features/claude-code-session-state" -import { - findNearestMessageWithFields, - MESSAGE_STORAGE, - type ToolPermission, -} from "../features/hook-message-injector" -import { log } from "../shared/logger" -import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive" - -const HOOK_NAME = "todo-continuation-enforcer" - -const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"] - -export interface TodoContinuationEnforcerOptions { - backgroundManager?: BackgroundManager - skipAgents?: string[] - isContinuationStopped?: (sessionID: string) => boolean -} - -export interface TodoContinuationEnforcer { - handler: (input: { event: { type: string; properties?: unknown } }) => Promise - markRecovering: (sessionID: string) => void - markRecoveryComplete: (sessionID: string) => void - cancelAllCountdowns: () => void -} - -interface Todo { - content: string - status: string - priority: string - id: string -} - -interface SessionState { - countdownTimer?: ReturnType - countdownInterval?: ReturnType - isRecovering?: boolean - countdownStartedAt?: number - abortDetectedAt?: number -} - -const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TODO_CONTINUATION)} - -Incomplete tasks remain in your todo list. Continue working on the next pending task. - -- Proceed without asking for permission -- Mark each task complete when finished -- Do not stop until all tasks are done` - -const COUNTDOWN_SECONDS = 2 -const TOAST_DURATION_MS = 900 -const COUNTDOWN_GRACE_PERIOD_MS = 500 - -function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - - return null -} - -function getIncompleteCount(todos: Todo[]): number { - return todos.filter(t => t.status !== "completed" && t.status !== "cancelled").length -} - -interface MessageInfo { - id?: string - role?: string - error?: { name?: string; data?: unknown } -} - -function isLastAssistantMessageAborted(messages: Array<{ info?: MessageInfo }>): boolean { - if (!messages || messages.length === 0) return false - - const assistantMessages = messages.filter(m => m.info?.role === "assistant") - if (assistantMessages.length === 0) return false - - const lastAssistant = assistantMessages[assistantMessages.length - 1] - const errorName = lastAssistant.info?.error?.name - - if (!errorName) return false - - return errorName === "MessageAbortedError" || errorName === "AbortError" -} - -export function createTodoContinuationEnforcer( - ctx: PluginInput, - options: TodoContinuationEnforcerOptions = {} -): TodoContinuationEnforcer { - const { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped } = options - const sessions = new Map() - - function getState(sessionID: string): SessionState { - let state = sessions.get(sessionID) - if (!state) { - state = {} - sessions.set(sessionID, state) - } - return state - } - - function cancelCountdown(sessionID: string): void { - const state = sessions.get(sessionID) - if (!state) return - - if (state.countdownTimer) { - clearTimeout(state.countdownTimer) - state.countdownTimer = undefined - } - if (state.countdownInterval) { - clearInterval(state.countdownInterval) - state.countdownInterval = undefined - } - state.countdownStartedAt = undefined - } - - function cleanup(sessionID: string): void { - cancelCountdown(sessionID) - sessions.delete(sessionID) - } - - const markRecovering = (sessionID: string): void => { - const state = getState(sessionID) - state.isRecovering = true - cancelCountdown(sessionID) - log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID }) - } - - const markRecoveryComplete = (sessionID: string): void => { - const state = sessions.get(sessionID) - if (state) { - state.isRecovering = false - log(`[${HOOK_NAME}] Session recovery complete`, { sessionID }) - } - } - - async function showCountdownToast(seconds: number, incompleteCount: number): Promise { - await ctx.client.tui.showToast({ - body: { - title: "Todo Continuation", - message: `Resuming in ${seconds}s... (${incompleteCount} tasks remaining)`, - variant: "warning" as const, - duration: TOAST_DURATION_MS, - }, - }).catch(() => {}) - } - - interface ResolvedMessageInfo { - agent?: string - model?: { providerID: string; modelID: string } - tools?: Record - } - - async function injectContinuation( - sessionID: string, - incompleteCount: number, - total: number, - resolvedInfo?: ResolvedMessageInfo - ): Promise { - const state = sessions.get(sessionID) - - if (state?.isRecovering) { - log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID }) - return - } - - const hasRunningBgTasks = backgroundManager - ? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running") - : false - - if (hasRunningBgTasks) { - log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID }) - return - } - - let todos: Todo[] = [] - try { - const response = await ctx.client.session.todo({ path: { id: sessionID } }) - todos = (response.data ?? response) as Todo[] - } catch (err) { - log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(err) }) - return - } - - const freshIncompleteCount = getIncompleteCount(todos) - if (freshIncompleteCount === 0) { - log(`[${HOOK_NAME}] Skipped injection: no incomplete todos`, { sessionID }) - return - } - - let agentName = resolvedInfo?.agent - let model = resolvedInfo?.model - let tools = resolvedInfo?.tools - - if (!agentName || !model) { - const messageDir = getMessageDir(sessionID) - const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - agentName = agentName ?? prevMessage?.agent - model = model ?? (prevMessage?.model?.providerID && prevMessage?.model?.modelID - ? { - providerID: prevMessage.model.providerID, - modelID: prevMessage.model.modelID, - ...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {}) - } - : undefined) - tools = tools ?? prevMessage?.tools - } - - if (agentName && skipAgents.includes(agentName)) { - log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName }) - return - } - - const editPermission = tools?.edit - const writePermission = tools?.write - const hasWritePermission = !tools || - ((editPermission !== false && editPermission !== "deny") && - (writePermission !== false && writePermission !== "deny")) - if (!hasWritePermission) { - log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName }) - return - } - - const incompleteTodos = todos.filter(t => t.status !== "completed" && t.status !== "cancelled") - const todoList = incompleteTodos - .map(t => `- [${t.status}] ${t.content}`) - .join("\n") - const prompt = `${CONTINUATION_PROMPT} - -[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining] - -Remaining tasks: -${todoList}` - - try { - log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: agentName, model, incompleteCount: freshIncompleteCount }) - - await ctx.client.session.promptAsync({ - path: { id: sessionID }, - body: { - agent: agentName, - ...(model !== undefined ? { model } : {}), - parts: [{ type: "text", text: prompt }], - }, - query: { directory: ctx.directory }, - }) - - log(`[${HOOK_NAME}] Injection successful`, { sessionID }) - } catch (err) { - log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(err) }) - } - } - - function startCountdown( - sessionID: string, - incompleteCount: number, - total: number, - resolvedInfo?: ResolvedMessageInfo - ): void { - const state = getState(sessionID) - cancelCountdown(sessionID) - - let secondsRemaining = COUNTDOWN_SECONDS - showCountdownToast(secondsRemaining, incompleteCount) - state.countdownStartedAt = Date.now() - - state.countdownInterval = setInterval(() => { - secondsRemaining-- - if (secondsRemaining > 0) { - showCountdownToast(secondsRemaining, incompleteCount) - } - }, 1000) - - state.countdownTimer = setTimeout(() => { - cancelCountdown(sessionID) - injectContinuation(sessionID, incompleteCount, total, resolvedInfo) - }, COUNTDOWN_SECONDS * 1000) - - log(`[${HOOK_NAME}] Countdown started`, { sessionID, seconds: COUNTDOWN_SECONDS, incompleteCount }) - } - - const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { - const props = event.properties as Record | undefined - - if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - const error = props?.error as { name?: string } | undefined - if (error?.name === "MessageAbortedError" || error?.name === "AbortError") { - const state = getState(sessionID) - state.abortDetectedAt = Date.now() - log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name }) - } - - cancelCountdown(sessionID) - log(`[${HOOK_NAME}] session.error`, { sessionID }) - return - } - - if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - log(`[${HOOK_NAME}] session.idle`, { sessionID }) - - const mainSessionID = getMainSessionID() - const isMainSession = sessionID === mainSessionID - const isBackgroundTaskSession = subagentSessions.has(sessionID) - - if (mainSessionID && !isMainSession && !isBackgroundTaskSession) { - log(`[${HOOK_NAME}] Skipped: not main or background task session`, { sessionID }) - return - } - - const state = getState(sessionID) - - if (state.isRecovering) { - log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID }) - return - } - - // Check 1: Event-based abort detection (primary, most reliable) - if (state.abortDetectedAt) { - const timeSinceAbort = Date.now() - state.abortDetectedAt - const ABORT_WINDOW_MS = 3000 - if (timeSinceAbort < ABORT_WINDOW_MS) { - log(`[${HOOK_NAME}] Skipped: abort detected via event ${timeSinceAbort}ms ago`, { sessionID }) - state.abortDetectedAt = undefined - return - } - state.abortDetectedAt = undefined - } - - const hasRunningBgTasks = backgroundManager - ? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running") - : false - - if (hasRunningBgTasks) { - log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID }) - return - } - - // Check 2: API-based abort detection (fallback, for cases where event was missed) - try { - const messagesResp = await ctx.client.session.messages({ - path: { id: sessionID }, - query: { directory: ctx.directory }, - }) - const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? [] - - if (isLastAssistantMessageAborted(messages)) { - log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID }) - return - } - } catch (err) { - log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(err) }) - } - - let todos: Todo[] = [] - try { - const response = await ctx.client.session.todo({ path: { id: sessionID } }) - todos = (response.data ?? response) as Todo[] - } catch (err) { - log(`[${HOOK_NAME}] Todo fetch failed`, { sessionID, error: String(err) }) - return - } - - if (!todos || todos.length === 0) { - log(`[${HOOK_NAME}] No todos`, { sessionID }) - return - } - - const incompleteCount = getIncompleteCount(todos) - if (incompleteCount === 0) { - log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length }) - return - } - - let resolvedInfo: ResolvedMessageInfo | undefined - let hasCompactionMessage = false - try { - const messagesResp = await ctx.client.session.messages({ - path: { id: sessionID }, - }) - const messages = (messagesResp.data ?? []) as Array<{ - info?: { - agent?: string - model?: { providerID: string; modelID: string } - modelID?: string - providerID?: string - tools?: Record - } - }> - for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - if (info?.agent === "compaction") { - hasCompactionMessage = true - continue - } - if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { - resolvedInfo = { - agent: info.agent, - model: info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined), - tools: info.tools, - } - break - } - } - } catch (err) { - log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(err) }) - } - - log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasCompactionMessage }) - if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) { - log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent }) - return - } - if (hasCompactionMessage && !resolvedInfo?.agent) { - log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID }) - return - } - - if (isContinuationStopped?.(sessionID)) { - log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) - return - } - - startCountdown(sessionID, incompleteCount, todos.length, resolvedInfo) - return - } - - if (event.type === "message.updated") { - const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined - const role = info?.role as string | undefined - - if (!sessionID) return - - if (role === "user") { - const state = sessions.get(sessionID) - if (state?.countdownStartedAt) { - const elapsed = Date.now() - state.countdownStartedAt - if (elapsed < COUNTDOWN_GRACE_PERIOD_MS) { - log(`[${HOOK_NAME}] Ignoring user message in grace period`, { sessionID, elapsed }) - return - } - } - if (state) state.abortDetectedAt = undefined - cancelCountdown(sessionID) - } - - if (role === "assistant") { - const state = sessions.get(sessionID) - if (state) state.abortDetectedAt = undefined - cancelCountdown(sessionID) - } - return - } - - if (event.type === "message.part.updated") { - const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined - const role = info?.role as string | undefined - - if (sessionID && role === "assistant") { - const state = sessions.get(sessionID) - if (state) state.abortDetectedAt = undefined - cancelCountdown(sessionID) - } - return - } - - if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { - const sessionID = props?.sessionID as string | undefined - if (sessionID) { - const state = sessions.get(sessionID) - if (state) state.abortDetectedAt = undefined - cancelCountdown(sessionID) - } - return - } - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - cleanup(sessionInfo.id) - log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) - } - return - } - } - - const cancelAllCountdowns = (): void => { - for (const sessionID of sessions.keys()) { - cancelCountdown(sessionID) - } - log(`[${HOOK_NAME}] All countdowns cancelled`) - } - - return { - handler, - markRecovering, - markRecoveryComplete, - cancelAllCountdowns, - } -} diff --git a/src/hooks/todo-continuation-enforcer/abort-detection.ts b/src/hooks/todo-continuation-enforcer/abort-detection.ts new file mode 100644 index 000000000..a9a8c2996 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/abort-detection.ts @@ -0,0 +1,17 @@ +import type { MessageInfo } from "./types" + +export function isLastAssistantMessageAborted( + messages: Array<{ info?: MessageInfo }> +): boolean { + if (!messages || messages.length === 0) return false + + const assistantMessages = messages.filter((message) => message.info?.role === "assistant") + if (assistantMessages.length === 0) return false + + const lastAssistant = assistantMessages[assistantMessages.length - 1] + const errorName = lastAssistant.info?.error?.name + + if (!errorName) return false + + return errorName === "MessageAbortedError" || errorName === "AbortError" +} diff --git a/src/hooks/todo-continuation-enforcer/constants.ts b/src/hooks/todo-continuation-enforcer/constants.ts new file mode 100644 index 000000000..03e7d01fd --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/constants.ts @@ -0,0 +1,19 @@ +import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive" + +export const HOOK_NAME = "todo-continuation-enforcer" + +export const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"] + +export const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TODO_CONTINUATION)} + +Incomplete tasks remain in your todo list. Continue working on the next pending task. + +- Proceed without asking for permission +- Mark each task complete when finished +- Do not stop until all tasks are done` + +export const COUNTDOWN_SECONDS = 2 +export const TOAST_DURATION_MS = 900 +export const COUNTDOWN_GRACE_PERIOD_MS = 500 + +export const ABORT_WINDOW_MS = 3000 diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts new file mode 100644 index 000000000..2e8911323 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -0,0 +1,139 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import type { BackgroundManager } from "../../features/background-agent" +import { + findNearestMessageWithFields, + type ToolPermission, +} from "../../features/hook-message-injector" +import { log } from "../../shared/logger" + +import { + CONTINUATION_PROMPT, + DEFAULT_SKIP_AGENTS, + HOOK_NAME, +} from "./constants" +import { getMessageDir } from "./message-directory" +import { getIncompleteCount } from "./todo" +import type { ResolvedMessageInfo, Todo } from "./types" +import type { SessionStateStore } from "./session-state" + +function hasWritePermission(tools: Record | undefined): boolean { + const editPermission = tools?.edit + const writePermission = tools?.write + return ( + !tools || + (editPermission !== false && editPermission !== "deny" && writePermission !== false && writePermission !== "deny") + ) +} + +export async function injectContinuation(args: { + ctx: PluginInput + sessionID: string + backgroundManager?: BackgroundManager + skipAgents?: string[] + resolvedInfo?: ResolvedMessageInfo + sessionStateStore: SessionStateStore +}): Promise { + const { + ctx, + sessionID, + backgroundManager, + skipAgents = DEFAULT_SKIP_AGENTS, + resolvedInfo, + sessionStateStore, + } = args + + const state = sessionStateStore.getExistingState(sessionID) + if (state?.isRecovering) { + log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID }) + return + } + + const hasRunningBgTasks = backgroundManager + ? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running") + : false + + if (hasRunningBgTasks) { + log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID }) + return + } + + let todos: Todo[] = [] + try { + const response = await ctx.client.session.todo({ path: { id: sessionID } }) + todos = (response.data ?? response) as Todo[] + } catch (error) { + log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(error) }) + return + } + + const freshIncompleteCount = getIncompleteCount(todos) + if (freshIncompleteCount === 0) { + log(`[${HOOK_NAME}] Skipped injection: no incomplete todos`, { sessionID }) + return + } + + let agentName = resolvedInfo?.agent + let model = resolvedInfo?.model + let tools = resolvedInfo?.tools + + if (!agentName || !model) { + const messageDir = getMessageDir(sessionID) + const previousMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + agentName = agentName ?? previousMessage?.agent + model = + model ?? + (previousMessage?.model?.providerID && previousMessage?.model?.modelID + ? { + providerID: previousMessage.model.providerID, + modelID: previousMessage.model.modelID, + ...(previousMessage.model.variant + ? { variant: previousMessage.model.variant } + : {}), + } + : undefined) + tools = tools ?? previousMessage?.tools + } + + if (agentName && skipAgents.includes(agentName)) { + log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName }) + return + } + + if (!hasWritePermission(tools)) { + log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName }) + return + } + + const incompleteTodos = todos.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled") + const todoList = incompleteTodos.map((todo) => `- [${todo.status}] ${todo.content}`).join("\n") + const prompt = `${CONTINUATION_PROMPT} + +[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining] + +Remaining tasks: +${todoList}` + + try { + log(`[${HOOK_NAME}] Injecting continuation`, { + sessionID, + agent: agentName, + model, + incompleteCount: freshIncompleteCount, + }) + + await ctx.client.session.promptAsync({ + path: { id: sessionID }, + body: { + agent: agentName, + ...(model !== undefined ? { model } : {}), + parts: [{ type: "text", text: prompt }], + }, + query: { directory: ctx.directory }, + }) + + log(`[${HOOK_NAME}] Injection successful`, { sessionID }) + } catch (error) { + log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(error) }) + } +} diff --git a/src/hooks/todo-continuation-enforcer/countdown.ts b/src/hooks/todo-continuation-enforcer/countdown.ts new file mode 100644 index 000000000..7404d32d1 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/countdown.ts @@ -0,0 +1,83 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import type { BackgroundManager } from "../../features/background-agent" +import { log } from "../../shared/logger" + +import { + COUNTDOWN_SECONDS, + HOOK_NAME, + TOAST_DURATION_MS, +} from "./constants" +import type { ResolvedMessageInfo } from "./types" +import type { SessionStateStore } from "./session-state" +import { injectContinuation } from "./continuation-injection" + +async function showCountdownToast( + ctx: PluginInput, + seconds: number, + incompleteCount: number +): Promise { + await ctx.client.tui + .showToast({ + body: { + title: "Todo Continuation", + message: `Resuming in ${seconds}s... (${incompleteCount} tasks remaining)`, + variant: "warning" as const, + duration: TOAST_DURATION_MS, + }, + }) + .catch(() => {}) +} + +export function startCountdown(args: { + ctx: PluginInput + sessionID: string + incompleteCount: number + total: number + resolvedInfo?: ResolvedMessageInfo + backgroundManager?: BackgroundManager + skipAgents: string[] + sessionStateStore: SessionStateStore +}): void { + const { + ctx, + sessionID, + incompleteCount, + resolvedInfo, + backgroundManager, + skipAgents, + sessionStateStore, + } = args + + const state = sessionStateStore.getState(sessionID) + sessionStateStore.cancelCountdown(sessionID) + + let secondsRemaining = COUNTDOWN_SECONDS + showCountdownToast(ctx, secondsRemaining, incompleteCount) + state.countdownStartedAt = Date.now() + + state.countdownInterval = setInterval(() => { + secondsRemaining-- + if (secondsRemaining > 0) { + showCountdownToast(ctx, secondsRemaining, incompleteCount) + } + }, 1000) + + state.countdownTimer = setTimeout(() => { + sessionStateStore.cancelCountdown(sessionID) + injectContinuation({ + ctx, + sessionID, + backgroundManager, + skipAgents, + resolvedInfo, + sessionStateStore, + }) + }, COUNTDOWN_SECONDS * 1000) + + log(`[${HOOK_NAME}] Countdown started`, { + sessionID, + seconds: COUNTDOWN_SECONDS, + incompleteCount, + }) +} diff --git a/src/hooks/todo-continuation-enforcer/handler.ts b/src/hooks/todo-continuation-enforcer/handler.ts new file mode 100644 index 000000000..9e96559db --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/handler.ts @@ -0,0 +1,65 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import type { BackgroundManager } from "../../features/background-agent" +import { log } from "../../shared/logger" + +import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants" +import type { SessionStateStore } from "./session-state" +import { handleSessionIdle } from "./idle-event" +import { handleNonIdleEvent } from "./non-idle-events" + +export function createTodoContinuationHandler(args: { + ctx: PluginInput + sessionStateStore: SessionStateStore + backgroundManager?: BackgroundManager + skipAgents?: string[] + isContinuationStopped?: (sessionID: string) => boolean +}): (input: { event: { type: string; properties?: unknown } }) => Promise { + const { + ctx, + sessionStateStore, + backgroundManager, + skipAgents = DEFAULT_SKIP_AGENTS, + isContinuationStopped, + } = args + + return async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { + const props = event.properties as Record | undefined + + if (event.type === "session.error") { + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + + const error = props?.error as { name?: string } | undefined + if (error?.name === "MessageAbortedError" || error?.name === "AbortError") { + const state = sessionStateStore.getState(sessionID) + state.abortDetectedAt = Date.now() + log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name }) + } + + sessionStateStore.cancelCountdown(sessionID) + log(`[${HOOK_NAME}] session.error`, { sessionID }) + return + } + + if (event.type === "session.idle") { + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + await handleSessionIdle({ + ctx, + sessionID, + sessionStateStore, + backgroundManager, + skipAgents, + isContinuationStopped, + }) + return + } + + handleNonIdleEvent({ + eventType: event.type, + properties: props, + sessionStateStore, + }) + } +} diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts new file mode 100644 index 000000000..62a945872 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -0,0 +1,158 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import type { BackgroundManager } from "../../features/background-agent" +import { getMainSessionID, subagentSessions } from "../../features/claude-code-session-state" +import type { ToolPermission } from "../../features/hook-message-injector" +import { log } from "../../shared/logger" + +import { + ABORT_WINDOW_MS, + DEFAULT_SKIP_AGENTS, + HOOK_NAME, +} from "./constants" +import { isLastAssistantMessageAborted } from "./abort-detection" +import { getIncompleteCount } from "./todo" +import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types" +import type { SessionStateStore } from "./session-state" +import { startCountdown } from "./countdown" + +export async function handleSessionIdle(args: { + ctx: PluginInput + sessionID: string + sessionStateStore: SessionStateStore + backgroundManager?: BackgroundManager + skipAgents?: string[] + isContinuationStopped?: (sessionID: string) => boolean +}): Promise { + const { + ctx, + sessionID, + sessionStateStore, + backgroundManager, + skipAgents = DEFAULT_SKIP_AGENTS, + isContinuationStopped, + } = args + + log(`[${HOOK_NAME}] session.idle`, { sessionID }) + + const mainSessionID = getMainSessionID() + const isMainSession = sessionID === mainSessionID + const isBackgroundTaskSession = subagentSessions.has(sessionID) + + if (mainSessionID && !isMainSession && !isBackgroundTaskSession) { + log(`[${HOOK_NAME}] Skipped: not main or background task session`, { sessionID }) + return + } + + const state = sessionStateStore.getState(sessionID) + if (state.isRecovering) { + log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID }) + return + } + + if (state.abortDetectedAt) { + const timeSinceAbort = Date.now() - state.abortDetectedAt + if (timeSinceAbort < ABORT_WINDOW_MS) { + log(`[${HOOK_NAME}] Skipped: abort detected via event ${timeSinceAbort}ms ago`, { sessionID }) + state.abortDetectedAt = undefined + return + } + state.abortDetectedAt = undefined + } + + const hasRunningBgTasks = backgroundManager + ? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running") + : false + + if (hasRunningBgTasks) { + log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID }) + return + } + + try { + const messagesResp = await ctx.client.session.messages({ + path: { id: sessionID }, + query: { directory: ctx.directory }, + }) + const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? [] + if (isLastAssistantMessageAborted(messages)) { + log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID }) + return + } + } catch (error) { + log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(error) }) + } + + let todos: Todo[] = [] + try { + const response = await ctx.client.session.todo({ path: { id: sessionID } }) + todos = (response.data ?? response) as Todo[] + } catch (error) { + log(`[${HOOK_NAME}] Todo fetch failed`, { sessionID, error: String(error) }) + return + } + + if (!todos || todos.length === 0) { + log(`[${HOOK_NAME}] No todos`, { sessionID }) + return + } + + const incompleteCount = getIncompleteCount(todos) + if (incompleteCount === 0) { + log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length }) + return + } + + let resolvedInfo: ResolvedMessageInfo | undefined + let hasCompactionMessage = false + try { + const messagesResp = await ctx.client.session.messages({ + path: { id: sessionID }, + }) + const messages = (messagesResp.data ?? []) as Array<{ info?: MessageInfo }> + for (let i = messages.length - 1; i >= 0; i--) { + const info = messages[i].info + if (info?.agent === "compaction") { + hasCompactionMessage = true + continue + } + if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { + resolvedInfo = { + agent: info.agent, + model: info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined), + tools: info.tools as Record | undefined, + } + break + } + } + } catch (error) { + log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(error) }) + } + + log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasCompactionMessage }) + + if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) { + log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent }) + return + } + if (hasCompactionMessage && !resolvedInfo?.agent) { + log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID }) + return + } + + if (isContinuationStopped?.(sessionID)) { + log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) + return + } + + startCountdown({ + ctx, + sessionID, + incompleteCount, + total: todos.length, + resolvedInfo, + backgroundManager, + skipAgents, + sessionStateStore, + }) +} diff --git a/src/hooks/todo-continuation-enforcer/index.ts b/src/hooks/todo-continuation-enforcer/index.ts new file mode 100644 index 000000000..85a2a6bc9 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/index.ts @@ -0,0 +1,58 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import { log } from "../../shared/logger" + +import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants" +import { createTodoContinuationHandler } from "./handler" +import { createSessionStateStore } from "./session-state" +import type { TodoContinuationEnforcer, TodoContinuationEnforcerOptions } from "./types" + +export type { TodoContinuationEnforcer, TodoContinuationEnforcerOptions } from "./types" + +export function createTodoContinuationEnforcer( + ctx: PluginInput, + options: TodoContinuationEnforcerOptions = {} +): TodoContinuationEnforcer { + const { + backgroundManager, + skipAgents = DEFAULT_SKIP_AGENTS, + isContinuationStopped, + } = options + + const sessionStateStore = createSessionStateStore() + + const markRecovering = (sessionID: string): void => { + const state = sessionStateStore.getState(sessionID) + state.isRecovering = true + sessionStateStore.cancelCountdown(sessionID) + log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID }) + } + + const markRecoveryComplete = (sessionID: string): void => { + const state = sessionStateStore.getExistingState(sessionID) + if (state) { + state.isRecovering = false + log(`[${HOOK_NAME}] Session recovery complete`, { sessionID }) + } + } + + const handler = createTodoContinuationHandler({ + ctx, + sessionStateStore, + backgroundManager, + skipAgents, + isContinuationStopped, + }) + + const cancelAllCountdowns = (): void => { + sessionStateStore.cancelAllCountdowns() + log(`[${HOOK_NAME}] All countdowns cancelled`) + } + + return { + handler, + markRecovering, + markRecoveryComplete, + cancelAllCountdowns, + } +} diff --git a/src/hooks/todo-continuation-enforcer/message-directory.ts b/src/hooks/todo-continuation-enforcer/message-directory.ts new file mode 100644 index 000000000..85e682427 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/message-directory.ts @@ -0,0 +1,18 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" + +import { MESSAGE_STORAGE } from "../../features/hook-message-injector" + +export function getMessageDir(sessionID: string): string | null { + if (!existsSync(MESSAGE_STORAGE)) return null + + const directPath = join(MESSAGE_STORAGE, sessionID) + if (existsSync(directPath)) return directPath + + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) + if (existsSync(sessionPath)) return sessionPath + } + + return null +} diff --git a/src/hooks/todo-continuation-enforcer/non-idle-events.ts b/src/hooks/todo-continuation-enforcer/non-idle-events.ts new file mode 100644 index 000000000..dc4677047 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/non-idle-events.ts @@ -0,0 +1,74 @@ +import { log } from "../../shared/logger" + +import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants" +import type { SessionStateStore } from "./session-state" + +export function handleNonIdleEvent(args: { + eventType: string + properties: Record | undefined + sessionStateStore: SessionStateStore +}): void { + const { eventType, properties, sessionStateStore } = args + + if (eventType === "message.updated") { + const info = properties?.info as Record | undefined + const sessionID = info?.sessionID as string | undefined + const role = info?.role as string | undefined + if (!sessionID) return + + if (role === "user") { + const state = sessionStateStore.getExistingState(sessionID) + if (state?.countdownStartedAt) { + const elapsed = Date.now() - state.countdownStartedAt + if (elapsed < COUNTDOWN_GRACE_PERIOD_MS) { + log(`[${HOOK_NAME}] Ignoring user message in grace period`, { sessionID, elapsed }) + return + } + } + if (state) state.abortDetectedAt = undefined + sessionStateStore.cancelCountdown(sessionID) + return + } + + if (role === "assistant") { + const state = sessionStateStore.getExistingState(sessionID) + if (state) state.abortDetectedAt = undefined + sessionStateStore.cancelCountdown(sessionID) + return + } + + return + } + + if (eventType === "message.part.updated") { + const info = properties?.info as Record | undefined + const sessionID = info?.sessionID as string | undefined + const role = info?.role as string | undefined + + if (sessionID && role === "assistant") { + const state = sessionStateStore.getExistingState(sessionID) + if (state) state.abortDetectedAt = undefined + sessionStateStore.cancelCountdown(sessionID) + } + return + } + + if (eventType === "tool.execute.before" || eventType === "tool.execute.after") { + const sessionID = properties?.sessionID as string | undefined + if (sessionID) { + const state = sessionStateStore.getExistingState(sessionID) + if (state) state.abortDetectedAt = undefined + sessionStateStore.cancelCountdown(sessionID) + } + return + } + + if (eventType === "session.deleted") { + const sessionInfo = properties?.info as { id?: string } | undefined + if (sessionInfo?.id) { + sessionStateStore.cleanup(sessionInfo.id) + log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) + } + return + } +} diff --git a/src/hooks/todo-continuation-enforcer/session-state.ts b/src/hooks/todo-continuation-enforcer/session-state.ts new file mode 100644 index 000000000..fc96437ab --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/session-state.ts @@ -0,0 +1,62 @@ +import type { SessionState } from "./types" + +export interface SessionStateStore { + getState: (sessionID: string) => SessionState + getExistingState: (sessionID: string) => SessionState | undefined + cancelCountdown: (sessionID: string) => void + cleanup: (sessionID: string) => void + cancelAllCountdowns: () => void +} + +export function createSessionStateStore(): SessionStateStore { + const sessions = new Map() + + function getState(sessionID: string): SessionState { + const existingState = sessions.get(sessionID) + if (existingState) return existingState + + const state: SessionState = {} + sessions.set(sessionID, state) + return state + } + + function getExistingState(sessionID: string): SessionState | undefined { + return sessions.get(sessionID) + } + + function cancelCountdown(sessionID: string): void { + const state = sessions.get(sessionID) + if (!state) return + + if (state.countdownTimer) { + clearTimeout(state.countdownTimer) + state.countdownTimer = undefined + } + + if (state.countdownInterval) { + clearInterval(state.countdownInterval) + state.countdownInterval = undefined + } + + state.countdownStartedAt = undefined + } + + function cleanup(sessionID: string): void { + cancelCountdown(sessionID) + sessions.delete(sessionID) + } + + function cancelAllCountdowns(): void { + for (const sessionID of sessions.keys()) { + cancelCountdown(sessionID) + } + } + + return { + getState, + getExistingState, + cancelCountdown, + cleanup, + cancelAllCountdowns, + } +} diff --git a/src/hooks/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts similarity index 99% rename from src/hooks/todo-continuation-enforcer.test.ts rename to src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts index 626d5c951..ba8ba212a 100644 --- a/src/hooks/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import type { BackgroundManager } from "../features/background-agent" -import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state" -import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer" +import type { BackgroundManager } from "../../features/background-agent" +import { setMainSession, subagentSessions, _resetForTesting } from "../../features/claude-code-session-state" +import { createTodoContinuationEnforcer } from "." type TimerCallback = (...args: any[]) => void diff --git a/src/hooks/todo-continuation-enforcer/todo.ts b/src/hooks/todo-continuation-enforcer/todo.ts new file mode 100644 index 000000000..dbc6f5b61 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/todo.ts @@ -0,0 +1,5 @@ +import type { Todo } from "./types" + +export function getIncompleteCount(todos: Todo[]): number { + return todos.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled").length +} diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts new file mode 100644 index 000000000..8ef8745fa --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -0,0 +1,47 @@ +import type { BackgroundManager } from "../../features/background-agent" +import type { ToolPermission } from "../../features/hook-message-injector" + +export interface TodoContinuationEnforcerOptions { + backgroundManager?: BackgroundManager + skipAgents?: string[] + isContinuationStopped?: (sessionID: string) => boolean +} + +export interface TodoContinuationEnforcer { + handler: (input: { event: { type: string; properties?: unknown } }) => Promise + markRecovering: (sessionID: string) => void + markRecoveryComplete: (sessionID: string) => void + cancelAllCountdowns: () => void +} + +export interface Todo { + content: string + status: string + priority: string + id: string +} + +export interface SessionState { + countdownTimer?: ReturnType + countdownInterval?: ReturnType + isRecovering?: boolean + countdownStartedAt?: number + abortDetectedAt?: number +} + +export interface MessageInfo { + id?: string + role?: string + error?: { name?: string; data?: unknown } + agent?: string + model?: { providerID: string; modelID: string } + providerID?: string + modelID?: string + tools?: Record +} + +export interface ResolvedMessageInfo { + agent?: string + model?: { providerID: string; modelID: string } + tools?: Record +} diff --git a/src/hooks/unstable-agent-babysitter/index.ts b/src/hooks/unstable-agent-babysitter/index.ts index e492281a2..1850f1869 100644 --- a/src/hooks/unstable-agent-babysitter/index.ts +++ b/src/hooks/unstable-agent-babysitter/index.ts @@ -1,250 +1 @@ -import type { BackgroundManager, BackgroundTask } from "../../features/background-agent" -import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state" -import { log } from "../../shared/logger" - -const HOOK_NAME = "unstable-agent-babysitter" -const DEFAULT_TIMEOUT_MS = 120000 -const COOLDOWN_MS = 5 * 60 * 1000 -const THINKING_SUMMARY_MAX_CHARS = 500 - -type BabysittingConfig = { - timeout_ms?: number -} - -type BabysitterContext = { - directory: string - client: { - session: { - messages: (args: { path: { id: string } }) => Promise<{ data?: unknown } | unknown[]> - prompt: (args: { - path: { id: string } - body: { - parts: Array<{ type: "text"; text: string }> - agent?: string - model?: { providerID: string; modelID: string } - } - query?: { directory?: string } - }) => Promise - promptAsync: (args: { - path: { id: string } - body: { - parts: Array<{ type: "text"; text: string }> - agent?: string - model?: { providerID: string; modelID: string } - } - query?: { directory?: string } - }) => Promise - } - } -} - -type BabysitterOptions = { - backgroundManager: Pick - config?: BabysittingConfig -} - -type MessageInfo = { - role?: string - agent?: string - model?: { providerID: string; modelID: string } - providerID?: string - modelID?: string -} - -type MessagePart = { - type?: string - text?: string - thinking?: string -} - -function hasData(value: unknown): value is { data?: unknown } { - return typeof value === "object" && value !== null && "data" in value -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} - -function getMessageInfo(value: unknown): MessageInfo | undefined { - if (!isRecord(value)) return undefined - if (!isRecord(value.info)) return undefined - const info = value.info - const modelValue = isRecord(info.model) - ? info.model - : undefined - const model = modelValue && typeof modelValue.providerID === "string" && typeof modelValue.modelID === "string" - ? { providerID: modelValue.providerID, modelID: modelValue.modelID } - : undefined - return { - role: typeof info.role === "string" ? info.role : undefined, - agent: typeof info.agent === "string" ? info.agent : undefined, - model, - providerID: typeof info.providerID === "string" ? info.providerID : undefined, - modelID: typeof info.modelID === "string" ? info.modelID : undefined, - } -} - -function getMessageParts(value: unknown): MessagePart[] { - if (!isRecord(value)) return [] - if (!Array.isArray(value.parts)) return [] - return value.parts.filter(isRecord).map((part) => ({ - type: typeof part.type === "string" ? part.type : undefined, - text: typeof part.text === "string" ? part.text : undefined, - thinking: typeof part.thinking === "string" ? part.thinking : undefined, - })) -} - -function extractMessages(value: unknown): unknown[] { - if (Array.isArray(value)) { - return value - } - if (hasData(value) && Array.isArray(value.data)) { - return value.data - } - return [] -} - -function isUnstableTask(task: BackgroundTask): boolean { - if (task.isUnstableAgent === true) return true - const modelId = task.model?.modelID?.toLowerCase() - return modelId ? modelId.includes("gemini") || modelId.includes("minimax") : false -} - -async function resolveMainSessionTarget( - ctx: BabysitterContext, - sessionID: string -): Promise<{ agent?: string; model?: { providerID: string; modelID: string } }> { - let agent = getSessionAgent(sessionID) - let model: { providerID: string; modelID: string } | undefined - - try { - const messagesResp = await ctx.client.session.messages({ - path: { id: sessionID }, - }) - const messages = extractMessages(messagesResp) - for (let i = messages.length - 1; i >= 0; i--) { - const info = getMessageInfo(messages[i]) - if (info?.agent || info?.model || (info?.providerID && info?.modelID)) { - agent = agent ?? info?.agent - model = info?.model ?? (info?.providerID && info?.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined) - break - } - } - } catch (error) { - log(`[${HOOK_NAME}] Failed to resolve main session agent`, { sessionID, error: String(error) }) - } - - return { agent, model } -} - -async function getThinkingSummary(ctx: BabysitterContext, sessionID: string): Promise { - try { - const messagesResp = await ctx.client.session.messages({ - path: { id: sessionID }, - }) - const messages = extractMessages(messagesResp) - const chunks: string[] = [] - - for (const message of messages) { - const info = getMessageInfo(message) - if (info?.role !== "assistant") continue - const parts = getMessageParts(message) - for (const part of parts) { - if (part.type === "thinking" && part.thinking) { - chunks.push(part.thinking) - } - if (part.type === "reasoning" && part.text) { - chunks.push(part.text) - } - } - } - - const combined = chunks.join("\n").trim() - if (!combined) return null - if (combined.length <= THINKING_SUMMARY_MAX_CHARS) return combined - return combined.slice(0, THINKING_SUMMARY_MAX_CHARS) + "..." - } catch (error) { - log(`[${HOOK_NAME}] Failed to fetch thinking summary`, { sessionID, error: String(error) }) - return null - } -} - -function buildReminder(task: BackgroundTask, summary: string | null, idleMs: number): string { - const idleSeconds = Math.round(idleMs / 1000) - const summaryText = summary ?? "(No thinking trace available)" - return `Unstable background agent appears idle for ${idleSeconds}s. - -Task ID: ${task.id} -Description: ${task.description} -Agent: ${task.agent} -Status: ${task.status} -Session ID: ${task.sessionID ?? "N/A"} - -Thinking summary (first ${THINKING_SUMMARY_MAX_CHARS} chars): -${summaryText} - -Suggested actions: -- background_output task_id="${task.id}" full_session=true include_thinking=true include_tool_results=true message_limit=50 -- background_cancel taskId="${task.id}" - -This is a reminder only. No automatic action was taken.` -} - -export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, options: BabysitterOptions) { - const reminderCooldowns = new Map() - - const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => { - if (event.type !== "session.idle") return - - const props = event.properties as Record | undefined - const sessionID = props?.sessionID as string | undefined - if (!sessionID) return - - const mainSessionID = getMainSessionID() - if (!mainSessionID || sessionID !== mainSessionID) return - - const tasks = options.backgroundManager.getTasksByParentSession(mainSessionID) - if (tasks.length === 0) return - - const timeoutMs = options.config?.timeout_ms ?? DEFAULT_TIMEOUT_MS - const now = Date.now() - - for (const task of tasks) { - if (task.status !== "running") continue - if (!isUnstableTask(task)) continue - - const lastMessageAt = task.progress?.lastMessageAt - if (!lastMessageAt) continue - - const idleMs = now - lastMessageAt.getTime() - if (idleMs < timeoutMs) continue - - const lastReminderAt = reminderCooldowns.get(task.id) - if (lastReminderAt && now - lastReminderAt < COOLDOWN_MS) continue - - const summary = task.sessionID ? await getThinkingSummary(ctx, task.sessionID) : null - const reminder = buildReminder(task, summary, idleMs) - const { agent, model } = await resolveMainSessionTarget(ctx, mainSessionID) - - try { - await ctx.client.session.promptAsync({ - path: { id: mainSessionID }, - body: { - ...(agent ? { agent } : {}), - ...(model ? { model } : {}), - parts: [{ type: "text", text: reminder }], - }, - query: { directory: ctx.directory }, - }) - reminderCooldowns.set(task.id, now) - log(`[${HOOK_NAME}] Reminder injected`, { taskId: task.id, sessionID: mainSessionID }) - } catch (error) { - log(`[${HOOK_NAME}] Reminder injection failed`, { taskId: task.id, error: String(error) }) - } - } - } - - return { - event: eventHandler, - } -} +export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter-hook" diff --git a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts new file mode 100644 index 000000000..a7b2b551f --- /dev/null +++ b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts @@ -0,0 +1,250 @@ +import type { BackgroundManager, BackgroundTask } from "../../features/background-agent" +import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state" +import { log } from "../../shared/logger" + +const HOOK_NAME = "unstable-agent-babysitter" +const DEFAULT_TIMEOUT_MS = 120000 +const COOLDOWN_MS = 5 * 60 * 1000 +const THINKING_SUMMARY_MAX_CHARS = 500 as const + +type BabysittingConfig = { + timeout_ms?: number +} + +type BabysitterContext = { + directory: string + client: { + session: { + messages: (args: { path: { id: string } }) => Promise<{ data?: unknown } | unknown[]> + prompt: (args: { + path: { id: string } + body: { + parts: Array<{ type: "text"; text: string }> + agent?: string + model?: { providerID: string; modelID: string } + } + query?: { directory?: string } + }) => Promise + promptAsync: (args: { + path: { id: string } + body: { + parts: Array<{ type: "text"; text: string }> + agent?: string + model?: { providerID: string; modelID: string } + } + query?: { directory?: string } + }) => Promise + } + } +} + +type BabysitterOptions = { + backgroundManager: Pick + config?: BabysittingConfig +} + +type MessageInfo = { + role?: string + agent?: string + model?: { providerID: string; modelID: string } + providerID?: string + modelID?: string +} + +type MessagePart = { + type?: string + text?: string + thinking?: string +} + +function hasData(value: unknown): value is { data?: unknown } { + return typeof value === "object" && value !== null && "data" in value +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function getMessageInfo(value: unknown): MessageInfo | undefined { + if (!isRecord(value)) return undefined + if (!isRecord(value.info)) return undefined + const info = value.info + const modelValue = isRecord(info.model) + ? info.model + : undefined + const model = modelValue && typeof modelValue.providerID === "string" && typeof modelValue.modelID === "string" + ? { providerID: modelValue.providerID, modelID: modelValue.modelID } + : undefined + return { + role: typeof info.role === "string" ? info.role : undefined, + agent: typeof info.agent === "string" ? info.agent : undefined, + model, + providerID: typeof info.providerID === "string" ? info.providerID : undefined, + modelID: typeof info.modelID === "string" ? info.modelID : undefined, + } +} + +function getMessageParts(value: unknown): MessagePart[] { + if (!isRecord(value)) return [] + if (!Array.isArray(value.parts)) return [] + return value.parts.filter(isRecord).map((part) => ({ + type: typeof part.type === "string" ? part.type : undefined, + text: typeof part.text === "string" ? part.text : undefined, + thinking: typeof part.thinking === "string" ? part.thinking : undefined, + })) +} + +function extractMessages(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value + } + if (hasData(value) && Array.isArray(value.data)) { + return value.data + } + return [] +} + +function isUnstableTask(task: BackgroundTask): boolean { + if (task.isUnstableAgent === true) return true + const modelId = task.model?.modelID?.toLowerCase() + return modelId ? modelId.includes("gemini") || modelId.includes("minimax") : false +} + +async function resolveMainSessionTarget( + ctx: BabysitterContext, + sessionID: string +): Promise<{ agent?: string; model?: { providerID: string; modelID: string } }> { + let agent = getSessionAgent(sessionID) + let model: { providerID: string; modelID: string } | undefined + + try { + const messagesResp = await ctx.client.session.messages({ + path: { id: sessionID }, + }) + const messages = extractMessages(messagesResp) + for (let i = messages.length - 1; i >= 0; i--) { + const info = getMessageInfo(messages[i]) + if (info?.agent || info?.model || (info?.providerID && info?.modelID)) { + agent = agent ?? info?.agent + model = info?.model ?? (info?.providerID && info?.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined) + break + } + } + } catch (error) { + log(`[${HOOK_NAME}] Failed to resolve main session agent`, { sessionID, error: String(error) }) + } + + return { agent, model } +} + +async function getThinkingSummary(ctx: BabysitterContext, sessionID: string): Promise { + try { + const messagesResp = await ctx.client.session.messages({ + path: { id: sessionID }, + }) + const messages = extractMessages(messagesResp) + const chunks: string[] = [] + + for (const message of messages) { + const info = getMessageInfo(message) + if (info?.role !== "assistant") continue + const parts = getMessageParts(message) + for (const part of parts) { + if (part.type === "thinking" && part.thinking) { + chunks.push(part.thinking) + } + if (part.type === "reasoning" && part.text) { + chunks.push(part.text) + } + } + } + + const combined = chunks.join("\n").trim() + if (!combined) return null + if (combined.length <= THINKING_SUMMARY_MAX_CHARS) return combined + return combined.slice(0, THINKING_SUMMARY_MAX_CHARS) + "..." + } catch (error) { + log(`[${HOOK_NAME}] Failed to fetch thinking summary`, { sessionID, error: String(error) }) + return null + } +} + +function buildReminder(task: BackgroundTask, summary: string | null, idleMs: number): string { + const idleSeconds = Math.round(idleMs / 1000) + const summaryText = summary ?? "(No thinking trace available)" + return `Unstable background agent appears idle for ${idleSeconds}s. + +Task ID: ${task.id} +Description: ${task.description} +Agent: ${task.agent} +Status: ${task.status} +Session ID: ${task.sessionID ?? "N/A"} + +Thinking summary (first ${THINKING_SUMMARY_MAX_CHARS} chars): +${summaryText} + +Suggested actions: +- background_output task_id="${task.id}" full_session=true include_thinking=true include_tool_results=true message_limit=50 +- background_cancel taskId="${task.id}" + +This is a reminder only. No automatic action was taken.` +} + +export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, options: BabysitterOptions) { + const reminderCooldowns = new Map() + + const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => { + if (event.type !== "session.idle") return + + const props = event.properties as Record | undefined + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + + const mainSessionID = getMainSessionID() + if (!mainSessionID || sessionID !== mainSessionID) return + + const tasks = options.backgroundManager.getTasksByParentSession(mainSessionID) + if (tasks.length === 0) return + + const timeoutMs = options.config?.timeout_ms ?? DEFAULT_TIMEOUT_MS + const now = Date.now() + + for (const task of tasks) { + if (task.status !== "running") continue + if (!isUnstableTask(task)) continue + + const lastMessageAt = task.progress?.lastMessageAt + if (!lastMessageAt) continue + + const idleMs = now - lastMessageAt.getTime() + if (idleMs < timeoutMs) continue + + const lastReminderAt = reminderCooldowns.get(task.id) + if (lastReminderAt && now - lastReminderAt < COOLDOWN_MS) continue + + const summary = task.sessionID ? await getThinkingSummary(ctx, task.sessionID) : null + const reminder = buildReminder(task, summary, idleMs) + const { agent, model } = await resolveMainSessionTarget(ctx, mainSessionID) + + try { + await ctx.client.session.promptAsync({ + path: { id: mainSessionID }, + body: { + ...(agent ? { agent } : {}), + ...(model ? { model } : {}), + parts: [{ type: "text", text: reminder }], + }, + query: { directory: ctx.directory }, + }) + reminderCooldowns.set(task.id, now) + log(`[${HOOK_NAME}] Reminder injected`, { taskId: task.id, sessionID: mainSessionID }) + } catch (error) { + log(`[${HOOK_NAME}] Reminder injection failed`, { taskId: task.id, error: String(error) }) + } + } + } + + return { + event: eventHandler, + } +} diff --git a/src/shared/command-executor.ts b/src/shared/command-executor.ts index 9baa85aa1..5efc0491c 100644 --- a/src/shared/command-executor.ts +++ b/src/shared/command-executor.ts @@ -1,225 +1,5 @@ -import { spawn } from "child_process" -import { exec } from "child_process" -import { promisify } from "util" -import { existsSync } from "fs" -import { homedir } from "os" +export { executeHookCommand } from "./command-executor/execute-hook-command" +export type { CommandResult, ExecuteHookOptions } from "./command-executor/execute-hook-command" -const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"] -const DEFAULT_BASH_PATHS = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"] - -function getHomeDir(): string { - return process.env.HOME || process.env.USERPROFILE || homedir() -} - -function findShellPath(defaultPaths: string[], customPath?: string): string | null { - if (customPath && existsSync(customPath)) { - return customPath - } - for (const path of defaultPaths) { - if (existsSync(path)) { - return path - } - } - return null -} - -function findZshPath(customZshPath?: string): string | null { - return findShellPath(DEFAULT_ZSH_PATHS, customZshPath) -} - -function findBashPath(): string | null { - return findShellPath(DEFAULT_BASH_PATHS) -} - -const execAsync = promisify(exec) - -export interface CommandResult { - exitCode: number - stdout?: string - stderr?: string -} - -export interface ExecuteHookOptions { - forceZsh?: boolean - zshPath?: string -} - -/** - * Execute a hook command with stdin input - */ -export async function executeHookCommand( - command: string, - stdin: string, - cwd: string, - options?: ExecuteHookOptions -): Promise { - const home = getHomeDir() - - let expandedCommand = command - .replace(/^~(?=\/|$)/g, home) - .replace(/\s~(?=\/)/g, ` ${home}`) - .replace(/\$CLAUDE_PROJECT_DIR/g, cwd) - .replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd) - - let finalCommand = expandedCommand - - if (options?.forceZsh) { - // Always verify shell exists before using it - const zshPath = findZshPath(options.zshPath) - const escapedCommand = expandedCommand.replace(/'/g, "'\\''") - if (zshPath) { - finalCommand = `${zshPath} -lc '${escapedCommand}'` - } else { - // Fall back to bash login shell to preserve PATH from user profile - const bashPath = findBashPath() - if (bashPath) { - finalCommand = `${bashPath} -lc '${escapedCommand}'` - } - // If neither zsh nor bash found, fall through to spawn with shell: true - } - } - - return new Promise((resolve) => { - const proc = spawn(finalCommand, { - cwd, - shell: true, - env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd }, - }) - - let stdout = "" - let stderr = "" - - proc.stdout?.on("data", (data) => { - stdout += data.toString() - }) - - proc.stderr?.on("data", (data) => { - stderr += data.toString() - }) - - proc.stdin?.write(stdin) - proc.stdin?.end() - - proc.on("close", (code) => { - resolve({ - exitCode: code ?? 0, - stdout: stdout.trim(), - stderr: stderr.trim(), - }) - }) - - proc.on("error", (err) => { - resolve({ - exitCode: 1, - stderr: err.message, - }) - }) - }) -} - -/** - * Execute a simple command and return output - */ -export async function executeCommand(command: string): Promise { - try { - const { stdout, stderr } = await execAsync(command) - - const out = stdout?.toString().trim() ?? "" - const err = stderr?.toString().trim() ?? "" - - if (err) { - if (out) { - return `${out}\n[stderr: ${err}]` - } - return `[stderr: ${err}]` - } - - return out - } catch (error: unknown) { - const e = error as { stdout?: Buffer; stderr?: Buffer; message?: string } - const stdout = e?.stdout?.toString().trim() ?? "" - const stderr = e?.stderr?.toString().trim() ?? "" - const errMsg = stderr || e?.message || String(error) - - if (stdout) { - return `${stdout}\n[stderr: ${errMsg}]` - } - return `[stderr: ${errMsg}]` - } -} - -/** - * Find and execute embedded commands in text (!`command`) - */ -interface CommandMatch { - fullMatch: string - command: string - start: number - end: number -} - -const COMMAND_PATTERN = /!`([^`]+)`/g - -function findCommands(text: string): CommandMatch[] { - const matches: CommandMatch[] = [] - let match: RegExpExecArray | null - - COMMAND_PATTERN.lastIndex = 0 - - while ((match = COMMAND_PATTERN.exec(text)) !== null) { - matches.push({ - fullMatch: match[0], - command: match[1], - start: match.index, - end: match.index + match[0].length, - }) - } - - return matches -} - -/** - * Resolve embedded commands in text recursively - */ -export async function resolveCommandsInText( - text: string, - depth: number = 0, - maxDepth: number = 3 -): Promise { - if (depth >= maxDepth) { - return text - } - - const matches = findCommands(text) - if (matches.length === 0) { - return text - } - - const tasks = matches.map((m) => executeCommand(m.command)) - const results = await Promise.allSettled(tasks) - - const replacements = new Map() - - matches.forEach((match, idx) => { - const result = results[idx] - if (result.status === "rejected") { - replacements.set( - match.fullMatch, - `[error: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}]` - ) - } else { - replacements.set(match.fullMatch, result.value) - } - }) - - let resolved = text - for (const [pattern, replacement] of replacements.entries()) { - resolved = resolved.split(pattern).join(replacement) - } - - if (findCommands(resolved).length > 0) { - return resolveCommandsInText(resolved, depth + 1, maxDepth) - } - - return resolved -} +export { executeCommand } from "./command-executor/execute-command" +export { resolveCommandsInText } from "./command-executor/resolve-commands-in-text" diff --git a/src/shared/command-executor/embedded-commands.ts b/src/shared/command-executor/embedded-commands.ts new file mode 100644 index 000000000..2a856d9ec --- /dev/null +++ b/src/shared/command-executor/embedded-commands.ts @@ -0,0 +1,26 @@ +export interface CommandMatch { + fullMatch: string + command: string + start: number + end: number +} + +const COMMAND_PATTERN = /!`([^`]+)`/g + +export function findEmbeddedCommands(text: string): CommandMatch[] { + const matches: CommandMatch[] = [] + let match: RegExpExecArray | null + + COMMAND_PATTERN.lastIndex = 0 + + while ((match = COMMAND_PATTERN.exec(text)) !== null) { + matches.push({ + fullMatch: match[0], + command: match[1], + start: match.index, + end: match.index + match[0].length, + }) + } + + return matches +} diff --git a/src/shared/command-executor/execute-command.ts b/src/shared/command-executor/execute-command.ts new file mode 100644 index 000000000..9140b5eb1 --- /dev/null +++ b/src/shared/command-executor/execute-command.ts @@ -0,0 +1,28 @@ +import { exec } from "node:child_process" +import { promisify } from "node:util" + +const execAsync = promisify(exec) + +type ExecError = { stdout?: Buffer; stderr?: Buffer; message?: string } + +export async function executeCommand(command: string): Promise { + try { + const { stdout, stderr } = await execAsync(command) + + const out = stdout?.toString().trim() ?? "" + const err = stderr?.toString().trim() ?? "" + + if (err) { + return out ? `${out}\n[stderr: ${err}]` : `[stderr: ${err}]` + } + + return out + } catch (error: unknown) { + const e = error as ExecError + const stdout = e?.stdout?.toString().trim() ?? "" + const stderr = e?.stderr?.toString().trim() ?? "" + const errorMessage = stderr || e?.message || String(error) + + return stdout ? `${stdout}\n[stderr: ${errorMessage}]` : `[stderr: ${errorMessage}]` + } +} diff --git a/src/shared/command-executor/execute-hook-command.ts b/src/shared/command-executor/execute-hook-command.ts new file mode 100644 index 000000000..f0c60c994 --- /dev/null +++ b/src/shared/command-executor/execute-hook-command.ts @@ -0,0 +1,78 @@ +import { spawn } from "node:child_process" +import { getHomeDirectory } from "./home-directory" +import { findBashPath, findZshPath } from "./shell-path" + +export interface CommandResult { + exitCode: number + stdout?: string + stderr?: string +} + +export interface ExecuteHookOptions { + forceZsh?: boolean + zshPath?: string +} + +export async function executeHookCommand( + command: string, + stdin: string, + cwd: string, + options?: ExecuteHookOptions, +): Promise { + const home = getHomeDirectory() + + const expandedCommand = command + .replace(/^~(?=\/|$)/g, home) + .replace(/\s~(?=\/)/g, ` ${home}`) + .replace(/\$CLAUDE_PROJECT_DIR/g, cwd) + .replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd) + + let finalCommand = expandedCommand + + if (options?.forceZsh) { + const zshPath = findZshPath(options.zshPath) + const escapedCommand = expandedCommand.replace(/'/g, "'\\''") + if (zshPath) { + finalCommand = `${zshPath} -lc '${escapedCommand}'` + } else { + const bashPath = findBashPath() + if (bashPath) { + finalCommand = `${bashPath} -lc '${escapedCommand}'` + } + } + } + + return new Promise((resolve) => { + const proc = spawn(finalCommand, { + cwd, + shell: true, + env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd }, + }) + + let stdout = "" + let stderr = "" + + proc.stdout?.on("data", (data) => { + stdout += data.toString() + }) + + proc.stderr?.on("data", (data) => { + stderr += data.toString() + }) + + proc.stdin?.write(stdin) + proc.stdin?.end() + + proc.on("close", (code) => { + resolve({ + exitCode: code ?? 0, + stdout: stdout.trim(), + stderr: stderr.trim(), + }) + }) + + proc.on("error", (err) => { + resolve({ exitCode: 1, stderr: err.message }) + }) + }) +} diff --git a/src/shared/command-executor/home-directory.ts b/src/shared/command-executor/home-directory.ts new file mode 100644 index 000000000..8ea21991b --- /dev/null +++ b/src/shared/command-executor/home-directory.ts @@ -0,0 +1,5 @@ +import { homedir } from "node:os" + +export function getHomeDirectory(): string { + return process.env.HOME || process.env.USERPROFILE || homedir() +} diff --git a/src/shared/command-executor/resolve-commands-in-text.ts b/src/shared/command-executor/resolve-commands-in-text.ts new file mode 100644 index 000000000..eb692e921 --- /dev/null +++ b/src/shared/command-executor/resolve-commands-in-text.ts @@ -0,0 +1,49 @@ +import { executeCommand } from "./execute-command" +import { findEmbeddedCommands } from "./embedded-commands" + +export async function resolveCommandsInText( + text: string, + depth: number = 0, + maxDepth: number = 3, +): Promise { + if (depth >= maxDepth) { + return text + } + + const matches = findEmbeddedCommands(text) + if (matches.length === 0) { + return text + } + + const tasks = matches.map((m) => executeCommand(m.command)) + const results = await Promise.allSettled(tasks) + + const replacements = new Map() + + matches.forEach((match, idx) => { + const result = results[idx] + if (result.status === "rejected") { + replacements.set( + match.fullMatch, + `[error: ${ + result.reason instanceof Error + ? result.reason.message + : String(result.reason) + }]`, + ) + } else { + replacements.set(match.fullMatch, result.value) + } + }) + + let resolved = text + for (const [pattern, replacement] of replacements.entries()) { + resolved = resolved.split(pattern).join(replacement) + } + + if (findEmbeddedCommands(resolved).length > 0) { + return resolveCommandsInText(resolved, depth + 1, maxDepth) + } + + return resolved +} diff --git a/src/shared/command-executor/shell-path.ts b/src/shared/command-executor/shell-path.ts new file mode 100644 index 000000000..e3131d445 --- /dev/null +++ b/src/shared/command-executor/shell-path.ts @@ -0,0 +1,27 @@ +import { existsSync } from "node:fs" + +const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"] +const DEFAULT_BASH_PATHS = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"] + +function findShellPath( + defaultPaths: string[], + customPath?: string, +): string | null { + if (customPath && existsSync(customPath)) { + return customPath + } + for (const path of defaultPaths) { + if (existsSync(path)) { + return path + } + } + return null +} + +export function findZshPath(customZshPath?: string): string | null { + return findShellPath(DEFAULT_ZSH_PATHS, customZshPath) +} + +export function findBashPath(): string | null { + return findShellPath(DEFAULT_BASH_PATHS) +} diff --git a/src/shared/tmux/tmux-utils.ts b/src/shared/tmux/tmux-utils.ts index 9dd2d71ba..df4d417d6 100644 --- a/src/shared/tmux/tmux-utils.ts +++ b/src/shared/tmux/tmux-utils.ts @@ -1,312 +1,13 @@ -import { spawn } from "bun" -import type { TmuxConfig, TmuxLayout } from "../../config/schema" -import type { SpawnPaneResult } from "./types" -import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver" +export { isInsideTmux, getCurrentPaneId } from "./tmux-utils/environment" +export type { SplitDirection } from "./tmux-utils/environment" -let serverAvailable: boolean | null = null -let serverCheckUrl: string | null = null +export { isServerRunning, resetServerCheck } from "./tmux-utils/server-health" -export function isInsideTmux(): boolean { - return !!process.env.TMUX -} +export { getPaneDimensions } from "./tmux-utils/pane-dimensions" +export type { PaneDimensions } from "./tmux-utils/pane-dimensions" -export async function isServerRunning(serverUrl: string): Promise { - if (serverCheckUrl === serverUrl && serverAvailable === true) { - return true - } +export { spawnTmuxPane } from "./tmux-utils/pane-spawn" +export { closeTmuxPane } from "./tmux-utils/pane-close" +export { replaceTmuxPane } from "./tmux-utils/pane-replace" - const healthUrl = new URL("/health", serverUrl).toString() - const timeoutMs = 3000 - const maxAttempts = 2 - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), timeoutMs) - - try { - const response = await fetch(healthUrl, { signal: controller.signal }).catch( - () => null - ) - clearTimeout(timeout) - - if (response?.ok) { - serverCheckUrl = serverUrl - serverAvailable = true - return true - } - } finally { - clearTimeout(timeout) - } - - if (attempt < maxAttempts) { - await new Promise((r) => setTimeout(r, 250)) - } - } - - return false -} - -export function resetServerCheck(): void { - serverAvailable = null - serverCheckUrl = null -} - -export type SplitDirection = "-h" | "-v" - -export function getCurrentPaneId(): string | undefined { - return process.env.TMUX_PANE -} - -export interface PaneDimensions { - paneWidth: number - windowWidth: number -} - -export async function getPaneDimensions(paneId: string): Promise { - const tmux = await getTmuxPath() - if (!tmux) return null - - const proc = spawn([tmux, "display", "-p", "-t", paneId, "#{pane_width},#{window_width}"], { - stdout: "pipe", - stderr: "pipe", - }) - const exitCode = await proc.exited - const stdout = await new Response(proc.stdout).text() - - if (exitCode !== 0) return null - - const [paneWidth, windowWidth] = stdout.trim().split(",").map(Number) - if (isNaN(paneWidth) || isNaN(windowWidth)) return null - - return { paneWidth, windowWidth } -} - -export async function spawnTmuxPane( - sessionId: string, - description: string, - config: TmuxConfig, - serverUrl: string, - targetPaneId?: string, - splitDirection: SplitDirection = "-h" -): Promise { - const { log } = await import("../logger") - - log("[spawnTmuxPane] called", { sessionId, description, serverUrl, configEnabled: config.enabled, targetPaneId, splitDirection }) - - if (!config.enabled) { - log("[spawnTmuxPane] SKIP: config.enabled is false") - return { success: false } - } - if (!isInsideTmux()) { - log("[spawnTmuxPane] SKIP: not inside tmux", { TMUX: process.env.TMUX }) - return { success: false } - } - - const serverRunning = await isServerRunning(serverUrl) - if (!serverRunning) { - log("[spawnTmuxPane] SKIP: server not running", { serverUrl }) - return { success: false } - } - - const tmux = await getTmuxPath() - if (!tmux) { - log("[spawnTmuxPane] SKIP: tmux not found") - return { success: false } - } - - log("[spawnTmuxPane] all checks passed, spawning...") - - const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}` - - const args = [ - "split-window", - splitDirection, - "-d", - "-P", - "-F", - "#{pane_id}", - ...(targetPaneId ? ["-t", targetPaneId] : []), - opencodeCmd, - ] - - const proc = spawn([tmux, ...args], { stdout: "pipe", stderr: "pipe" }) - const exitCode = await proc.exited - const stdout = await new Response(proc.stdout).text() - const paneId = stdout.trim() - - if (exitCode !== 0 || !paneId) { - return { success: false } - } - - const title = `omo-subagent-${description.slice(0, 20)}` - const titleProc = spawn([tmux, "select-pane", "-t", paneId, "-T", title], { - stdout: "ignore", - stderr: "pipe", - }) - // Drain stderr immediately to avoid backpressure - const stderrPromise = new Response(titleProc.stderr).text().catch(() => "") - const titleExitCode = await titleProc.exited - if (titleExitCode !== 0) { - const titleStderr = await stderrPromise - log("[spawnTmuxPane] WARNING: failed to set pane title", { - paneId, - title, - exitCode: titleExitCode, - stderr: titleStderr.trim(), - }) - } - - return { success: true, paneId } -} - -export async function closeTmuxPane(paneId: string): Promise { - const { log } = await import("../logger") - - if (!isInsideTmux()) { - log("[closeTmuxPane] SKIP: not inside tmux") - return false - } - - const tmux = await getTmuxPath() - if (!tmux) { - log("[closeTmuxPane] SKIP: tmux not found") - return false - } - - // Send Ctrl+C to trigger graceful exit of opencode attach process - log("[closeTmuxPane] sending Ctrl+C for graceful shutdown", { paneId }) - const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], { - stdout: "pipe", - stderr: "pipe", - }) - await ctrlCProc.exited - - // Brief delay for graceful shutdown - await new Promise((r) => setTimeout(r, 250)) - - log("[closeTmuxPane] killing pane", { paneId }) - - const proc = spawn([tmux, "kill-pane", "-t", paneId], { - stdout: "pipe", - stderr: "pipe", - }) - const exitCode = await proc.exited - const stderr = await new Response(proc.stderr).text() - - if (exitCode !== 0) { - log("[closeTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() }) - } else { - log("[closeTmuxPane] SUCCESS", { paneId }) - } - - return exitCode === 0 -} - -export async function replaceTmuxPane( - paneId: string, - sessionId: string, - description: string, - config: TmuxConfig, - serverUrl: string -): Promise { - const { log } = await import("../logger") - - log("[replaceTmuxPane] called", { paneId, sessionId, description }) - - if (!config.enabled) { - return { success: false } - } - if (!isInsideTmux()) { - return { success: false } - } - - const tmux = await getTmuxPath() - if (!tmux) { - return { success: false } - } - - // Send Ctrl+C to trigger graceful exit of existing opencode attach process - // Note: No delay here - respawn-pane -k will handle any remaining process. - // We send Ctrl+C first to give the process a chance to exit gracefully, - // then immediately respawn. This prevents orphaned processes while avoiding - // the race condition where the pane closes before respawn-pane runs. - log("[replaceTmuxPane] sending Ctrl+C for graceful shutdown", { paneId }) - const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], { - stdout: "pipe", - stderr: "pipe", - }) - await ctrlCProc.exited - - const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}` - - const proc = spawn([tmux, "respawn-pane", "-k", "-t", paneId, opencodeCmd], { - stdout: "pipe", - stderr: "pipe", - }) - const exitCode = await proc.exited - - if (exitCode !== 0) { - const stderr = await new Response(proc.stderr).text() - log("[replaceTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() }) - return { success: false } - } - - const title = `omo-subagent-${description.slice(0, 20)}` - const titleProc = spawn([tmux, "select-pane", "-t", paneId, "-T", title], { - stdout: "ignore", - stderr: "pipe", - }) - // Drain stderr immediately to avoid backpressure - const stderrPromise = new Response(titleProc.stderr).text().catch(() => "") - const titleExitCode = await titleProc.exited - if (titleExitCode !== 0) { - const titleStderr = await stderrPromise - log("[replaceTmuxPane] WARNING: failed to set pane title", { - paneId, - exitCode: titleExitCode, - stderr: titleStderr.trim(), - }) - } - - log("[replaceTmuxPane] SUCCESS", { paneId, sessionId }) - return { success: true, paneId } -} - -export async function applyLayout( - tmux: string, - layout: TmuxLayout, - mainPaneSize: number -): Promise { - const layoutProc = spawn([tmux, "select-layout", layout], { stdout: "ignore", stderr: "ignore" }) - await layoutProc.exited - - if (layout.startsWith("main-")) { - const dimension = - layout === "main-horizontal" ? "main-pane-height" : "main-pane-width" - const sizeProc = spawn([tmux, "set-window-option", dimension, `${mainPaneSize}%`], { - stdout: "ignore", - stderr: "ignore", - }) - await sizeProc.exited - } -} - -export async function enforceMainPaneWidth( - mainPaneId: string, - windowWidth: number -): Promise { - const { log } = await import("../logger") - const tmux = await getTmuxPath() - if (!tmux) return - - const DIVIDER_WIDTH = 1 - const mainWidth = Math.floor((windowWidth - DIVIDER_WIDTH) / 2) - - const proc = spawn([tmux, "resize-pane", "-t", mainPaneId, "-x", String(mainWidth)], { - stdout: "ignore", - stderr: "ignore", - }) - await proc.exited - - log("[enforceMainPaneWidth] main pane resized", { mainPaneId, mainWidth, windowWidth }) -} +export { applyLayout, enforceMainPaneWidth } from "./tmux-utils/layout" diff --git a/src/shared/tmux/tmux-utils/environment.ts b/src/shared/tmux/tmux-utils/environment.ts new file mode 100644 index 000000000..5c8166b3f --- /dev/null +++ b/src/shared/tmux/tmux-utils/environment.ts @@ -0,0 +1,9 @@ +export type SplitDirection = "-h" | "-v" + +export function isInsideTmux(): boolean { + return Boolean(process.env.TMUX) +} + +export function getCurrentPaneId(): string | undefined { + return process.env.TMUX_PANE +} diff --git a/src/shared/tmux/tmux-utils/layout.ts b/src/shared/tmux/tmux-utils/layout.ts new file mode 100644 index 000000000..d7900ff73 --- /dev/null +++ b/src/shared/tmux/tmux-utils/layout.ts @@ -0,0 +1,49 @@ +import { spawn } from "bun" +import type { TmuxLayout } from "../../../config/schema" +import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" + +export async function applyLayout( + tmux: string, + layout: TmuxLayout, + mainPaneSize: number, +): Promise { + const layoutProc = spawn([tmux, "select-layout", layout], { + stdout: "ignore", + stderr: "ignore", + }) + await layoutProc.exited + + if (layout.startsWith("main-")) { + const dimension = + layout === "main-horizontal" ? "main-pane-height" : "main-pane-width" + const sizeProc = spawn( + [tmux, "set-window-option", dimension, `${mainPaneSize}%`], + { stdout: "ignore", stderr: "ignore" }, + ) + await sizeProc.exited + } +} + +export async function enforceMainPaneWidth( + mainPaneId: string, + windowWidth: number, +): Promise { + const { log } = await import("../../logger") + const tmux = await getTmuxPath() + if (!tmux) return + + const dividerWidth = 1 + const mainWidth = Math.floor((windowWidth - dividerWidth) / 2) + + const proc = spawn([tmux, "resize-pane", "-t", mainPaneId, "-x", String(mainWidth)], { + stdout: "ignore", + stderr: "ignore", + }) + await proc.exited + + log("[enforceMainPaneWidth] main pane resized", { + mainPaneId, + mainWidth, + windowWidth, + }) +} diff --git a/src/shared/tmux/tmux-utils/pane-close.ts b/src/shared/tmux/tmux-utils/pane-close.ts new file mode 100644 index 000000000..cc6f4b6c4 --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-close.ts @@ -0,0 +1,48 @@ +import { spawn } from "bun" +import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" +import { isInsideTmux } from "./environment" + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +export async function closeTmuxPane(paneId: string): Promise { + const { log } = await import("../../logger") + + if (!isInsideTmux()) { + log("[closeTmuxPane] SKIP: not inside tmux") + return false + } + + const tmux = await getTmuxPath() + if (!tmux) { + log("[closeTmuxPane] SKIP: tmux not found") + return false + } + + log("[closeTmuxPane] sending Ctrl+C for graceful shutdown", { paneId }) + const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], { + stdout: "pipe", + stderr: "pipe", + }) + await ctrlCProc.exited + + await delay(250) + + log("[closeTmuxPane] killing pane", { paneId }) + + const proc = spawn([tmux, "kill-pane", "-t", paneId], { + stdout: "pipe", + stderr: "pipe", + }) + const exitCode = await proc.exited + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + log("[closeTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() }) + } else { + log("[closeTmuxPane] SUCCESS", { paneId }) + } + + return exitCode === 0 +} diff --git a/src/shared/tmux/tmux-utils/pane-dimensions.ts b/src/shared/tmux/tmux-utils/pane-dimensions.ts new file mode 100644 index 000000000..a11ad2602 --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-dimensions.ts @@ -0,0 +1,28 @@ +import { spawn } from "bun" +import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" + +export interface PaneDimensions { + paneWidth: number + windowWidth: number +} + +export async function getPaneDimensions( + paneId: string, +): Promise { + const tmux = await getTmuxPath() + if (!tmux) return null + + const proc = spawn( + [tmux, "display", "-p", "-t", paneId, "#{pane_width},#{window_width}"], + { stdout: "pipe", stderr: "pipe" }, + ) + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + + if (exitCode !== 0) return null + + const [paneWidth, windowWidth] = stdout.trim().split(",").map(Number) + if (Number.isNaN(paneWidth) || Number.isNaN(windowWidth)) return null + + return { paneWidth, windowWidth } +} diff --git a/src/shared/tmux/tmux-utils/pane-replace.ts b/src/shared/tmux/tmux-utils/pane-replace.ts new file mode 100644 index 000000000..728041a64 --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-replace.ts @@ -0,0 +1,69 @@ +import { spawn } from "bun" +import type { TmuxConfig } from "../../../config/schema" +import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" +import type { SpawnPaneResult } from "../types" +import { isInsideTmux } from "./environment" + +export async function replaceTmuxPane( + paneId: string, + sessionId: string, + description: string, + config: TmuxConfig, + serverUrl: string, +): Promise { + const { log } = await import("../../logger") + + log("[replaceTmuxPane] called", { paneId, sessionId, description }) + + if (!config.enabled) { + return { success: false } + } + if (!isInsideTmux()) { + return { success: false } + } + + const tmux = await getTmuxPath() + if (!tmux) { + return { success: false } + } + + log("[replaceTmuxPane] sending Ctrl+C for graceful shutdown", { paneId }) + const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], { + stdout: "pipe", + stderr: "pipe", + }) + await ctrlCProc.exited + + const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}` + + const proc = spawn([tmux, "respawn-pane", "-k", "-t", paneId, opencodeCmd], { + stdout: "pipe", + stderr: "pipe", + }) + const exitCode = await proc.exited + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text() + log("[replaceTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() }) + return { success: false } + } + + const title = `omo-subagent-${description.slice(0, 20)}` + const titleProc = spawn([tmux, "select-pane", "-t", paneId, "-T", title], { + stdout: "ignore", + stderr: "pipe", + }) + const stderrPromise = new Response(titleProc.stderr).text().catch(() => "") + const titleExitCode = await titleProc.exited + if (titleExitCode !== 0) { + const titleStderr = await stderrPromise + log("[replaceTmuxPane] WARNING: failed to set pane title", { + paneId, + exitCode: titleExitCode, + stderr: titleStderr.trim(), + }) + } + + log("[replaceTmuxPane] SUCCESS", { paneId, sessionId }) + return { success: true, paneId } +} diff --git a/src/shared/tmux/tmux-utils/pane-spawn.ts b/src/shared/tmux/tmux-utils/pane-spawn.ts new file mode 100644 index 000000000..959a4e68c --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-spawn.ts @@ -0,0 +1,91 @@ +import { spawn } from "bun" +import type { TmuxConfig } from "../../../config/schema" +import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" +import type { SpawnPaneResult } from "../types" +import type { SplitDirection } from "./environment" +import { isInsideTmux } from "./environment" +import { isServerRunning } from "./server-health" + +export async function spawnTmuxPane( + sessionId: string, + description: string, + config: TmuxConfig, + serverUrl: string, + targetPaneId?: string, + splitDirection: SplitDirection = "-h", +): Promise { + const { log } = await import("../../logger") + + log("[spawnTmuxPane] called", { + sessionId, + description, + serverUrl, + configEnabled: config.enabled, + targetPaneId, + splitDirection, + }) + + if (!config.enabled) { + log("[spawnTmuxPane] SKIP: config.enabled is false") + return { success: false } + } + if (!isInsideTmux()) { + log("[spawnTmuxPane] SKIP: not inside tmux", { TMUX: process.env.TMUX }) + return { success: false } + } + + const serverRunning = await isServerRunning(serverUrl) + if (!serverRunning) { + log("[spawnTmuxPane] SKIP: server not running", { serverUrl }) + return { success: false } + } + + const tmux = await getTmuxPath() + if (!tmux) { + log("[spawnTmuxPane] SKIP: tmux not found") + return { success: false } + } + + log("[spawnTmuxPane] all checks passed, spawning...") + + const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}` + + const args = [ + "split-window", + splitDirection, + "-d", + "-P", + "-F", + "#{pane_id}", + ...(targetPaneId ? ["-t", targetPaneId] : []), + opencodeCmd, + ] + + const proc = spawn([tmux, ...args], { stdout: "pipe", stderr: "pipe" }) + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const paneId = stdout.trim() + + if (exitCode !== 0 || !paneId) { + return { success: false } + } + + const title = `omo-subagent-${description.slice(0, 20)}` + const titleProc = spawn([tmux, "select-pane", "-t", paneId, "-T", title], { + stdout: "ignore", + stderr: "pipe", + }) + const stderrPromise = new Response(titleProc.stderr).text().catch(() => "") + const titleExitCode = await titleProc.exited + if (titleExitCode !== 0) { + const titleStderr = await stderrPromise + log("[spawnTmuxPane] WARNING: failed to set pane title", { + paneId, + title, + exitCode: titleExitCode, + stderr: titleStderr.trim(), + }) + } + + return { success: true, paneId } +} diff --git a/src/shared/tmux/tmux-utils/server-health.ts b/src/shared/tmux/tmux-utils/server-health.ts new file mode 100644 index 000000000..f45d8d01b --- /dev/null +++ b/src/shared/tmux/tmux-utils/server-health.ts @@ -0,0 +1,47 @@ +let serverAvailable: boolean | null = null +let serverCheckUrl: string | null = null + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +export async function isServerRunning(serverUrl: string): Promise { + if (serverCheckUrl === serverUrl && serverAvailable === true) { + return true + } + + const healthUrl = new URL("/health", serverUrl).toString() + const timeoutMs = 3000 + const maxAttempts = 2 + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + + try { + const response = await fetch(healthUrl, { + signal: controller.signal, + }).catch(() => null) + clearTimeout(timeout) + + if (response?.ok) { + serverCheckUrl = serverUrl + serverAvailable = true + return true + } + } finally { + clearTimeout(timeout) + } + + if (attempt < maxAttempts) { + await delay(250) + } + } + + return false +} + +export function resetServerCheck(): void { + serverAvailable = null + serverCheckUrl = null +} diff --git a/src/tools/background-task/clients.ts b/src/tools/background-task/clients.ts new file mode 100644 index 000000000..b94977c37 --- /dev/null +++ b/src/tools/background-task/clients.ts @@ -0,0 +1,32 @@ +import type { BackgroundManager } from "../../features/background-agent" + +export type BackgroundOutputMessage = { + id?: string + info?: { role?: string; time?: string | { created?: number }; agent?: string } + parts?: Array<{ + type?: string + text?: string + thinking?: string + content?: string | Array<{ type: string; text?: string }> + output?: string + name?: string + }> +} + +export type BackgroundOutputMessagesResult = + | { data?: BackgroundOutputMessage[]; error?: unknown } + | BackgroundOutputMessage[] + +export type BackgroundOutputClient = { + session: { + messages: (args: { path: { id: string } }) => Promise + } +} + +export type BackgroundCancelClient = { + session: { + abort: (args: { path: { id: string } }) => Promise + } +} + +export type BackgroundOutputManager = Pick diff --git a/src/tools/background-task/create-background-cancel.ts b/src/tools/background-task/create-background-cancel.ts new file mode 100644 index 000000000..fb584e9b3 --- /dev/null +++ b/src/tools/background-task/create-background-cancel.ts @@ -0,0 +1,115 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin" +import type { BackgroundManager } from "../../features/background-agent" +import type { BackgroundCancelArgs } from "./types" +import type { BackgroundCancelClient } from "./clients" +import { BACKGROUND_CANCEL_DESCRIPTION } from "./constants" + +export function createBackgroundCancel(manager: BackgroundManager, client: BackgroundCancelClient): ToolDefinition { + return tool({ + description: BACKGROUND_CANCEL_DESCRIPTION, + args: { + taskId: tool.schema.string().optional().describe("Task ID to cancel (required if all=false)"), + all: tool.schema.boolean().optional().describe("Cancel all running background tasks (default: false)"), + }, + async execute(args: BackgroundCancelArgs, toolContext) { + try { + const cancelAll = args.all === true + + if (!cancelAll && !args.taskId) { + return `[ERROR] Invalid arguments: Either provide a taskId or set all=true to cancel all running tasks.` + } + + if (cancelAll) { + const tasks = manager.getAllDescendantTasks(toolContext.sessionID) + const cancellableTasks = tasks.filter((t: { status: string }) => t.status === "running" || t.status === "pending") + + if (cancellableTasks.length === 0) { + return `No running or pending background tasks to cancel.` + } + + const cancelledInfo: Array<{ id: string; description: string; status: string; sessionID?: string }> = [] + + for (const task of cancellableTasks) { + const originalStatus = task.status + const cancelled = await manager.cancelTask(task.id, { + source: "background_cancel", + abortSession: originalStatus === "running", + skipNotification: true, + }) + if (!cancelled) continue + cancelledInfo.push({ + id: task.id, + description: task.description, + status: originalStatus === "pending" ? "pending" : "running", + sessionID: task.sessionID, + }) + } + + const tableRows = cancelledInfo + .map( + (t) => + `| \`${t.id}\` | ${t.description} | ${t.status} | ${t.sessionID ? `\`${t.sessionID}\`` : "(not started)"} |` + ) + .join("\n") + + const resumableTasks = cancelledInfo.filter((t) => t.sessionID) + const resumeSection = + resumableTasks.length > 0 + ? `\n## Continue Instructions + +To continue a cancelled task, use: +\`\`\` +task(session_id="", prompt="Continue: ") +\`\`\` + +Continuable sessions: +${resumableTasks.map((t) => `- \`${t.sessionID}\` (${t.description})`).join("\n")}` + : "" + + return `Cancelled ${cancelledInfo.length} background task(s): + +| Task ID | Description | Status | Session ID | +|---------|-------------|--------|------------| +${tableRows} +${resumeSection}` + } + + const task = manager.getTask(args.taskId!) + if (!task) { + return `[ERROR] Task not found: ${args.taskId}` + } + + if (task.status !== "running" && task.status !== "pending") { + return `[ERROR] Cannot cancel task: current status is "${task.status}". +Only running or pending tasks can be cancelled.` + } + + const cancelled = await manager.cancelTask(task.id, { + source: "background_cancel", + abortSession: task.status === "running", + skipNotification: true, + }) + if (!cancelled) { + return `[ERROR] Failed to cancel task: ${task.id}` + } + + if (task.status === "pending") { + return `Pending task cancelled successfully + +Task ID: ${task.id} +Description: ${task.description} +Status: ${task.status}` + } + + return `Task cancelled successfully + +Task ID: ${task.id} +Description: ${task.description} +Session ID: ${task.sessionID} +Status: ${task.status}` + } catch (error) { + return `[ERROR] Error cancelling task: ${error instanceof Error ? error.message : String(error)}` + } + }, + }) +} diff --git a/src/tools/background-task/create-background-output.ts b/src/tools/background-task/create-background-output.ts new file mode 100644 index 000000000..c498ddbf1 --- /dev/null +++ b/src/tools/background-task/create-background-output.ts @@ -0,0 +1,89 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin" +import type { BackgroundOutputArgs } from "./types" +import type { BackgroundOutputClient, BackgroundOutputManager } from "./clients" +import { BACKGROUND_OUTPUT_DESCRIPTION } from "./constants" +import { delay } from "./delay" +import { formatFullSession } from "./full-session-format" +import { formatTaskResult } from "./task-result-format" +import { formatTaskStatus } from "./task-status-format" + +export function createBackgroundOutput(manager: BackgroundOutputManager, client: BackgroundOutputClient): ToolDefinition { + return tool({ + description: BACKGROUND_OUTPUT_DESCRIPTION, + args: { + task_id: tool.schema.string().describe("Task ID to get output from"), + block: tool.schema + .boolean() + .optional() + .describe( + "Wait for completion (default: false). System notifies when done, so blocking is rarely needed." + ), + timeout: tool.schema.number().optional().describe("Max wait time in ms (default: 60000, max: 600000)"), + full_session: tool.schema.boolean().optional().describe("Return full session messages with filters (default: false)"), + include_thinking: tool.schema.boolean().optional().describe("Include thinking/reasoning parts in full_session output (default: false)"), + message_limit: tool.schema.number().optional().describe("Max messages to return (capped at 100)"), + since_message_id: tool.schema.string().optional().describe("Return messages after this message ID (exclusive)"), + include_tool_results: tool.schema.boolean().optional().describe("Include tool results in full_session output (default: false)"), + thinking_max_chars: tool.schema.number().optional().describe("Max characters for thinking content (default: 2000)"), + }, + async execute(args: BackgroundOutputArgs) { + try { + const task = manager.getTask(args.task_id) + if (!task) { + return `Task not found: ${args.task_id}` + } + + if (args.full_session === true) { + return await formatFullSession(task, client, { + includeThinking: args.include_thinking === true, + messageLimit: args.message_limit, + sinceMessageId: args.since_message_id, + includeToolResults: args.include_tool_results === true, + thinkingMaxChars: args.thinking_max_chars, + }) + } + + const shouldBlock = args.block === true + const timeoutMs = Math.min(args.timeout ?? 60000, 600000) + + if (task.status === "completed") { + return await formatTaskResult(task, client) + } + + if (task.status === "error" || task.status === "cancelled") { + return formatTaskStatus(task) + } + + if (!shouldBlock) { + return formatTaskStatus(task) + } + + const startTime = Date.now() + while (Date.now() - startTime < timeoutMs) { + await delay(1000) + + const currentTask = manager.getTask(args.task_id) + if (!currentTask) { + return `Task was deleted: ${args.task_id}` + } + + if (currentTask.status === "completed") { + return await formatTaskResult(currentTask, client) + } + + if (currentTask.status === "error" || currentTask.status === "cancelled") { + return formatTaskStatus(currentTask) + } + } + + const finalTask = manager.getTask(args.task_id) + if (!finalTask) { + return `Task was deleted: ${args.task_id}` + } + return `Timeout exceeded (${timeoutMs}ms). Task still ${finalTask.status}.\n\n${formatTaskStatus(finalTask)}` + } catch (error) { + return `Error getting output: ${error instanceof Error ? error.message : String(error)}` + } + }, + }) +} diff --git a/src/tools/background-task/create-background-task.ts b/src/tools/background-task/create-background-task.ts new file mode 100644 index 000000000..a3411dc41 --- /dev/null +++ b/src/tools/background-task/create-background-task.ts @@ -0,0 +1,116 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin" +import type { BackgroundManager } from "../../features/background-agent" +import type { BackgroundTaskArgs } from "./types" +import { BACKGROUND_TASK_DESCRIPTION } from "./constants" +import { findFirstMessageWithAgent, findNearestMessageWithFields } from "../../features/hook-message-injector" +import { getSessionAgent } from "../../features/claude-code-session-state" +import { storeToolMetadata } from "../../features/tool-metadata-store" +import { log } from "../../shared/logger" +import { delay } from "./delay" +import { getMessageDir } from "./message-dir" + +type ToolContextWithMetadata = { + sessionID: string + messageID: string + agent: string + abort: AbortSignal + metadata?: (input: { title?: string; metadata?: Record }) => void + callID?: string +} + +export function createBackgroundTask(manager: BackgroundManager): ToolDefinition { + return tool({ + description: BACKGROUND_TASK_DESCRIPTION, + args: { + description: tool.schema.string().describe("Short task description (shown in status)"), + prompt: tool.schema.string().describe("Full detailed prompt for the agent"), + agent: tool.schema.string().describe("Agent type to use (any registered agent)"), + }, + async execute(args: BackgroundTaskArgs, toolContext) { + const ctx = toolContext as ToolContextWithMetadata + + if (!args.agent || args.agent.trim() === "") { + return `[ERROR] Agent parameter is required. Please specify which agent to use (e.g., "explore", "librarian", "build", etc.)` + } + + try { + const messageDir = getMessageDir(ctx.sessionID) + const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null + const sessionAgent = getSessionAgent(ctx.sessionID) + const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent + + log("[background_task] parentAgent resolution", { + sessionID: ctx.sessionID, + ctxAgent: ctx.agent, + sessionAgent, + firstMessageAgent, + prevMessageAgent: prevMessage?.agent, + resolvedParentAgent: parentAgent, + }) + + const parentModel = + prevMessage?.model?.providerID && prevMessage?.model?.modelID + ? { + providerID: prevMessage.model.providerID, + modelID: prevMessage.model.modelID, + ...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {}), + } + : undefined + + const task = await manager.launch({ + description: args.description, + prompt: args.prompt, + agent: args.agent.trim(), + parentSessionID: ctx.sessionID, + parentMessageID: ctx.messageID, + parentModel, + parentAgent, + }) + + const WAIT_FOR_SESSION_INTERVAL_MS = 50 + const WAIT_FOR_SESSION_TIMEOUT_MS = 30000 + const waitStart = Date.now() + let sessionId = task.sessionID + while (!sessionId && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) { + if (ctx.abort?.aborted) { + await manager.cancelTask(task.id) + return `Task aborted and cancelled while waiting for session to start.\n\nTask ID: ${task.id}` + } + await delay(WAIT_FOR_SESSION_INTERVAL_MS) + const updated = manager.getTask(task.id) + if (!updated || updated.status === "error") { + return `Task ${!updated ? "was deleted" : `entered error state`}\.\n\nTask ID: ${task.id}` + } + sessionId = updated?.sessionID + } + + const bgMeta = { + title: args.description, + metadata: { sessionId: sessionId ?? "pending" }, + } + await ctx.metadata?.(bgMeta) + + if (ctx.callID) { + storeToolMetadata(ctx.sessionID, ctx.callID, bgMeta) + } + + return `Background task launched successfully. + +Task ID: ${task.id} +Session ID: ${sessionId ?? "pending"} +Description: ${task.description} +Agent: ${task.agent} +Status: ${task.status} + +The system will notify you when the task completes. +Use \`background_output\` tool with task_id="${task.id}" to check progress: +- block=false (default): Check status immediately - returns full status info +- block=true: Wait for completion (rarely needed since system notifies)` + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return `[ERROR] Failed to launch background task: ${message}` + } + }, + }) +} diff --git a/src/tools/background-task/delay.ts b/src/tools/background-task/delay.ts new file mode 100644 index 000000000..e0b2c7ebf --- /dev/null +++ b/src/tools/background-task/delay.ts @@ -0,0 +1,3 @@ +export function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/tools/background-task/full-session-format.ts b/src/tools/background-task/full-session-format.ts new file mode 100644 index 000000000..9b50a09fb --- /dev/null +++ b/src/tools/background-task/full-session-format.ts @@ -0,0 +1,148 @@ +import type { BackgroundTask } from "../../features/background-agent" +import type { BackgroundOutputClient, BackgroundOutputMessagesResult, BackgroundOutputMessage } from "./clients" +import { extractMessages, getErrorMessage } from "./session-messages" +import { formatMessageTime } from "./time-format" +import { truncateText } from "./truncate-text" +import { formatTaskStatus } from "./task-status-format" + +const MAX_MESSAGE_LIMIT = 100 +const THINKING_MAX_CHARS = 2000 + +function extractToolResultText(part: NonNullable[number]): string[] { + if (typeof part.content === "string" && part.content.length > 0) { + return [part.content] + } + + if (Array.isArray(part.content)) { + const blocks: string[] = [] + for (const block of part.content) { + if ((block.type === "text" || block.type === "reasoning") && block.text) { + blocks.push(block.text) + } + } + if (blocks.length > 0) return blocks + } + + if (part.output && part.output.length > 0) { + return [part.output] + } + + return [] +} + +export async function formatFullSession( + task: BackgroundTask, + client: BackgroundOutputClient, + options: { + includeThinking: boolean + messageLimit?: number + sinceMessageId?: string + includeToolResults: boolean + thinkingMaxChars?: number + } +): Promise { + if (!task.sessionID) { + return formatTaskStatus(task) + } + + const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({ + path: { id: task.sessionID }, + }) + + const errorMessage = getErrorMessage(messagesResult) + if (errorMessage) { + return `Error fetching messages: ${errorMessage}` + } + + const rawMessages = extractMessages(messagesResult) + if (!Array.isArray(rawMessages)) { + return "Error fetching messages: invalid response" + } + + const sortedMessages = [...rawMessages].sort((a, b) => { + const timeA = String(a.info?.time ?? "") + const timeB = String(b.info?.time ?? "") + return timeA.localeCompare(timeB) + }) + + let filteredMessages = sortedMessages + if (options.sinceMessageId) { + const index = filteredMessages.findIndex((message) => message.id === options.sinceMessageId) + if (index === -1) { + return `Error: since_message_id not found: ${options.sinceMessageId}` + } + filteredMessages = filteredMessages.slice(index + 1) + } + + const includeThinking = options.includeThinking + const includeToolResults = options.includeToolResults + const thinkingMaxChars = options.thinkingMaxChars ?? THINKING_MAX_CHARS + + const normalizedMessages: BackgroundOutputMessage[] = [] + for (const message of filteredMessages) { + const parts = (message.parts ?? []).filter((part) => { + if (part.type === "thinking" || part.type === "reasoning") { + return includeThinking + } + if (part.type === "tool_result") { + return includeToolResults + } + return part.type === "text" + }) + + if (parts.length === 0) { + continue + } + + normalizedMessages.push({ ...message, parts }) + } + + const limit = typeof options.messageLimit === "number" ? Math.min(options.messageLimit, MAX_MESSAGE_LIMIT) : undefined + const hasMore = limit !== undefined && normalizedMessages.length > limit + const visibleMessages = limit !== undefined ? normalizedMessages.slice(0, limit) : normalizedMessages + + const lines: string[] = [] + lines.push("# Full Session Output") + lines.push("") + lines.push(`Task ID: ${task.id}`) + lines.push(`Description: ${task.description}`) + lines.push(`Status: ${task.status}`) + lines.push(`Session ID: ${task.sessionID}`) + lines.push(`Total messages: ${normalizedMessages.length}`) + lines.push(`Returned: ${visibleMessages.length}`) + lines.push(`Has more: ${hasMore ? "true" : "false"}`) + lines.push("") + lines.push("## Messages") + + if (visibleMessages.length === 0) { + lines.push("") + lines.push("(No messages found)") + return lines.join("\n") + } + + for (const message of visibleMessages) { + const role = message.info?.role ?? "unknown" + const agent = message.info?.agent ? ` (${message.info.agent})` : "" + const time = formatMessageTime(message.info?.time) + const idLabel = message.id ? ` id=${message.id}` : "" + lines.push("") + lines.push(`[${role}${agent}] ${time}${idLabel}`) + + for (const part of message.parts ?? []) { + if (part.type === "text" && part.text) { + lines.push(part.text.trim()) + } else if (part.type === "thinking" && part.thinking) { + lines.push(`[thinking] ${truncateText(part.thinking, thinkingMaxChars)}`) + } else if (part.type === "reasoning" && part.text) { + lines.push(`[thinking] ${truncateText(part.text, thinkingMaxChars)}`) + } else if (part.type === "tool_result") { + const toolTexts = extractToolResultText(part) + for (const toolText of toolTexts) { + lines.push(`[tool result] ${toolText}`) + } + } + } + } + + return lines.join("\n") +} diff --git a/src/tools/background-task/message-dir.ts b/src/tools/background-task/message-dir.ts new file mode 100644 index 000000000..74c496073 --- /dev/null +++ b/src/tools/background-task/message-dir.ts @@ -0,0 +1,17 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { MESSAGE_STORAGE } from "../../features/hook-message-injector" + +export function getMessageDir(sessionID: string): string | null { + if (!existsSync(MESSAGE_STORAGE)) return null + + const directPath = join(MESSAGE_STORAGE, sessionID) + if (existsSync(directPath)) return directPath + + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) + if (existsSync(sessionPath)) return sessionPath + } + + return null +} diff --git a/src/tools/background-task/session-messages.ts b/src/tools/background-task/session-messages.ts new file mode 100644 index 000000000..783b504a7 --- /dev/null +++ b/src/tools/background-task/session-messages.ts @@ -0,0 +1,22 @@ +import type { BackgroundOutputMessage, BackgroundOutputMessagesResult } from "./clients" + +export function getErrorMessage(value: BackgroundOutputMessagesResult): string | null { + if (Array.isArray(value)) return null + if (value.error === undefined || value.error === null) return null + if (typeof value.error === "string" && value.error.length > 0) return value.error + return String(value.error) +} + +function isSessionMessage(value: unknown): value is BackgroundOutputMessage { + return typeof value === "object" && value !== null +} + +export function extractMessages(value: BackgroundOutputMessagesResult): BackgroundOutputMessage[] { + if (Array.isArray(value)) { + return value.filter(isSessionMessage) + } + if (Array.isArray(value.data)) { + return value.data.filter(isSessionMessage) + } + return [] +} diff --git a/src/tools/background-task/task-result-format.ts b/src/tools/background-task/task-result-format.ts new file mode 100644 index 000000000..564eb31fe --- /dev/null +++ b/src/tools/background-task/task-result-format.ts @@ -0,0 +1,113 @@ +import type { BackgroundTask } from "../../features/background-agent" +import { consumeNewMessages } from "../../shared/session-cursor" +import type { BackgroundOutputClient, BackgroundOutputMessagesResult } from "./clients" +import { extractMessages, getErrorMessage } from "./session-messages" +import { formatDuration } from "./time-format" + +function getTimeString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +export async function formatTaskResult(task: BackgroundTask, client: BackgroundOutputClient): Promise { + if (!task.sessionID) { + return `Error: Task has no sessionID` + } + + const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({ + path: { id: task.sessionID }, + }) + + const errorMessage = getErrorMessage(messagesResult) + if (errorMessage) { + return `Error fetching messages: ${errorMessage}` + } + + const messages = extractMessages(messagesResult) + if (!Array.isArray(messages) || messages.length === 0) { + return `Task Result + +Task ID: ${task.id} +Description: ${task.description} +Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)} +Session ID: ${task.sessionID} + +--- + +(No messages found)` + } + + const relevantMessages = messages.filter((m) => m.info?.role === "assistant" || m.info?.role === "tool") + if (relevantMessages.length === 0) { + return `Task Result + +Task ID: ${task.id} +Description: ${task.description} +Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)} +Session ID: ${task.sessionID} + +--- + +(No assistant or tool response found)` + } + + const sortedMessages = [...relevantMessages].sort((a, b) => { + const timeA = getTimeString(a.info?.time) + const timeB = getTimeString(b.info?.time) + return timeA.localeCompare(timeB) + }) + + const newMessages = consumeNewMessages(task.sessionID, sortedMessages) + if (newMessages.length === 0) { + const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) + return `Task Result + +Task ID: ${task.id} +Description: ${task.description} +Duration: ${duration} +Session ID: ${task.sessionID} + +--- + +(No new output since last check)` + } + + const extractedContent: string[] = [] + for (const message of newMessages) { + for (const part of message.parts ?? []) { + if ((part.type === "text" || part.type === "reasoning") && part.text) { + extractedContent.push(part.text) + continue + } + + if (part.type === "tool_result") { + const toolResult = part as { content?: string | Array<{ type: string; text?: string }> } + if (typeof toolResult.content === "string" && toolResult.content) { + extractedContent.push(toolResult.content) + continue + } + + if (Array.isArray(toolResult.content)) { + for (const block of toolResult.content) { + if ((block.type === "text" || block.type === "reasoning") && block.text) { + extractedContent.push(block.text) + } + } + } + } + } + } + + const textContent = extractedContent.filter((text) => text.length > 0).join("\n\n") + const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) + + return `Task Result + +Task ID: ${task.id} +Description: ${task.description} +Duration: ${duration} +Session ID: ${task.sessionID} + +--- + +${textContent || "(No text output)"}` +} diff --git a/src/tools/background-task/task-status-format.ts b/src/tools/background-task/task-status-format.ts new file mode 100644 index 000000000..8dd89132a --- /dev/null +++ b/src/tools/background-task/task-status-format.ts @@ -0,0 +1,68 @@ +import type { BackgroundTask } from "../../features/background-agent" +import { formatDuration } from "./time-format" +import { truncateText } from "./truncate-text" + +export function formatTaskStatus(task: BackgroundTask): string { + let duration: string + if (task.status === "pending" && task.queuedAt) { + duration = formatDuration(task.queuedAt, undefined) + } else if (task.startedAt) { + duration = formatDuration(task.startedAt, task.completedAt) + } else { + duration = "N/A" + } + + const promptPreview = truncateText(task.prompt, 500) + + let progressSection = "" + if (task.progress?.lastTool) { + progressSection = `\n| Last tool | ${task.progress.lastTool} |` + } + + let lastMessageSection = "" + if (task.progress?.lastMessage) { + const truncated = truncateText(task.progress.lastMessage, 500) + const messageTime = task.progress.lastMessageAt ? task.progress.lastMessageAt.toISOString() : "N/A" + lastMessageSection = ` + +## Last Message (${messageTime}) + +\`\`\` +${truncated} +\`\`\`` + } + + let statusNote = "" + if (task.status === "pending") { + statusNote = ` + +> **Queued**: Task is waiting for a concurrency slot to become available.` + } else if (task.status === "running") { + statusNote = ` + +> **Note**: No need to wait explicitly - the system will notify you when this task completes.` + } else if (task.status === "error") { + statusNote = ` + +> **Failed**: The task encountered an error. Check the last message for details.` + } + + const durationLabel = task.status === "pending" ? "Queued for" : "Duration" + + return `# Task Status + +| Field | Value | +|-------|-------| +| Task ID | \`${task.id}\` | +| Description | ${task.description} | +| Agent | ${task.agent} | +| Status | **${task.status}** | +| ${durationLabel} | ${duration} | +| Session ID | \`${task.sessionID}\` |${progressSection} +${statusNote} +## Original Prompt + +\`\`\` +${promptPreview} +\`\`\`${lastMessageSection}` +} diff --git a/src/tools/background-task/time-format.ts b/src/tools/background-task/time-format.ts new file mode 100644 index 000000000..a68bc9b0a --- /dev/null +++ b/src/tools/background-task/time-format.ts @@ -0,0 +1,30 @@ +export function formatDuration(start: Date, end?: Date): string { + const duration = (end ?? new Date()).getTime() - start.getTime() + const seconds = Math.floor(duration / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + + if (hours > 0) { + return `${hours}h ${minutes % 60}m ${seconds % 60}s` + } + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s` + } + return `${seconds}s` +} + +export function formatMessageTime(value: unknown): string { + if (typeof value === "string") { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? value : date.toISOString() + } + if (typeof value === "object" && value !== null) { + if ("created" in value) { + const created = (value as { created?: number }).created + if (typeof created === "number") { + return new Date(created).toISOString() + } + } + } + return "Unknown time" +} diff --git a/src/tools/background-task/tools.ts b/src/tools/background-task/tools.ts index ec12128cb..ce30adb91 100644 --- a/src/tools/background-task/tools.ts +++ b/src/tools/background-task/tools.ts @@ -1,757 +1,11 @@ -import { tool, type ToolDefinition } from "@opencode-ai/plugin" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" -import type { BackgroundManager, BackgroundTask } from "../../features/background-agent" -import type { BackgroundTaskArgs, BackgroundOutputArgs, BackgroundCancelArgs } from "./types" -import { BACKGROUND_TASK_DESCRIPTION, BACKGROUND_OUTPUT_DESCRIPTION, BACKGROUND_CANCEL_DESCRIPTION } from "./constants" -import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector" -import { getSessionAgent } from "../../features/claude-code-session-state" -import { log } from "../../shared/logger" -import { consumeNewMessages } from "../../shared/session-cursor" -import { storeToolMetadata } from "../../features/tool-metadata-store" - -type BackgroundOutputMessage = { - info?: { role?: string; time?: string | { created?: number }; agent?: string } - parts?: Array<{ - type?: string - text?: string - content?: string | Array<{ type: string; text?: string }> - name?: string - }> -} - -type BackgroundOutputMessagesResult = - | { data?: BackgroundOutputMessage[]; error?: unknown } - | BackgroundOutputMessage[] - -export type BackgroundOutputClient = { - session: { - messages: (args: { path: { id: string } }) => Promise - } -} - -export type BackgroundCancelClient = { - session: { - abort: (args: { path: { id: string } }) => Promise - } -} - -export type BackgroundOutputManager = Pick - -const MAX_MESSAGE_LIMIT = 100 -const THINKING_MAX_CHARS = 2000 - -type FullSessionMessagePart = { - type?: string - text?: string - thinking?: string - content?: string | Array<{ type?: string; text?: string }> - output?: string -} - -type FullSessionMessage = { - id?: string - info?: { role?: string; time?: string; agent?: string } - parts?: FullSessionMessagePart[] -} - -function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null - - const directPath = join(MESSAGE_STORAGE, sessionID) - if (existsSync(directPath)) return directPath - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) - if (existsSync(sessionPath)) return sessionPath - } - - return null -} - -function formatDuration(start: Date, end?: Date): string { - const duration = (end ?? new Date()).getTime() - start.getTime() - const seconds = Math.floor(duration / 1000) - const minutes = Math.floor(seconds / 60) - const hours = Math.floor(minutes / 60) - - if (hours > 0) { - return `${hours}h ${minutes % 60}m ${seconds % 60}s` - } else if (minutes > 0) { - return `${minutes}m ${seconds % 60}s` - } else { - return `${seconds}s` - } -} - -type ToolContextWithMetadata = { - sessionID: string - messageID: string - agent: string - abort: AbortSignal - metadata?: (input: { title?: string; metadata?: Record }) => void -} - -export function createBackgroundTask(manager: BackgroundManager): ToolDefinition { - return tool({ - description: BACKGROUND_TASK_DESCRIPTION, - args: { - description: tool.schema.string().describe("Short task description (shown in status)"), - prompt: tool.schema.string().describe("Full detailed prompt for the agent"), - agent: tool.schema.string().describe("Agent type to use (any registered agent)"), - }, - async execute(args: BackgroundTaskArgs, toolContext) { - const ctx = toolContext as ToolContextWithMetadata - - if (!args.agent || args.agent.trim() === "") { - return `[ERROR] Agent parameter is required. Please specify which agent to use (e.g., "explore", "librarian", "build", etc.)` - } - - try { - const messageDir = getMessageDir(ctx.sessionID) - const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null - const sessionAgent = getSessionAgent(ctx.sessionID) - const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent - - log("[background_task] parentAgent resolution", { - sessionID: ctx.sessionID, - ctxAgent: ctx.agent, - sessionAgent, - firstMessageAgent, - prevMessageAgent: prevMessage?.agent, - resolvedParentAgent: parentAgent, - }) - - const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID - ? { - providerID: prevMessage.model.providerID, - modelID: prevMessage.model.modelID, - ...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {}) - } - : undefined - - const task = await manager.launch({ - description: args.description, - prompt: args.prompt, - agent: args.agent.trim(), - parentSessionID: ctx.sessionID, - parentMessageID: ctx.messageID, - parentModel, - parentAgent, - }) - - const WAIT_FOR_SESSION_INTERVAL_MS = 50 - const WAIT_FOR_SESSION_TIMEOUT_MS = 30000 - const waitStart = Date.now() - let sessionId = task.sessionID - while (!sessionId && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) { - if (ctx.abort?.aborted) { - await manager.cancelTask(task.id) - return `Task aborted and cancelled while waiting for session to start.\n\nTask ID: ${task.id}` - } - await delay(WAIT_FOR_SESSION_INTERVAL_MS) - const updated = manager.getTask(task.id) - if (!updated || updated.status === "error") { - return `Task ${!updated ? "was deleted" : `entered error state`}.\n\nTask ID: ${task.id}` - } - sessionId = updated?.sessionID - } - - const bgMeta = { - title: args.description, - metadata: { sessionId: sessionId ?? "pending" } as Record, - } - await ctx.metadata?.(bgMeta) - const callID = (ctx as any).callID as string | undefined - if (callID) { - storeToolMetadata(ctx.sessionID, callID, bgMeta) - } - - return `Background task launched successfully. - -Task ID: ${task.id} -Session ID: ${sessionId ?? "pending"} -Description: ${task.description} -Agent: ${task.agent} -Status: ${task.status} - -The system will notify you when the task completes. -Use \`background_output\` tool with task_id="${task.id}" to check progress: -- block=false (default): Check status immediately - returns full status info -- block=true: Wait for completion (rarely needed since system notifies)` - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return `[ERROR] Failed to launch background task: ${message}` - } - }, - }) -} - -function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) -} - -function truncateText(text: string, maxLength: number): string { - if (text.length <= maxLength) return text - return text.slice(0, maxLength) + "..." -} - -function formatTaskStatus(task: BackgroundTask): string { - let duration: string - if (task.status === "pending" && task.queuedAt) { - duration = formatDuration(task.queuedAt, undefined) - } else if (task.startedAt) { - duration = formatDuration(task.startedAt, task.completedAt) - } else { - duration = "N/A" - } - const promptPreview = truncateText(task.prompt, 500) - - let progressSection = "" - if (task.progress?.lastTool) { - progressSection = `\n| Last tool | ${task.progress.lastTool} |` - } - - let lastMessageSection = "" - if (task.progress?.lastMessage) { - const truncated = truncateText(task.progress.lastMessage, 500) - const messageTime = task.progress.lastMessageAt - ? task.progress.lastMessageAt.toISOString() - : "N/A" - lastMessageSection = ` - -## Last Message (${messageTime}) - -\`\`\` -${truncated} -\`\`\`` - } - - let statusNote = "" - if (task.status === "pending") { - statusNote = ` - -> **Queued**: Task is waiting for a concurrency slot to become available.` - } else if (task.status === "running") { - statusNote = ` - -> **Note**: No need to wait explicitly - the system will notify you when this task completes.` - } else if (task.status === "error") { - statusNote = ` - -> **Failed**: The task encountered an error. Check the last message for details.` - } - - const durationLabel = task.status === "pending" ? "Queued for" : "Duration" - - return `# Task Status - -| Field | Value | -|-------|-------| -| Task ID | \`${task.id}\` | -| Description | ${task.description} | -| Agent | ${task.agent} | -| Status | **${task.status}** | -| ${durationLabel} | ${duration} | -| Session ID | \`${task.sessionID}\` |${progressSection} -${statusNote} -## Original Prompt - -\`\`\` -${promptPreview} -\`\`\`${lastMessageSection}` -} - -function getErrorMessage(value: BackgroundOutputMessagesResult): string | null { - if (Array.isArray(value)) return null - if (value.error === undefined || value.error === null) return null - if (typeof value.error === "string" && value.error.length > 0) return value.error - return String(value.error) -} - -function isSessionMessage(value: unknown): value is { - info?: { role?: string; time?: string } - parts?: Array<{ - type?: string - text?: string - content?: string | Array<{ type: string; text?: string }> - name?: string - }> -} { - return typeof value === "object" && value !== null -} - -function extractMessages(value: BackgroundOutputMessagesResult): BackgroundOutputMessage[] { - if (Array.isArray(value)) { - return value.filter(isSessionMessage) - } - if (Array.isArray(value.data)) { - return value.data.filter(isSessionMessage) - } - return [] -} - -async function formatTaskResult(task: BackgroundTask, client: BackgroundOutputClient): Promise { - if (!task.sessionID) { - return `Error: Task has no sessionID` - } - - const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({ - path: { id: task.sessionID }, - }) - - const errorMessage = getErrorMessage(messagesResult) - if (errorMessage) { - return `Error fetching messages: ${errorMessage}` - } - - const messages = extractMessages(messagesResult) - - if (!Array.isArray(messages) || messages.length === 0) { - return `Task Result - -Task ID: ${task.id} -Description: ${task.description} -Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)} -Session ID: ${task.sessionID} - ---- - -(No messages found)` - } - - // Include both assistant messages AND tool messages - // Tool results (grep, glob, bash output) come from role "tool" - const relevantMessages = messages.filter( - (m) => m.info?.role === "assistant" || m.info?.role === "tool" - ) - - if (relevantMessages.length === 0) { - return `Task Result - -Task ID: ${task.id} -Description: ${task.description} -Duration: ${formatDuration(task.startedAt ?? new Date(), task.completedAt)} -Session ID: ${task.sessionID} - ---- - -(No assistant or tool response found)` - } - - // Sort by time ascending (oldest first) to process messages in order - const sortedMessages = [...relevantMessages].sort((a, b) => { - const timeA = String((a as { info?: { time?: string } }).info?.time ?? "") - const timeB = String((b as { info?: { time?: string } }).info?.time ?? "") - return timeA.localeCompare(timeB) - }) - - const newMessages = consumeNewMessages(task.sessionID, sortedMessages) - if (newMessages.length === 0) { - const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) - return `Task Result - -Task ID: ${task.id} -Description: ${task.description} -Duration: ${duration} -Session ID: ${task.sessionID} - ---- - -(No new output since last check)` - } - - // Extract content from ALL messages, not just the last one - // Tool results may be in earlier messages while the final message is empty - const extractedContent: string[] = [] - - for (const message of newMessages) { - for (const part of message.parts ?? []) { - // Handle both "text" and "reasoning" parts (thinking models use "reasoning") - if ((part.type === "text" || part.type === "reasoning") && part.text) { - extractedContent.push(part.text) - } else if (part.type === "tool_result") { - // Tool results contain the actual output from tool calls - const toolResult = part as { content?: string | Array<{ type: string; text?: string }> } - if (typeof toolResult.content === "string" && toolResult.content) { - extractedContent.push(toolResult.content) - } else if (Array.isArray(toolResult.content)) { - // Handle array of content blocks - for (const block of toolResult.content) { - // Handle both "text" and "reasoning" parts (thinking models use "reasoning") - if ((block.type === "text" || block.type === "reasoning") && block.text) { - extractedContent.push(block.text) - } - } - } - } - } - } - - const textContent = extractedContent - .filter((text) => text.length > 0) - .join("\n\n") - - const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt) - - return `Task Result - -Task ID: ${task.id} -Description: ${task.description} -Duration: ${duration} -Session ID: ${task.sessionID} - ---- - -${textContent || "(No text output)"}` -} - -function extractToolResultText(part: FullSessionMessagePart): string[] { - if (typeof part.content === "string" && part.content.length > 0) { - return [part.content] - } - - if (Array.isArray(part.content)) { - const blocks = part.content - .filter((block) => (block.type === "text" || block.type === "reasoning") && block.text) - .map((block) => block.text as string) - if (blocks.length > 0) return blocks - } - - if (part.output && part.output.length > 0) { - return [part.output] - } - - return [] -} - -async function formatFullSession( - task: BackgroundTask, - client: BackgroundOutputClient, - options: { - includeThinking: boolean - messageLimit?: number - sinceMessageId?: string - includeToolResults: boolean - thinkingMaxChars?: number - } -): Promise { - if (!task.sessionID) { - return formatTaskStatus(task) - } - - const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({ - path: { id: task.sessionID }, - }) - - const errorMessage = getErrorMessage(messagesResult) - if (errorMessage) { - return `Error fetching messages: ${errorMessage}` - } - - const rawMessages = extractMessages(messagesResult) - if (!Array.isArray(rawMessages)) { - return "Error fetching messages: invalid response" - } - - const sortedMessages = [...(rawMessages as FullSessionMessage[])].sort((a, b) => { - const timeA = String(a.info?.time ?? "") - const timeB = String(b.info?.time ?? "") - return timeA.localeCompare(timeB) - }) - - let filteredMessages = sortedMessages - - if (options.sinceMessageId) { - const index = filteredMessages.findIndex((message) => message.id === options.sinceMessageId) - if (index === -1) { - return `Error: since_message_id not found: ${options.sinceMessageId}` - } - filteredMessages = filteredMessages.slice(index + 1) - } - - const includeThinking = options.includeThinking - const includeToolResults = options.includeToolResults - const thinkingMaxChars = options.thinkingMaxChars ?? THINKING_MAX_CHARS - - const normalizedMessages: FullSessionMessage[] = [] - for (const message of filteredMessages) { - const parts = (message.parts ?? []).filter((part) => { - if (part.type === "thinking" || part.type === "reasoning") { - return includeThinking - } - if (part.type === "tool_result") { - return includeToolResults - } - return part.type === "text" - }) - - if (parts.length === 0) { - continue - } - - normalizedMessages.push({ ...message, parts }) - } - - const limit = typeof options.messageLimit === "number" - ? Math.min(options.messageLimit, MAX_MESSAGE_LIMIT) - : undefined - const hasMore = limit !== undefined && normalizedMessages.length > limit - const visibleMessages = limit !== undefined - ? normalizedMessages.slice(0, limit) - : normalizedMessages - - const lines: string[] = [] - lines.push("# Full Session Output") - lines.push("") - lines.push(`Task ID: ${task.id}`) - lines.push(`Description: ${task.description}`) - lines.push(`Status: ${task.status}`) - lines.push(`Session ID: ${task.sessionID}`) - lines.push(`Total messages: ${normalizedMessages.length}`) - lines.push(`Returned: ${visibleMessages.length}`) - lines.push(`Has more: ${hasMore ? "true" : "false"}`) - lines.push("") - lines.push("## Messages") - - if (visibleMessages.length === 0) { - lines.push("") - lines.push("(No messages found)") - return lines.join("\n") - } - - for (const message of visibleMessages) { - const role = message.info?.role ?? "unknown" - const agent = message.info?.agent ? ` (${message.info.agent})` : "" - const time = formatMessageTime(message.info?.time) - const idLabel = message.id ? ` id=${message.id}` : "" - lines.push("") - lines.push(`[${role}${agent}] ${time}${idLabel}`) - - for (const part of message.parts ?? []) { - if (part.type === "text" && part.text) { - lines.push(part.text.trim()) - } else if (part.type === "thinking" && part.thinking) { - lines.push(`[thinking] ${truncateText(part.thinking, thinkingMaxChars)}`) - } else if (part.type === "reasoning" && part.text) { - lines.push(`[thinking] ${truncateText(part.text, thinkingMaxChars)}`) - } else if (part.type === "tool_result") { - const toolTexts = extractToolResultText(part) - for (const toolText of toolTexts) { - lines.push(`[tool result] ${toolText}`) - } - } - } - } - - return lines.join("\n") -} - -export function createBackgroundOutput(manager: BackgroundOutputManager, client: BackgroundOutputClient): ToolDefinition { - return tool({ - description: BACKGROUND_OUTPUT_DESCRIPTION, - args: { - task_id: tool.schema.string().describe("Task ID to get output from"), - block: tool.schema.boolean().optional().describe("Wait for completion (default: false). System notifies when done, so blocking is rarely needed."), - timeout: tool.schema.number().optional().describe("Max wait time in ms (default: 60000, max: 600000)"), - full_session: tool.schema.boolean().optional().describe("Return full session messages with filters (default: false)"), - include_thinking: tool.schema.boolean().optional().describe("Include thinking/reasoning parts in full_session output (default: false)"), - message_limit: tool.schema.number().optional().describe("Max messages to return (capped at 100)"), - since_message_id: tool.schema.string().optional().describe("Return messages after this message ID (exclusive)"), - include_tool_results: tool.schema.boolean().optional().describe("Include tool results in full_session output (default: false)"), - thinking_max_chars: tool.schema.number().optional().describe("Max characters for thinking content (default: 2000)"), - }, - async execute(args: BackgroundOutputArgs) { - try { - const task = manager.getTask(args.task_id) - if (!task) { - return `Task not found: ${args.task_id}` - } - - if (args.full_session === true) { - return await formatFullSession(task, client, { - includeThinking: args.include_thinking === true, - messageLimit: args.message_limit, - sinceMessageId: args.since_message_id, - includeToolResults: args.include_tool_results === true, - thinkingMaxChars: args.thinking_max_chars, - }) - } - - const shouldBlock = args.block === true - const timeoutMs = Math.min(args.timeout ?? 60000, 600000) - - // Already completed: return result immediately (regardless of block flag) - if (task.status === "completed") { - return await formatTaskResult(task, client) - } - - // Error or cancelled: return status immediately - if (task.status === "error" || task.status === "cancelled") { - return formatTaskStatus(task) - } - - // Non-blocking and still running: return status - if (!shouldBlock) { - return formatTaskStatus(task) - } - - // Blocking: poll until completion or timeout - const startTime = Date.now() - - while (Date.now() - startTime < timeoutMs) { - await delay(1000) - - const currentTask = manager.getTask(args.task_id) - if (!currentTask) { - return `Task was deleted: ${args.task_id}` - } - - if (currentTask.status === "completed") { - return await formatTaskResult(currentTask, client) - } - - if (currentTask.status === "error" || currentTask.status === "cancelled") { - return formatTaskStatus(currentTask) - } - } - - // Timeout exceeded: return current status - const finalTask = manager.getTask(args.task_id) - if (!finalTask) { - return `Task was deleted: ${args.task_id}` - } - return `Timeout exceeded (${timeoutMs}ms). Task still ${finalTask.status}.\n\n${formatTaskStatus(finalTask)}` - } catch (error) { - return `Error getting output: ${error instanceof Error ? error.message : String(error)}` - } - }, - }) -} - -export function createBackgroundCancel(manager: BackgroundManager, client: BackgroundCancelClient): ToolDefinition { - return tool({ - description: BACKGROUND_CANCEL_DESCRIPTION, - args: { - taskId: tool.schema.string().optional().describe("Task ID to cancel (required if all=false)"), - all: tool.schema.boolean().optional().describe("Cancel all running background tasks (default: false)"), - }, - async execute(args: BackgroundCancelArgs, toolContext) { - try { - const cancelAll = args.all === true - - if (!cancelAll && !args.taskId) { - return `[ERROR] Invalid arguments: Either provide a taskId or set all=true to cancel all running tasks.` - } - - if (cancelAll) { - const tasks = manager.getAllDescendantTasks(toolContext.sessionID) - const cancellableTasks = tasks.filter(t => t.status === "running" || t.status === "pending") - - if (cancellableTasks.length === 0) { - return `No running or pending background tasks to cancel.` - } - - const cancelledInfo: Array<{ - id: string - description: string - status: string - sessionID?: string - }> = [] - - for (const task of cancellableTasks) { - const originalStatus = task.status - const cancelled = await manager.cancelTask(task.id, { - source: "background_cancel", - abortSession: originalStatus === "running", - skipNotification: true, - }) - if (!cancelled) continue - cancelledInfo.push({ - id: task.id, - description: task.description, - status: originalStatus === "pending" ? "pending" : "running", - sessionID: task.sessionID, - }) - } - - const tableRows = cancelledInfo - .map(t => `| \`${t.id}\` | ${t.description} | ${t.status} | ${t.sessionID ? `\`${t.sessionID}\`` : "(not started)"} |`) - .join("\n") - - const resumableTasks = cancelledInfo.filter(t => t.sessionID) - const resumeSection = resumableTasks.length > 0 - ? `\n## Continue Instructions - -To continue a cancelled task, use: -\`\`\` -task(session_id="", prompt="Continue: ") -\`\`\` - -Continuable sessions: -${resumableTasks.map(t => `- \`${t.sessionID}\` (${t.description})`).join("\n")}` - : "" - - return `Cancelled ${cancelledInfo.length} background task(s): - -| Task ID | Description | Status | Session ID | -|---------|-------------|--------|------------| -${tableRows} -${resumeSection}` - } - - const task = manager.getTask(args.taskId!) - if (!task) { - return `[ERROR] Task not found: ${args.taskId}` - } - - if (task.status !== "running" && task.status !== "pending") { - return `[ERROR] Cannot cancel task: current status is "${task.status}". -Only running or pending tasks can be cancelled.` - } - - const cancelled = await manager.cancelTask(task.id, { - source: "background_cancel", - abortSession: task.status === "running", - skipNotification: true, - }) - if (!cancelled) { - return `[ERROR] Failed to cancel task: ${task.id}` - } - - if (task.status === "pending") { - return `Pending task cancelled successfully - -Task ID: ${task.id} -Description: ${task.description} -Status: ${task.status}` - } - - return `Task cancelled successfully - -Task ID: ${task.id} -Description: ${task.description} -Session ID: ${task.sessionID} -Status: ${task.status}` - } catch (error) { - return `[ERROR] Error cancelling task: ${error instanceof Error ? error.message : String(error)}` - } - }, - }) -} -function formatMessageTime(value: unknown): string { - if (typeof value === "string") { - const date = new Date(value) - return Number.isNaN(date.getTime()) ? value : date.toISOString() - } - if (typeof value === "object" && value !== null) { - if ("created" in value) { - const created = (value as { created?: number }).created - if (typeof created === "number") { - return new Date(created).toISOString() - } - } - } - return "Unknown time" -} +export type { + BackgroundCancelClient, + BackgroundOutputClient, + BackgroundOutputManager, + BackgroundOutputMessage, + BackgroundOutputMessagesResult, +} from "./clients" + +export { createBackgroundTask } from "./create-background-task" +export { createBackgroundOutput } from "./create-background-output" +export { createBackgroundCancel } from "./create-background-cancel" diff --git a/src/tools/background-task/truncate-text.ts b/src/tools/background-task/truncate-text.ts new file mode 100644 index 000000000..42fd3c3a0 --- /dev/null +++ b/src/tools/background-task/truncate-text.ts @@ -0,0 +1,4 @@ +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text + return text.slice(0, maxLength) + "..." +}