refactor: wave 2 - split atlas, auto-update-checker, session-recovery, todo-enforcer, background-task hooks
- Extract atlas/ into 15 focused modules (hook, event handler, tool policies, types, etc.) - Split auto-update-checker into checker/ and hook/ subdirectories with single-purpose files - Decompose session-recovery into separate recovery strategy files per error type - Extract todo-continuation-enforcer from monolith to directory with dedicated modules - Split background-task/tools.ts into individual tool creator files - Extract command-executor, tmux-utils into focused sub-modules - Split config/schema.ts into domain-specific schema files - Decompose cli/config-manager.ts into focused modules - Rollback skill-mcp-manager, model-availability, index.ts splits that broke tests - Fix all import path depths for moved files (../../ -> ../../../) - Add explicit type annotations to resolve TS7006 implicit any errors Typecheck: 0 errors Tests: 2359 pass, 5 fail (all pre-existing)
This commit is contained in:
@@ -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"
|
||||
|
||||
29
src/agents/prometheus/system-prompt.ts
Normal file
29
src/agents/prometheus/system-prompt.ts
Normal file
@@ -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,
|
||||
}
|
||||
191
src/cli/cli-program.ts
Normal file
191
src/cli/cli-program.ts
Normal file
@@ -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 <value>", "Claude subscription: no, yes, max20")
|
||||
.option("--openai <value>", "OpenAI/ChatGPT subscription: no, yes (default: no)")
|
||||
.option("--gemini <value>", "Gemini integration: no, yes")
|
||||
.option("--copilot <value>", "GitHub Copilot subscription: no, yes")
|
||||
.option("--opencode-zen <value>", "OpenCode Zen access: no, yes (default: no)")
|
||||
.option("--zai-coding-plan <value>", "Z.ai Coding Plan subscription: no, yes (default: no)")
|
||||
.option("--kimi-for-coding <value>", "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 <message>")
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.description("Run opencode with todo/background task completion enforcement")
|
||||
.option("-a, --agent <name>", "Agent to use (default: from CLI/env/config, fallback: Sisyphus)")
|
||||
.option("-d, --directory <path>", "Working directory")
|
||||
.option("-t, --timeout <ms>", "Timeout in milliseconds (default: 30 minutes)", parseInt)
|
||||
.option("-p, --port <port>", "Server port (attaches if port already in use)", parseInt)
|
||||
.option("--attach <url>", "Attach to existing opencode server URL")
|
||||
.option("--on-complete <command>", "Shell command to run after completion")
|
||||
.option("--json", "Output structured JSON result to stdout")
|
||||
.option("--session-id <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 <path>", "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 <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()
|
||||
}
|
||||
@@ -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<string | null> {
|
||||
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<NpmDistTags | null> {
|
||||
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<string> {
|
||||
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<OpenCodeConfig>(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<ConfigMergeResult> {
|
||||
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<T extends Record<string, unknown>>(target: T, source: Partial<T>): T {
|
||||
const result = { ...target }
|
||||
|
||||
for (const key of Object.keys(source) as Array<keyof T>) {
|
||||
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<string, unknown>,
|
||||
sourceValue as Record<string, unknown>
|
||||
) as T[keyof T]
|
||||
} else if (sourceValue !== undefined) {
|
||||
result[key] = sourceValue as T[keyof T]
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function generateOmoConfig(installConfig: InstallConfig): Record<string, unknown> {
|
||||
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<Record<string, unknown>>(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<OpenCodeBinaryResult | null> {
|
||||
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<boolean> {
|
||||
const result = await findOpenCodeBinaryWithVersion()
|
||||
return result !== null
|
||||
}
|
||||
|
||||
export async function getOpenCodeVersion(): Promise<string | null> {
|
||||
const result = await findOpenCodeBinaryWithVersion()
|
||||
return result?.version ?? null
|
||||
}
|
||||
|
||||
export async function addAuthPlugins(config: InstallConfig): Promise<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 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<boolean> {
|
||||
const result = await runBunInstallWithDetails()
|
||||
return result.success
|
||||
}
|
||||
|
||||
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
||||
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<string, unknown>
|
||||
|
||||
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<Record<string, unknown>>(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"
|
||||
|
||||
82
src/cli/config-manager/add-plugin-to-opencode-config.ts
Normal file
82
src/cli/config-manager/add-plugin-to-opencode-config.ts
Normal file
@@ -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<ConfigMergeResult> {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/cli/config-manager/add-provider-config.ts
Normal file
54
src/cli/config-manager/add-provider-config.ts
Normal file
@@ -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<string, unknown>
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/cli/config-manager/antigravity-provider-configuration.ts
Normal file
64
src/cli/config-manager/antigravity-provider-configuration.ts
Normal file
@@ -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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
64
src/cli/config-manager/auth-plugins.ts
Normal file
64
src/cli/config-manager/auth-plugins.ts
Normal file
@@ -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<string | null> {
|
||||
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<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 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/cli/config-manager/bun-install.ts
Normal file
60
src/cli/config-manager/bun-install.ts
Normal file
@@ -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<boolean> {
|
||||
const result = await runBunInstallWithDetails()
|
||||
return result.success
|
||||
}
|
||||
|
||||
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
||||
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`,
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/cli/config-manager/config-context.ts
Normal file
46
src/cli/config-manager/config-context.ts
Normal file
@@ -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
|
||||
}
|
||||
29
src/cli/config-manager/deep-merge-record.ts
Normal file
29
src/cli/config-manager/deep-merge-record.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export function deepMergeRecord<TTarget extends Record<string, unknown>>(
|
||||
target: TTarget,
|
||||
source: Partial<TTarget>
|
||||
): TTarget {
|
||||
const result: TTarget = { ...target }
|
||||
|
||||
for (const key of Object.keys(source) as Array<keyof TTarget>) {
|
||||
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<string, unknown>,
|
||||
sourceValue as Record<string, unknown>
|
||||
) as TTarget[keyof TTarget]
|
||||
} else if (sourceValue !== undefined) {
|
||||
result[key] = sourceValue as TTarget[keyof TTarget]
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
78
src/cli/config-manager/detect-current-config.ts
Normal file
78
src/cli/config-manager/detect-current-config.ts
Normal file
@@ -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<Record<string, unknown>>(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
|
||||
}
|
||||
9
src/cli/config-manager/ensure-config-directory-exists.ts
Normal file
9
src/cli/config-manager/ensure-config-directory-exists.ts
Normal file
@@ -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 })
|
||||
}
|
||||
}
|
||||
39
src/cli/config-manager/format-error-with-suggestion.ts
Normal file
39
src/cli/config-manager/format-error-with-suggestion.ts
Normal file
@@ -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}`
|
||||
}
|
||||
6
src/cli/config-manager/generate-omo-config.ts
Normal file
6
src/cli/config-manager/generate-omo-config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { InstallConfig } from "../types"
|
||||
import { generateModelConfig } from "../model-fallback"
|
||||
|
||||
export function generateOmoConfig(installConfig: InstallConfig): Record<string, unknown> {
|
||||
return generateModelConfig(installConfig)
|
||||
}
|
||||
21
src/cli/config-manager/npm-dist-tags.ts
Normal file
21
src/cli/config-manager/npm-dist-tags.ts
Normal file
@@ -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<NpmDistTags | null> {
|
||||
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
|
||||
}
|
||||
}
|
||||
40
src/cli/config-manager/opencode-binary.ts
Normal file
40
src/cli/config-manager/opencode-binary.ts
Normal file
@@ -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<OpenCodeBinaryResult | null> {
|
||||
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<boolean> {
|
||||
const result = await findOpenCodeBinaryWithVersion()
|
||||
return result !== null
|
||||
}
|
||||
|
||||
export async function getOpenCodeVersion(): Promise<string | null> {
|
||||
const result = await findOpenCodeBinaryWithVersion()
|
||||
return result?.version ?? null
|
||||
}
|
||||
17
src/cli/config-manager/opencode-config-format.ts
Normal file
17
src/cli/config-manager/opencode-config-format.ts
Normal file
@@ -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 }
|
||||
}
|
||||
48
src/cli/config-manager/parse-opencode-config-file.ts
Normal file
48
src/cli/config-manager/parse-opencode-config-file.ts
Normal file
@@ -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<OpenCodeConfig>(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}`) }
|
||||
}
|
||||
}
|
||||
19
src/cli/config-manager/plugin-name-with-version.ts
Normal file
19
src/cli/config-manager/plugin-name-with-version.ts
Normal file
@@ -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<string> {
|
||||
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}`
|
||||
}
|
||||
67
src/cli/config-manager/write-omo-config.ts
Normal file
67
src/cli/config-manager/write-omo-config.ts
Normal file
@@ -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<Record<string, unknown>>(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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
190
src/cli/index.ts
190
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 <value>", "Claude subscription: no, yes, max20")
|
||||
.option("--openai <value>", "OpenAI/ChatGPT subscription: no, yes (default: no)")
|
||||
.option("--gemini <value>", "Gemini integration: no, yes")
|
||||
.option("--copilot <value>", "GitHub Copilot subscription: no, yes")
|
||||
.option("--opencode-zen <value>", "OpenCode Zen access: no, yes (default: no)")
|
||||
.option("--zai-coding-plan <value>", "Z.ai Coding Plan subscription: no, yes (default: no)")
|
||||
.option("--kimi-for-coding <value>", "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 <message>")
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.description("Run opencode with todo/background task completion enforcement")
|
||||
.option("-a, --agent <name>", "Agent to use (default: from CLI/env/config, fallback: Sisyphus)")
|
||||
.option("-d, --directory <path>", "Working directory")
|
||||
.option("-t, --timeout <ms>", "Timeout in milliseconds (default: 30 minutes)", parseInt)
|
||||
.option("-p, --port <port>", "Server port (attaches if port already in use)", parseInt)
|
||||
.option("--attach <url>", "Attach to existing opencode server URL")
|
||||
.option("--on-complete <command>", "Shell command to run after completion")
|
||||
.option("--json", "Output structured JSON result to stdout")
|
||||
.option("--session-id <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 <path>", "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 <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()
|
||||
|
||||
@@ -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<typeof OhMyOpenCodeConfigSchema>
|
||||
export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>
|
||||
export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
|
||||
export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>
|
||||
export type AgentName = z.infer<typeof AgentNameSchema>
|
||||
export type HookName = z.infer<typeof HookNameSchema>
|
||||
export type BuiltinCommandName = z.infer<typeof BuiltinCommandNameSchema>
|
||||
export type BuiltinSkillName = z.infer<typeof BuiltinSkillNameSchema>
|
||||
export type SisyphusAgentConfig = z.infer<typeof SisyphusAgentConfigSchema>
|
||||
export type CommentCheckerConfig = z.infer<typeof CommentCheckerConfigSchema>
|
||||
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
|
||||
export type DynamicContextPruningConfig = z.infer<typeof DynamicContextPruningConfigSchema>
|
||||
export type SkillsConfig = z.infer<typeof SkillsConfigSchema>
|
||||
export type SkillDefinition = z.infer<typeof SkillDefinitionSchema>
|
||||
export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>
|
||||
export type NotificationConfig = z.infer<typeof NotificationConfigSchema>
|
||||
export type BabysittingConfig = z.infer<typeof BabysittingConfigSchema>
|
||||
export type CategoryConfig = z.infer<typeof CategoryConfigSchema>
|
||||
export type CategoriesConfig = z.infer<typeof CategoriesConfigSchema>
|
||||
export type BuiltinCategoryName = z.infer<typeof BuiltinCategoryNameSchema>
|
||||
export type GitMasterConfig = z.infer<typeof GitMasterConfigSchema>
|
||||
export type BrowserAutomationProvider = z.infer<typeof BrowserAutomationProviderSchema>
|
||||
export type BrowserAutomationConfig = z.infer<typeof BrowserAutomationConfigSchema>
|
||||
export type WebsearchProvider = z.infer<typeof WebsearchProviderSchema>
|
||||
export type WebsearchConfig = z.infer<typeof WebsearchConfigSchema>
|
||||
export type TmuxConfig = z.infer<typeof TmuxConfigSchema>
|
||||
export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>
|
||||
export type SisyphusTasksConfig = z.infer<typeof SisyphusTasksConfigSchema>
|
||||
export type SisyphusConfig = z.infer<typeof SisyphusConfigSchema>
|
||||
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"
|
||||
|
||||
44
src/config/schema/agent-names.ts
Normal file
44
src/config/schema/agent-names.ts
Normal file
@@ -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<typeof AgentNameSchema>
|
||||
|
||||
export type BuiltinSkillName = z.infer<typeof BuiltinSkillNameSchema>
|
||||
60
src/config/schema/agent-overrides.ts
Normal file
60
src/config/schema/agent-overrides.ts
Normal file
@@ -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<typeof AgentOverrideConfigSchema>
|
||||
export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
|
||||
7
src/config/schema/babysitting.ts
Normal file
7
src/config/schema/babysitting.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const BabysittingConfigSchema = z.object({
|
||||
timeout_ms: z.number().default(120000),
|
||||
})
|
||||
|
||||
export type BabysittingConfig = z.infer<typeof BabysittingConfigSchema>
|
||||
11
src/config/schema/background-task.ts
Normal file
11
src/config/schema/background-task.ts
Normal file
@@ -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<typeof BackgroundTaskConfigSchema>
|
||||
22
src/config/schema/browser-automation.ts
Normal file
22
src/config/schema/browser-automation.ts
Normal file
@@ -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<typeof BrowserAutomationConfigSchema>
|
||||
40
src/config/schema/categories.ts
Normal file
40
src/config/schema/categories.ts
Normal file
@@ -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<typeof CategoryConfigSchema>
|
||||
export type CategoriesConfig = z.infer<typeof CategoriesConfigSchema>
|
||||
export type BuiltinCategoryName = z.infer<typeof BuiltinCategoryNameSchema>
|
||||
13
src/config/schema/claude-code.ts
Normal file
13
src/config/schema/claude-code.ts
Normal file
@@ -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<typeof ClaudeCodeConfigSchema>
|
||||
13
src/config/schema/commands.ts
Normal file
13
src/config/schema/commands.ts
Normal file
@@ -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<typeof BuiltinCommandNameSchema>
|
||||
8
src/config/schema/comment-checker.ts
Normal file
8
src/config/schema/comment-checker.ts
Normal file
@@ -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<typeof CommentCheckerConfigSchema>
|
||||
55
src/config/schema/dynamic-context-pruning.ts
Normal file
55
src/config/schema/dynamic-context-pruning.ts
Normal file
@@ -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
|
||||
>
|
||||
20
src/config/schema/experimental.ts
Normal file
20
src/config/schema/experimental.ts
Normal file
@@ -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<typeof ExperimentalConfigSchema>
|
||||
10
src/config/schema/git-master.ts
Normal file
10
src/config/schema/git-master.ts
Normal file
@@ -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<typeof GitMasterConfigSchema>
|
||||
51
src/config/schema/hooks.ts
Normal file
51
src/config/schema/hooks.ts
Normal file
@@ -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<typeof HookNameSchema>
|
||||
20
src/config/schema/internal/permission.ts
Normal file
20
src/config/schema/internal/permission.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const PermissionValueSchema = z.enum(["ask", "allow", "deny"])
|
||||
export type PermissionValue = z.infer<typeof PermissionValueSchema>
|
||||
|
||||
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<typeof AgentPermissionSchema>
|
||||
8
src/config/schema/notification.ts
Normal file
8
src/config/schema/notification.ts
Normal file
@@ -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<typeof NotificationConfigSchema>
|
||||
57
src/config/schema/oh-my-opencode-config.ts
Normal file
57
src/config/schema/oh-my-opencode-config.ts
Normal file
@@ -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<typeof OhMyOpenCodeConfigSchema>
|
||||
12
src/config/schema/ralph-loop.ts
Normal file
12
src/config/schema/ralph-loop.ts
Normal file
@@ -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<typeof RalphLoopConfigSchema>
|
||||
10
src/config/schema/sisyphus-agent.ts
Normal file
10
src/config/schema/sisyphus-agent.ts
Normal file
@@ -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<typeof SisyphusAgentConfigSchema>
|
||||
17
src/config/schema/sisyphus.ts
Normal file
17
src/config/schema/sisyphus.ts
Normal file
@@ -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<typeof SisyphusTasksConfigSchema>
|
||||
export type SisyphusConfig = z.infer<typeof SisyphusConfigSchema>
|
||||
45
src/config/schema/skills.ts
Normal file
45
src/config/schema/skills.ts
Normal file
@@ -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<typeof SkillsConfigSchema>
|
||||
export type SkillDefinition = z.infer<typeof SkillDefinitionSchema>
|
||||
20
src/config/schema/tmux.ts
Normal file
20
src/config/schema/tmux.ts
Normal file
@@ -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<typeof TmuxConfigSchema>
|
||||
export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>
|
||||
15
src/config/schema/websearch.ts
Normal file
15
src/config/schema/websearch.ts
Normal file
@@ -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<typeof WebsearchProviderSchema>
|
||||
export type WebsearchConfig = z.infer<typeof WebsearchConfigSchema>
|
||||
25
src/hooks/atlas/atlas-hook.ts
Normal file
25
src/hooks/atlas/atlas-hook.ts
Normal file
@@ -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<string, SessionState>()
|
||||
const pendingFilePaths = new Map<string, string>()
|
||||
|
||||
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 }),
|
||||
}
|
||||
}
|
||||
68
src/hooks/atlas/boulder-continuation-injector.ts
Normal file
68
src/hooks/atlas/boulder-continuation-injector.ts
Normal file
@@ -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<void> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
187
src/hooks/atlas/event-handler.ts
Normal file
187
src/hooks/atlas/event-handler.ts
Normal file
@@ -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<string, SessionState>
|
||||
getState: (sessionID: string) => SessionState
|
||||
}): (arg: { event: { type: string; properties?: unknown } }) => Promise<void> {
|
||||
const { ctx, options, sessions, getState } = input
|
||||
|
||||
return async ({ event }): Promise<void> => {
|
||||
const props = event.properties as Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
108
src/hooks/atlas/git-diff-stats.ts
Normal file
108
src/hooks/atlas/git-diff-stats.ts
Normal file
@@ -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<string, "modified" | "added" | "deleted">()
|
||||
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")
|
||||
}
|
||||
1
src/hooks/atlas/hook-name.ts
Normal file
1
src/hooks/atlas/hook-name.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const HOOK_NAME = "atlas"
|
||||
@@ -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] ?? "<session_id>"
|
||||
}
|
||||
|
||||
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<string, "modified" | "added" | "deleted">()
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
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<string, SessionState>()
|
||||
const pendingFilePaths = new Map<string, string>()
|
||||
|
||||
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<void> {
|
||||
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<void> => {
|
||||
const props = event.properties as Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown>; message?: string }
|
||||
): Promise<void> => {
|
||||
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 = `<system-reminder>${SINGLE_TASK_DIRECTIVE}</system-reminder>\n` + prompt
|
||||
log(`[${HOOK_NAME}] Injected single-task directive to task`, {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (
|
||||
input: ToolExecuteAfterInput,
|
||||
output: ToolExecuteAfterOutput
|
||||
): Promise<void> => {
|
||||
// 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}
|
||||
|
||||
<system-reminder>
|
||||
${buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId)}
|
||||
</system-reminder>`
|
||||
|
||||
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<system-reminder>\n${buildStandaloneVerificationReminder(subagentSessionId)}\n</system-reminder>`
|
||||
|
||||
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"
|
||||
|
||||
20
src/hooks/atlas/is-abort-error.ts
Normal file
20
src/hooks/atlas/is-abort-error.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
if (!error) return false
|
||||
|
||||
if (typeof error === "object") {
|
||||
const errObj = error as Record<string, unknown>
|
||||
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
|
||||
}
|
||||
38
src/hooks/atlas/recent-model-resolver.ts
Normal file
38
src/hooks/atlas/recent-model-resolver.ts
Normal file
@@ -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<ModelInfo | undefined> {
|
||||
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 }
|
||||
}
|
||||
9
src/hooks/atlas/session-last-agent.ts
Normal file
9
src/hooks/atlas/session-last-agent.ts
Normal file
@@ -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
|
||||
}
|
||||
7
src/hooks/atlas/sisyphus-path.ts
Normal file
7
src/hooks/atlas/sisyphus-path.ts
Normal file
@@ -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)
|
||||
}
|
||||
4
src/hooks/atlas/subagent-session-id.ts
Normal file
4
src/hooks/atlas/subagent-session-id.ts
Normal file
@@ -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] ?? "<session_id>"
|
||||
}
|
||||
154
src/hooks/atlas/system-reminder-templates.ts
Normal file
154
src/hooks/atlas/system-reminder-templates.ts
Normal file
@@ -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.**
|
||||
`
|
||||
109
src/hooks/atlas/tool-execute-after.ts
Normal file
109
src/hooks/atlas/tool-execute-after.ts
Normal file
@@ -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<string, string>
|
||||
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise<void> {
|
||||
const { ctx, pendingFilePaths } = input
|
||||
|
||||
return async (toolInput, toolOutput): Promise<void> => {
|
||||
// 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}
|
||||
|
||||
<system-reminder>
|
||||
${buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId)}
|
||||
</system-reminder>`
|
||||
|
||||
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<system-reminder>\n${buildStandaloneVerificationReminder(subagentSessionId)}\n</system-reminder>`
|
||||
|
||||
log(`[${HOOK_NAME}] Verification reminder appended for orchestrator`, {
|
||||
sessionID: toolInput.sessionID,
|
||||
fileCount: gitStats.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/hooks/atlas/tool-execute-before.ts
Normal file
52
src/hooks/atlas/tool-execute-before.ts
Normal file
@@ -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<string, string>
|
||||
}): (
|
||||
toolInput: { tool: string; sessionID?: string; callID?: string },
|
||||
toolOutput: { args: Record<string, unknown>; message?: string }
|
||||
) => Promise<void> {
|
||||
const { pendingFilePaths } = input
|
||||
|
||||
return async (toolInput, toolOutput): Promise<void> => {
|
||||
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 = `<system-reminder>${SINGLE_TASK_DIRECTIVE}</system-reminder>\n` + prompt
|
||||
log(`[${HOOK_NAME}] Injected single-task directive to task`, {
|
||||
sessionID: toolInput.sessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/hooks/atlas/types.ts
Normal file
27
src/hooks/atlas/types.ts
Normal file
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
lastEventWasAbortError?: boolean
|
||||
lastContinuationInjectedAt?: number
|
||||
promptFailureCount: number
|
||||
}
|
||||
83
src/hooks/atlas/verification-reminders.ts
Normal file
83
src/hooks/atlas/verification-reminders.ts
Normal file
@@ -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.**`
|
||||
}
|
||||
5
src/hooks/atlas/write-edit-tool-policy.ts
Normal file
5
src/hooks/atlas/write-edit-tool-policy.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"]
|
||||
|
||||
export function isWriteOrEditToolName(toolName: string): boolean {
|
||||
return WRITE_EDIT_TOOLS.includes(toolName)
|
||||
}
|
||||
@@ -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<string | null> {
|
||||
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<UpdateCheckResult> {
|
||||
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"
|
||||
|
||||
45
src/hooks/auto-update-checker/checker/cached-version.ts
Normal file
45
src/hooks/auto-update-checker/checker/cached-version.ts
Normal file
@@ -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
|
||||
}
|
||||
69
src/hooks/auto-update-checker/checker/check-for-update.ts
Normal file
69
src/hooks/auto-update-checker/checker/check-for-update.ts
Normal file
@@ -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<UpdateCheckResult> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
37
src/hooks/auto-update-checker/checker/config-paths.ts
Normal file
37
src/hooks/auto-update-checker/checker/config-paths.ts
Normal file
@@ -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
|
||||
}
|
||||
7
src/hooks/auto-update-checker/checker/jsonc-strip.ts
Normal file
7
src/hooks/auto-update-checker/checker/jsonc-strip.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function stripJsonComments(json: string): string {
|
||||
return json
|
||||
.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (match, group) =>
|
||||
group ? "" : match
|
||||
)
|
||||
.replace(/,(\s*[}\]])/g, "$1")
|
||||
}
|
||||
23
src/hooks/auto-update-checker/checker/latest-version.ts
Normal file
23
src/hooks/auto-update-checker/checker/latest-version.ts
Normal file
@@ -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<string | null> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
35
src/hooks/auto-update-checker/checker/local-dev-path.ts
Normal file
35
src/hooks/auto-update-checker/checker/local-dev-path.ts
Normal file
@@ -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
|
||||
}
|
||||
19
src/hooks/auto-update-checker/checker/local-dev-version.ts
Normal file
19
src/hooks/auto-update-checker/checker/local-dev-version.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
38
src/hooks/auto-update-checker/checker/plugin-entry.ts
Normal file
38
src/hooks/auto-update-checker/checker/plugin-entry.ts
Normal file
@@ -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
|
||||
}
|
||||
64
src/hooks/auto-update-checker/hook.ts
Normal file
64
src/hooks/auto-update-checker/hook.ts
Normal file
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
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<void> {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
23
src/hooks/auto-update-checker/hook/config-errors-toast.ts
Normal file
23
src/hooks/auto-update-checker/hook/config-errors-toast.ts
Normal file
@@ -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<void> {
|
||||
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()
|
||||
}
|
||||
@@ -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<void> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
21
src/hooks/auto-update-checker/hook/model-cache-warning.ts
Normal file
21
src/hooks/auto-update-checker/hook/model-cache-warning.ts
Normal file
@@ -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<void> {
|
||||
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")
|
||||
}
|
||||
25
src/hooks/auto-update-checker/hook/spinner-toast.ts
Normal file
25
src/hooks/auto-update-checker/hook/spinner-toast.ts
Normal file
@@ -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<void> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
22
src/hooks/auto-update-checker/hook/startup-toasts.ts
Normal file
22
src/hooks/auto-update-checker/hook/startup-toasts.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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}`)
|
||||
}
|
||||
34
src/hooks/auto-update-checker/hook/update-toasts.ts
Normal file
34
src/hooks/auto-update-checker/hook/update-toasts.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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}`)
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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"
|
||||
|
||||
33
src/hooks/auto-update-checker/version-channel.ts
Normal file
33
src/hooks/auto-update-checker/version-channel.ts
Normal file
@@ -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"
|
||||
}
|
||||
421
src/hooks/claude-code-hooks/claude-code-hooks-hook.ts
Normal file
421
src/hooks/claude-code-hooks/claude-code-hooks-hook.ts
Normal file
@@ -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<string>()
|
||||
const sessionErrorState = new Map<string, { hasError: boolean; errorMessage?: string }>()
|
||||
const sessionInterruptState = new Map<string, { interrupted: boolean }>()
|
||||
|
||||
export function createClaudeCodeHooksHook(
|
||||
ctx: PluginInput,
|
||||
config: PluginConfig = {},
|
||||
contextCollector?: ContextCollector
|
||||
) {
|
||||
return {
|
||||
"experimental.session.compacting": async (
|
||||
input: { sessionID: string },
|
||||
output: { context: string[] }
|
||||
): Promise<void> => {
|
||||
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<string, unknown>
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
}
|
||||
): Promise<void> => {
|
||||
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<string, unknown> }
|
||||
): Promise<void> => {
|
||||
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<string, unknown>,
|
||||
})
|
||||
|
||||
cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record<string, unknown>)
|
||||
|
||||
if (!isHookDisabled(config, "PreToolUse")) {
|
||||
const preCtx: PreToolUseContext = {
|
||||
sessionId: input.sessionID,
|
||||
toolName: input.tool,
|
||||
toolInput: output.args as Record<string, unknown>,
|
||||
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<string, unknown>, result.modifiedInput)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
): Promise<void> => {
|
||||
// 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<string, unknown> | 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<string, unknown>,
|
||||
},
|
||||
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<string>()
|
||||
const sessionErrorState = new Map<string, { hasError: boolean; errorMessage?: string }>()
|
||||
const sessionInterruptState = new Map<string, { interrupted: boolean }>()
|
||||
|
||||
export function createClaudeCodeHooksHook(
|
||||
ctx: PluginInput,
|
||||
config: PluginConfig = {},
|
||||
contextCollector?: ContextCollector
|
||||
) {
|
||||
return {
|
||||
"experimental.session.compacting": async (
|
||||
input: { sessionID: string },
|
||||
output: { context: string[] }
|
||||
): Promise<void> => {
|
||||
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<string, unknown>
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
}
|
||||
): Promise<void> => {
|
||||
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<string, unknown> }
|
||||
): Promise<void> => {
|
||||
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<string, unknown>,
|
||||
})
|
||||
|
||||
cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record<string, unknown>)
|
||||
|
||||
if (!isHookDisabled(config, "PreToolUse")) {
|
||||
const preCtx: PreToolUseContext = {
|
||||
sessionId: input.sessionID,
|
||||
toolName: input.tool,
|
||||
toolInput: output.args as Record<string, unknown>,
|
||||
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<string, unknown>, result.modifiedInput)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
): Promise<void> => {
|
||||
// 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<string, unknown> | 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<string, unknown>,
|
||||
},
|
||||
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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"
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, InteractiveBashSessionState>();
|
||||
|
||||
function getOrCreateState(sessionID: string): InteractiveBashSessionState {
|
||||
if (!sessionStates.has(sessionID)) {
|
||||
const persisted = loadInteractiveBashSessionState(sessionID);
|
||||
const state: InteractiveBashSessionState = persisted ?? {
|
||||
sessionID,
|
||||
tmuxSessions: new Set<string>(),
|
||||
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<void> {
|
||||
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<string, unknown> | 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"
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string>(["-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<string, InteractiveBashSessionState>();
|
||||
|
||||
function getOrCreateState(sessionID: string): InteractiveBashSessionState {
|
||||
if (!sessionStates.has(sessionID)) {
|
||||
const persisted = loadInteractiveBashSessionState(sessionID);
|
||||
const state: InteractiveBashSessionState = persisted ?? {
|
||||
sessionID,
|
||||
tmuxSessions: new Set<string>(),
|
||||
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<void> {
|
||||
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<string, unknown> | 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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>; message?: string }
|
||||
): Promise<void> => {
|
||||
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"
|
||||
|
||||
66
src/hooks/non-interactive-env/non-interactive-env-hook.ts
Normal file
66
src/hooks/non-interactive-env/non-interactive-env-hook.ts
Normal file
@@ -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<string, unknown>; message?: string }
|
||||
): Promise<void> => {
|
||||
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,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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>{{PROMISE}}</promise>
|
||||
- Do not stop until the task is truly done
|
||||
|
||||
Original task:
|
||||
{{PROMPT}}`
|
||||
|
||||
export interface RalphLoopHook {
|
||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
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<string, SessionState>()
|
||||
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(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "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<boolean> {
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
}),
|
||||
new Promise<never>((_, 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(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "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<void> => {
|
||||
const props = event.properties as Record<string, unknown> | 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"
|
||||
|
||||
428
src/hooks/ralph-loop/ralph-loop-hook.ts
Normal file
428
src/hooks/ralph-loop/ralph-loop-hook.ts
Normal file
@@ -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>{{PROMISE}}</promise>
|
||||
- Do not stop until the task is truly done
|
||||
|
||||
Original task:
|
||||
{{PROMPT}}`
|
||||
|
||||
export interface RalphLoopHook {
|
||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
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<string, SessionState>()
|
||||
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(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "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<boolean> {
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
}),
|
||||
new Promise<never>((_, 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(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "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<void> => {
|
||||
const props = event.properties as Record<string, unknown> | 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,
|
||||
}
|
||||
}
|
||||
65
src/hooks/session-recovery/detect-error-type.ts
Normal file
65
src/hooks/session-recovery/detect-error-type.ts
Normal file
@@ -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<string, unknown>
|
||||
const paths = [
|
||||
errorObj.data,
|
||||
errorObj.error,
|
||||
errorObj,
|
||||
(errorObj.data as Record<string, unknown>)?.error,
|
||||
]
|
||||
|
||||
for (const obj of paths) {
|
||||
if (obj && typeof obj === "object") {
|
||||
const msg = (obj as Record<string, unknown>).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
|
||||
}
|
||||
141
src/hooks/session-recovery/hook.ts
Normal file
141
src/hooks/session-recovery/hook.ts
Normal file
@@ -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<boolean>
|
||||
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<string>()
|
||||
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<boolean> => {
|
||||
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<RecoveryErrorType & string, string> = {
|
||||
tool_result_missing: "Tool Crash Recovery",
|
||||
thinking_block_order: "Thinking Block Recovery",
|
||||
thinking_disabled_violation: "Thinking Strip Recovery",
|
||||
}
|
||||
const toastMessages: Record<RecoveryErrorType & string, string> = {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<typeof createOpencodeClient>
|
||||
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
id?: string
|
||||
text?: string
|
||||
thinking?: string
|
||||
name?: string
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
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<boolean> {
|
||||
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<string, unknown>
|
||||
const paths = [
|
||||
errorObj.data,
|
||||
errorObj.error,
|
||||
errorObj,
|
||||
(errorObj.data as Record<string, unknown>)?.error,
|
||||
]
|
||||
|
||||
for (const obj of paths) {
|
||||
if (obj && typeof obj === "object") {
|
||||
const msg = (obj as Record<string, unknown>).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<boolean> {
|
||||
// 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<string, unknown> } }).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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean>
|
||||
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<string>()
|
||||
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<boolean> => {
|
||||
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<RecoveryErrorType & string, string> = {
|
||||
tool_result_missing: "Tool Crash Recovery",
|
||||
thinking_block_order: "Thinking Block Recovery",
|
||||
thinking_disabled_violation: "Thinking Strip Recovery",
|
||||
}
|
||||
const toastMessages: Record<RecoveryErrorType & string, string> = {
|
||||
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"
|
||||
|
||||
74
src/hooks/session-recovery/recover-empty-content-message.ts
Normal file
74
src/hooks/session-recovery/recover-empty-content-message.ts
Normal file
@@ -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<typeof createOpencodeClient>
|
||||
|
||||
const PLACEHOLDER_TEXT = "[user interrupted]"
|
||||
|
||||
export async function recoverEmptyContentMessage(
|
||||
_client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
_directory: string,
|
||||
error: unknown
|
||||
): Promise<boolean> {
|
||||
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
|
||||
}
|
||||
36
src/hooks/session-recovery/recover-thinking-block-order.ts
Normal file
36
src/hooks/session-recovery/recover-thinking-block-order.ts
Normal file
@@ -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<typeof createOpencodeClient>
|
||||
|
||||
export async function recoverThinkingBlockOrder(
|
||||
_client: Client,
|
||||
sessionID: string,
|
||||
_failedAssistantMsg: MessageData,
|
||||
_directory: string,
|
||||
error: unknown
|
||||
): Promise<boolean> {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import type { MessageData } from "./types"
|
||||
import { findMessagesWithThinkingBlocks, stripThinkingParts } from "./storage"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
export async function recoverThinkingDisabledViolation(
|
||||
_client: Client,
|
||||
sessionID: string,
|
||||
_failedAssistantMsg: MessageData
|
||||
): Promise<boolean> {
|
||||
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
|
||||
}
|
||||
61
src/hooks/session-recovery/recover-tool-result-missing.ts
Normal file
61
src/hooks/session-recovery/recover-tool-result-missing.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import type { MessageData } from "./types"
|
||||
import { readParts } from "./storage"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
interface ToolUsePart {
|
||||
type: "tool_use"
|
||||
id: string
|
||||
name: string
|
||||
input: Record<string, unknown>
|
||||
}
|
||||
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
}
|
||||
39
src/hooks/session-recovery/resume.ts
Normal file
39
src/hooks/session-recovery/resume.ts
Normal file
@@ -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<typeof createOpencodeClient>
|
||||
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user