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:
parent
29155ec7bc
commit
119e18c810
@ -1,50 +1,4 @@
|
|||||||
/**
|
export { PROMETHEUS_SYSTEM_PROMPT, PROMETHEUS_PERMISSION } from "./system-prompt"
|
||||||
* 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export individual sections for granular access
|
// Re-export individual sections for granular access
|
||||||
export { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints"
|
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"
|
export type { ConfigContext } from "./config-manager/config-context"
|
||||||
import { parseJsonc, getOpenCodeConfigPaths } from "../shared"
|
export {
|
||||||
import type {
|
initConfigContext,
|
||||||
OpenCodeBinaryType,
|
getConfigContext,
|
||||||
OpenCodeConfigPaths,
|
resetConfigContext,
|
||||||
} from "../shared/opencode-config-dir-types"
|
} from "./config-manager/config-context"
|
||||||
import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
|
|
||||||
import { generateModelConfig } from "./model-fallback"
|
|
||||||
|
|
||||||
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 {
|
export { generateOmoConfig } from "./config-manager/generate-omo-config"
|
||||||
binary: OpenCodeBinaryType
|
export { writeOmoConfig } from "./config-manager/write-omo-config"
|
||||||
version: string | null
|
|
||||||
paths: OpenCodeConfigPaths
|
|
||||||
}
|
|
||||||
|
|
||||||
let configContext: ConfigContext | null = null
|
export { isOpenCodeInstalled, getOpenCodeVersion } from "./config-manager/opencode-binary"
|
||||||
|
|
||||||
export function initConfigContext(binary: OpenCodeBinaryType, version: string | null): void {
|
export { fetchLatestVersion, addAuthPlugins } from "./config-manager/auth-plugins"
|
||||||
const paths = getOpenCodeConfigPaths({ binary, version })
|
export { ANTIGRAVITY_PROVIDER_CONFIG } from "./config-manager/antigravity-provider-configuration"
|
||||||
configContext = { binary, version, paths }
|
export { addProviderConfig } from "./config-manager/add-provider-config"
|
||||||
}
|
export { detectCurrentConfig } from "./config-manager/detect-current-config"
|
||||||
|
|
||||||
export function getConfigContext(): ConfigContext {
|
export type { BunInstallResult } from "./config-manager/bun-install"
|
||||||
if (!configContext) {
|
export { runBunInstall, runBunInstallWithDetails } from "./config-manager/bun-install"
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
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
|
#!/usr/bin/env bun
|
||||||
import { Command } from "commander"
|
import { runCli } from "./cli-program"
|
||||||
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
|
runCli()
|
||||||
|
|
||||||
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()
|
|
||||||
|
|||||||
@ -1,464 +1,23 @@
|
|||||||
import { z } from "zod"
|
export * from "./schema/agent-names"
|
||||||
import { AnyMcpNameSchema, McpNameSchema } from "../mcp/types"
|
export * from "./schema/agent-overrides"
|
||||||
|
export * from "./schema/babysitting"
|
||||||
const PermissionValue = z.enum(["ask", "allow", "deny"])
|
export * from "./schema/background-task"
|
||||||
|
export * from "./schema/browser-automation"
|
||||||
const BashPermission = z.union([
|
export * from "./schema/categories"
|
||||||
PermissionValue,
|
export * from "./schema/claude-code"
|
||||||
z.record(z.string(), PermissionValue),
|
export * from "./schema/comment-checker"
|
||||||
])
|
export * from "./schema/commands"
|
||||||
|
export * from "./schema/dynamic-context-pruning"
|
||||||
const AgentPermissionSchema = z.object({
|
export * from "./schema/experimental"
|
||||||
edit: PermissionValue.optional(),
|
export * from "./schema/git-master"
|
||||||
bash: BashPermission.optional(),
|
export * from "./schema/hooks"
|
||||||
webfetch: PermissionValue.optional(),
|
export * from "./schema/notification"
|
||||||
task: PermissionValue.optional(),
|
export * from "./schema/oh-my-opencode-config"
|
||||||
doom_loop: PermissionValue.optional(),
|
export * from "./schema/ralph-loop"
|
||||||
external_directory: PermissionValue.optional(),
|
export * from "./schema/skills"
|
||||||
})
|
export * from "./schema/sisyphus"
|
||||||
|
export * from "./schema/sisyphus-agent"
|
||||||
export const BuiltinAgentNameSchema = z.enum([
|
export * from "./schema/tmux"
|
||||||
"sisyphus",
|
export * from "./schema/websearch"
|
||||||
"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 { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types"
|
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"
|
export { HOOK_NAME } from "./hook-name"
|
||||||
import { execSync } from "node:child_process"
|
export { createAtlasHook } from "./atlas-hook"
|
||||||
import { existsSync, readdirSync } from "node:fs"
|
export type { AtlasHookOptions } from "./types"
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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"
|
export { isLocalDevMode, getLocalDevPath } from "./checker/local-dev-path"
|
||||||
import * as path from "node:path"
|
export { getLocalDevVersion } from "./checker/local-dev-version"
|
||||||
import { fileURLToPath } from "node:url"
|
export { findPluginEntry } from "./checker/plugin-entry"
|
||||||
import type { NpmDistTags, OpencodeConfig, PackageJson, UpdateCheckResult } from "./types"
|
export type { PluginEntryInfo } from "./checker/plugin-entry"
|
||||||
import {
|
export { getCachedVersion } from "./checker/cached-version"
|
||||||
PACKAGE_NAME,
|
export { updatePinnedVersion } from "./checker/pinned-version-updater"
|
||||||
NPM_REGISTRY_URL,
|
export { getLatestVersion } from "./checker/latest-version"
|
||||||
NPM_FETCH_TIMEOUT,
|
export { checkForUpdate } from "./checker/check-for-update"
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|||||||
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"
|
export { createAutoUpdateCheckerHook } from "./hook"
|
||||||
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"
|
|
||||||
|
|
||||||
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 { checkForUpdate } from "./checker"
|
||||||
export { invalidatePackage, invalidateCache } from "./cache"
|
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"
|
export { createClaudeCodeHooksHook } from "./claude-code-hooks-hook"
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,267 +1 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin";
|
export { createInteractiveBashSessionHook } from "./interactive-bash-session-hook"
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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 "./constants"
|
||||||
export * from "./detector"
|
export * from "./detector"
|
||||||
export * from "./types"
|
export * from "./types"
|
||||||
|
|
||||||
const BANNED_COMMAND_PATTERNS = SHELL_COMMAND_PATTERNS.banned
|
export { createNonInteractiveEnvHook } from "./non-interactive-env-hook"
|
||||||
.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,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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 "./types"
|
||||||
export * from "./constants"
|
export * from "./constants"
|
||||||
export { readState, writeState, clearState, incrementIteration } from "./storage"
|
export { readState, writeState, clearState, incrementIteration } from "./storage"
|
||||||
|
|
||||||
interface SessionState {
|
export { createRalphLoopHook } from "./ralph-loop-hook"
|
||||||
isRecovering?: boolean
|
export type { RalphLoopHook } from "./ralph-loop-hook"
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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"
|
export { createSessionRecoveryHook } from "./hook"
|
||||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
export type { SessionRecoveryHook, SessionRecoveryOptions } from "./hook"
|
||||||
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 interface SessionRecoveryOptions {
|
export { detectErrorType } from "./detect-error-type"
|
||||||
experimental?: ExperimentalConfig
|
export type { RecoveryErrorType } from "./detect-error-type"
|
||||||
}
|
|
||||||
|
|
||||||
type Client = ReturnType<typeof createOpencodeClient>
|
export type { MessageData, ResumeConfig } from "./types"
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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"
|
export { generatePartId } from "./storage/part-id"
|
||||||
import { join } from "node:path"
|
export { getMessageDir } from "./storage/message-dir"
|
||||||
import { MESSAGE_STORAGE, PART_STORAGE, THINKING_TYPES, META_TYPES } from "./constants"
|
export { readMessages } from "./storage/messages-reader"
|
||||||
import type { StoredMessageMeta, StoredPart, StoredTextPart } from "./types"
|
export { readParts } from "./storage/parts-reader"
|
||||||
|
export { hasContent, messageHasContent } from "./storage/part-content"
|
||||||
export function generatePartId(): string {
|
export { injectTextPart } from "./storage/text-part-injector"
|
||||||
const timestamp = Date.now().toString(16)
|
|
||||||
const random = Math.random().toString(36).substring(2, 10)
|
export {
|
||||||
return `prt_${timestamp}${random}`
|
findEmptyMessages,
|
||||||
}
|
findEmptyMessageByIndex,
|
||||||
|
findFirstEmptyMessage,
|
||||||
export function getMessageDir(sessionID: string): string {
|
} from "./storage/empty-messages"
|
||||||
if (!existsSync(MESSAGE_STORAGE)) return ""
|
export { findMessagesWithEmptyTextParts } from "./storage/empty-text"
|
||||||
|
|
||||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
export {
|
||||||
if (existsSync(directPath)) {
|
findMessagesWithThinkingBlocks,
|
||||||
return directPath
|
findMessagesWithThinkingOnly,
|
||||||
}
|
} from "./storage/thinking-block-search"
|
||||||
|
export {
|
||||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
findMessagesWithOrphanThinking,
|
||||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
findMessageByIndexNeedingThinking,
|
||||||
if (existsSync(sessionPath)) {
|
} from "./storage/orphan-thinking-search"
|
||||||
return sessionPath
|
|
||||||
}
|
export { prependThinkingPart } from "./storage/thinking-prepend"
|
||||||
}
|
export { stripThinkingParts } from "./storage/thinking-strip"
|
||||||
|
export { replaceEmptyTextParts } from "./storage/empty-text"
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user